2013 年终总结

日志

09年毕业,工作4年多,以前都没写过年度总结。今年开始要在每年年底最后一周完成该年度的总结。诚如我的QQ空间的签名“有些事不记下来就象什么也发生过”,现在想想年初做过什么已经比较模糊了,希望用文字把记忆变得清晰点。

工作

本来这份工作面试时是说做移动网盘(叫彩云)后台的,结果各种原因项目留在南京,接不回来,新建的团队没有可以长期做的项目,做了几个小项目后在去年底就解散了。今年转到另一个项目组,主要是做彩云的web portal,也就是彩云的web客户端,这个东西每隔2、3个月就大改一次,要不然我早失业了。职责主要是写JS做业务展现、写一些Java代码调后台接口,开发速度相对还是比较快的,而且人也多,不会出现一个人负责很多模块。所以,上班时可以支配的时间还是不少的。

自我学习

既然上班都有可以支配的时间,自然还是要学点东西的。

总体情况

今年在工具的使用上有很大进步。早上上班前、中午都会刷下微博,可以了解下业界的新东西、别人的分享等等。学习了Markdown书写法,用印象笔记做了大量的笔记,搭建了自己的个人博客,到目前累计发表了94篇文章,访问量也过万了,还是挺满意的。8月份买了kindle,看了几本电子书,效果很赞的。早上搭公车也用微信看一些公共帐号。feedly的订阅更多了,要有选择地阅读。
继续阅读

JUC LinkedBlockingQueue

java.util.concurrent.LinkedBlockingQueue 是一个基于单向链表的、范围任意的(其实是有界的)、FIFO 阻塞队列。访问与移除操作是在队头进行,添加操作是在队尾进行,并分别使用不同的锁进行保护,只有在可能涉及多个节点的操作才同时对两个锁进行加锁。

队列是否为空、是否已满仍然是通过元素数量的计数器(count)进行判断的,由于可以同时在队头、队尾并发地进行访问、添加操作,所以这个计数器必须是线程安全的,这里使用了一个原子类 AtomicInteger,这就决定了它的容量范围是: 1 – Integer.MAX_VALUE。

由于同时使用了两把锁,在需要同时使用两把锁时,加锁顺序与释放顺序是非常重要的:必须以固定的顺序进行加锁,再以与加锁顺序的相反的顺序释放锁。

头结点和尾结点一开始总是指向一个哨兵的结点,它不持有实际数据,当队列中有数据时,头结点仍然指向这个哨兵,尾结点指向有效数据的最后一个结点。这样做的好处在于,与计数器 count 结合后,对队头、队尾的访问可以独立进行,而不需要判断头结点与尾结点的关系。
继续阅读

JUC ArrayBlockingQueue

java.util.concurrent.ArrayBlockingQueue 是一个线程安全的、基于数组、有界的、阻塞的、FIFO 队列。试图向已满队列中放入元素会导致操作受阻塞;试图从空队列中提取元素将导致类似阻塞。

此类基于 java.util.concurrent.locks.ReentrantLock 来实现线程安全,所以提供了 ReentrantLock 所能支持的公平性选择。

属性

队列的操作主要有读、写,所以用了两个 int 类型的属性作为下一个读写位置的的指针。存放元素的数组是 final 修饰的,所以数组的大小是固定的。对于并发控制,是所有的访问都必须加锁,并用两个条件对象用于协调读写操作。

// 队列存放元素的容器
final Object[] items;

// 下一次读取或移除的位置
int takeIndex;

// 存放下一个放入元素的位置
int putIndex;

// 队列里有效元素的数量
int count;


// 所有访问的保护锁
final ReentrantLock lock;

// 等待获取的条件
private final Condition notEmpty;

// 等待放入的条件
private final Condition notFull;

环绕处理

如果指针一直往前增加或一直往后减小,那么总会超出数组的有效索引范围。所以需要进行一些环绕处理。

// 指针前移
final int inc(int i) {
    return (++i == items.length) ? 0 : i;
}

// 指针后移
final int dec(int i) {
    return ((i == 0) ? items.length : i) - 1;
}

注意,上面的处理都是对指针值的直接处理,而不关心是读指针还是写指针,因为是否有可读元素、可写空间的判断是通过对 count 计数来判断的。

这也是 count 的作用,它极大地简化了指针有效性的判断。在下面的 insertextract 方法中根本就不需要对读写指针之间的位置关系进行判断,非常精妙。

通过环绕处理可以把这个数组看成是圆形的缓存。
继续阅读

I/O 基础

缓冲区操作

缓冲区以及缓冲区是如何工作,是所有I/O的基础。“输入/输出”就是把数据移进或移出缓冲区。

进程执行I/O操作,就是向操作系统发出请求,让它要么把缓冲区的数据排干(写),要么用数据把缓冲区填满(读)。进程使用这一机制处理所有数据进出操作。

从磁盘读数据到进程内存区:
read-from-disk-into-user-process

进程使用 read( ) 系统调用,要求其缓冲区被填满。内核随即向磁盘控制硬件发出命令,要求其从磁盘读取数据。磁盘控制器把数据直接写入内核内存缓冲区,这一步通过DMA完成,无需主CPU 协助。一旦磁盘控制器把缓冲区装满,内核即把数据从内核空间的临时缓冲区拷贝到进程执行 read( ) 调用时指定的缓冲区。

用户空间与内核空间

  • 用户空间:用户空间是常规进程所在区域,是非特权区域,比如该区域的代码不能直接访问硬件设备。
  • 内核空间:内核空间是操作系统所在区域,有特别的权利:能与设备控制器通讯,控制用户区域进程的运行状态等等。

所有I/O都直接或间接通过内核空间,通过请求页面调度完成。

当进程请求I/O操作的时候,它执行一个系统调用将控制权移交给内核。内核随即采取必要步骤,找到进程所需数据,并把数据传送到用户空间内指定的缓冲区。如果数据已在内核空间,直接拷贝即可;如果不在内核空间,则进程被挂起,内核着手把数据读进内存。

发散、汇聚

根据发散、汇聚的概念,进程只需一个系统调用,就能把一连串缓冲区地址传递给操作系统,然后内核就可以顺序填充或排干多个缓冲区,读的时候把数据发散到多个用户空间缓冲区,写的时候再从多个缓冲区把数据汇聚起来。

scatter-gather

继续阅读

JUC 源码分析 四 wait notify notifyAll 与 条件对象

内置锁 与 wait notify 机制

每个Java对象都有一个内置锁,通过 synchronized 关键字使用。线程之间可以通过 Object 类的 wait, notify, notifyAll 进行协调。

wait, notify, notifyAll 方法说明:

  • wait:在其他线程调用此对象的 notify() 方法或 notifyAll() 方法前,导致当前线程等待。当前线程必须拥有此对象监视器。该线程发布对此监视器的所有权并等待,直到其他线程通过调用 notify 方法,或 notifyAll 方法通知在此对象的监视器上等待的线程醒来。然后该线程将等到重新获得对监视器的所有权后才能继续执行。
  • notify:唤醒在此对象监视器上等待的单个线程,直到当前线程放弃此对象上的锁定,才能继续执行被唤醒的线程。如果所有线程都在此对象上等待,则会选择唤醒其中一个线程,选择是任意性的,被唤醒的线程将以常规方式与在该对象上主动同步的其他所有线程进行竞争。
  • notifyAll:唤醒在此对象监视器上等待的所有线程,直到当前线程放弃此对象上的锁定,才能继续执行被唤醒的线程。被唤醒的线程将以常规方式与在该对象上主动同步的其他所有线程进行竞争。

以一个有界缓存为例,展示了内置锁和 wait、notify、notifyAll 的一般用法:

public class BoundedBuffer<T> {
       private final Object[] buffer;
       private final int length;

       public BoundedBuffer(int length ) {
             if (length < 0) {
                   throw new IllegalArgumentException("length < 0");
            }
             this.length = length ;
             buffer = new Object[length ];
      }

       // synchronized 用于方法上,表示一个同步方法,线程进入方法前自动获得内置锁
       public synchronized void put(T obj) throws InterruptedException {
             // 线程被唤醒时,条件不一定满足(虚假唤醒),所以需要在循环里进行测试、等待
             while (isFull()) {
                   // 在当前的对象实例上等待,由其他线程调用 notifyAll 或 notify 方法唤醒
                  wait();
            }

            doPut(obj);

             // 唤醒在此对象监视器上等待(通过wait方法进入等待)的所有线程。
             // 直到当前线程放弃此对象上的锁定,才能继续执行被唤醒的线程。
            notifyAll();

      }

       public synchronized T take() throws InterruptedException {
            T object = null;
             while (isEmpty()) {
                  wait();
            }
            object = doTake();
            notifyAll();
             return object;
      }

       private void doPut(T obj) { // not implemented
      }

       private T doTake() { // not implemented
             return null ;
      }

       private boolean isEmpty() { // not implemented
             return false ;
      }

       private boolean isFull() { // not implemented
             return false ;
      }
}

继续阅读

Java 内存模型 JMM

JMM,Java Memory Model,Java 内存模型。

什么是内存模型,要他何用?

假定一个线程为变量var赋值:var = 3;,内存模型要回答的问题是:在什么条件下,读取变量var的线程可以看到3这个值?

如果缺少了同步,线程可能无法看到其他线程操作的结果。导致这种情况的原因可以有:编译器生成指令的次序可以不同于源代码的“显然”版本,编译器还会把变量存储在寄存器而不是内存中;处理器可以乱序或并行执行指令;缓存会改变写入提交到主存得到变量的次序;存储在处理器本地缓存中的变量对其他处理器不可见 等等。

数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:

名称   代码示例     说明
写后读  a = 1;b = a;   写一个变量之后,再读这个位置。
写后写  a = 1;a = 2;   写一个变量之后,再写这个变量。
读后写  a = b;b = 1;   读一个变量之后,再写这个变量。

上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。

编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

as-if-serial语义

as-if-serial语义的意思指:不管怎么重排序,(单线程)程序的执行结果不能被改变。编译器、runtime 和处理器都必须遵守as-if-serial语义。

数据竞争

当程序未正确同步时,就会存在数据竞争。java内存模型规范对数据竞争的定义为:在一个线程中写一个变量,在另一个线程读同一个变量,而且写和读没有通过同步来排序。

顺序一致性模型

操作执行的顺序是唯一的,就是它们出现在程序中的顺序,这与执行它们的处理器无关;变量每一次读操作,都能得到执行序列上这个变量最新的写入值,无论这是哪个处理器写入的。这个是一个理想的模型,JMM是不支持的。

Java 语言规范规定了 JVM 要维护内部线程类似顺序语意(within-thread as-if-serial semantics):只要程序的最终结果等同于它在严格的顺序环境中执行的结果,那么上述所有的行为都是允许的。

JMM 规定了 JVM 的一种最小保证:什么时候写入一个变量会对其他线程可见。
继续阅读

GC 日志分析

不同的JVM及其选项会输出不同的日志。

GC 日志

生成下面日志使用的选项:-XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:d:/GClogs/tomcat6-gc.log

4.231: [GC 4.231: [DefNew: 4928K->512K(4928K), 0.0044047 secs] 6835K->3468K(15872K), 0.0045291 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
 
4.445: [Full GC (System) 4.445: [Tenured: 2956K->3043K(10944K), 0.1869806 secs] 4034K->3043K(15872K), [Perm : 3400K->3400K(12288K)], 0.1870847 secs] [Times: user=0.05 sys=0.00, real=0.19 secs] 

最前面的数字 4.2314.445 代表虚拟机启动以来的秒数。

[GC[Full GC 是垃圾回收的停顿类型,而不是区分是新生代还是年老代,如果有 Full 说明发生了 Stop-The-World 。如果是调用 System.gc() 触发的,那么将显示的是 [Full GC (System)

接下来的 [DefNew, [Tenured, [Perm 表示 GC 发生的区域,区域的名称与使用的 GC 收集器相关。
Serial 收集器中新生代名为 “Default New Generation”,显示的名字为 “[DefNew”。对于ParNew收集器,显示的是 “[ParNew”,表示 “Parallel New Generation”。 对于 Parallel Scavenge 收集器,新生代名为 “PSYoungGen”。年老代和永久代也相同,名称都由收集器决定。

方括号内部显示的 “4928K->512K(4928K)” 表示 “GC 前该区域已使用容量 -> GC 后该区域已使用容量 (该区域内存总容量) ”。

再往后的 “0.0044047 secs” 表示该区域GC所用时间,单位是秒。

再往后的 “6835K->3468K(15872K)” 表示 “GC 前Java堆已使用容量 -> GC后Java堆已使用容量 (Java堆总容量)”。

再往后的 “0.0045291 secs” 是Java堆GC所用的总时间。

最后的 “[Times: user=0.00 sys=0.00, real=0.00 secs]” 分别代表 用户态消耗的CPU时间、内核态消耗的CPU时间 和 操作从开始到结束所经过的墙钟时间。墙钟时间包括各种非运算的等待耗时,如IO等待、线程阻塞。CPU时间不包括等待时间,当系统有多核时,多线程操作会叠加这些CPU时间,所以user或sys时间会超过real时间。

堆的分代

JVM-heap-generations

在上图中:

  • young区域就是新生代,存放新创建对象;
  • tenured是年老代,存放在新生代经历多次垃圾回收后仍存活的对象;
  • perm是永生代,存放类定义信息、元数据等信息。

当GC发生在新生代时,称为Minor GC,次收集;当GC发生在年老代时,称为Major GC,主收集。 一般的,Minor GC的发生频率要比Major GC高很多。

JVM GC 相关的选项

-XX:+PrintGC 输出GC日志
-XX:+PrintGCDetails 输出GC的详细日志
-XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)
-XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息
-Xloggc:../logs/gc.log 日志文件的输出路径

欢迎关注我的微信公众号: coderbee笔记,可以更及时回复你的讨论。

JVM 类加载机制

虚拟机类加载机制:把描述类的数据从class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型。

在Java语言里面,类型的加载、连接和初始化都是在程序运行期间完成的。

类加载的时机

类的整个生命周期包括:加载(loading)、验证(verification)、准备(preparation)、解析(resolution)、初始化(initialization)、使用(using)和卸载(unloading) 7个阶段。其中 验证、准备、解析 3个部分统称连接(linking)。

class life cycle

5种必须立即初始化的情况:

  1. 遇到new, getstatic, putstatic 或 invokestatic 这4挑字节码指令;
  2. 使用 java.lang.reflect 包的方法对类进行反射调用;
  3. 初始化一个类的时候,其父类还没有初始化,必须先触发其父类的初始化;(对于接口,只有在真正用到其父接口的时候才会初始化)
  4. 虚拟机启动时,用户指定要执行的主类;
  5. 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic, REF_putStatic, REF_invokeStatic 的方法句柄,并且这个方法句柄锁对应的类没有进行出事,则需要先触发其初始化。

对于静态字段,只有直接定义这个字段的类才会被初始化。
继续阅读

JUC 源码分析 三 AbstractQueuedSynchronizer 共享模式 与 CountDownLatch

共享模式

共享模式允许一组线程获取同一个许可。为实现共享模式子类需要实现两个方法:

  • tryAcquireShared:返回int类型的值,小于0表示获取失败,等于0表示获取成功但不允许后续更多的获取,大于0表示获取成功且允许更多的后续获取。
  • tryReleaseShared:返回true表示释放许可成功,可以唤醒等待线程;false表示失败,不唤醒等待线程。

共享获取 acquireShared

public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}

private void doAcquireShared(int arg) {
       // 添加到等待队列,不管是共享模式还是独占模式,都共享同一个等待队列。
    final Node node = addWaiter(Node.SHARED);
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            if (p == head) {
                int r = tryAcquireShared(arg); // 尝试获取,返回值表示是否允许获取
                if (r >= 0) {
                   // 获取成功
                   // 把自己设为头结点并传递可以获取的信号
                   // node 把自己设为头结点后,它的后继发现它的前驱是头结点了,就会尝试获取。
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC
                    if (interrupted)
                        selfInterrupt();
                    failed = false;
                    return;
                }
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

private void setHeadAndPropagate(Node node, int propagate) {
    Node h = head; // Record old head for check below
    setHead(node);
    /*
     * 尝试通知队列里的下一个结点,如果:
     *       调用者指示或者之前操作记录显示需要传递
     *       (注意:这里对waitStatus使用单一检查,因为PROPAGATE可能被转换到SIGNAL)
     *   并且
     *       下一个结点以共享模式等待或者我们根本就不知道,因为它是空的。
     *
     * 在这些检查有点保守,可能导致不必要的唤醒,但只是在多重竞争acquires/releases时,
     * 因此,大多数都是现在或不久就需要通知的。
     */
    if (propagate > 0 || h == null || h.waitStatus < 0) {
        Node s = node.next;
        if (s == null || s.isShared())
            doReleaseShared();
    }
}

private void setHead(Node node) {
    head = node;
    node.thread = null; // for GC
    node.prev = null;
}

继续阅读

内存关卡/栅栏 ( Memory Barriers / Fences ) – 译

翻译自:Martin ThompsonMemory Barriers/Fences

在这篇文章里,我将讨论并发编程里最基础的技术–以内存关卡或栅栏著称,那让进程内的内存状态对其他进程可见。

CPU 使用了很多技术去尝试和适应这样的事实:CPU 执行单元的性能已远远超出主内存性能。在我的“Writing Combining”文章,我只是谈及其中一种技术。CPU 使用的用来隐藏内存延迟的最普通技术是管线化指令,然后付出巨大努力和资源去尝试重排序这些管线来最小化缓存不命中的有关拖延。

当一个程序执行的时候,它不在乎,如果重排序后的指令提供了一样的最终结果。例如,在一个循环内,如果循环内没有操作使用循环计算器,循环计数器什么时候更新是不在乎的。编译器和 CPU 自由地重排序指令来最大化地利用 CPU,直到下一次迭代即将开始时才更新它(循环计数器)。也可能,在一个循环的执行过程中,这个变量可能存储在一个寄存器里,永远不会推到缓存或主内存,因此,它对其它 CPU 永远不可见。

CPU 核包含多个执行单元。例如,一个现代的Intel CPU 包含6个执行单元,可以做一组数学,条件逻辑和内存操作的组合。每个执行单元可以做这些任务的组合。这些执行单元并行地操作,允许指令并行地执行。如果从其它 CPU 来观察,这引入了程序顺序的另一层不确定性。

最终,当缓存不命中发生时,现代 CPU 可以根据内存加载的结果做一个假设,然后基于这个假设继续执行直至实际数据的加载完成。

提供“程序顺序”保留了 CPU 和编译器自由地做它们认为可以提升性能的事情。

cpu-memory-cache-system

加载(load)和存储(store)到缓存和主内存是被缓冲和重排序的,使用加载(load),存储(store),和写组合(writing-combining)缓存。这些缓存是关联的队列,允许快速查找。这种查找是必须的,当一个稍后的加载需要读取一个之前存储的、还没有到达缓存的值时。上图描绘了现代多核 CPU 的简化视图。它显示了执行单元如何使用本地寄存器和缓存来管理内存,与缓存子系统来回传送。

在多线程环境下,需要采用一些技术来让程序结果及时可见。我不会在这篇文章里涉及缓存一致性。仅仅假设一旦内存被推到缓存,然后有一个协议消息将发生,以确保所有共享数据的缓存是一致的。这种使内存对处理器核可见的技术被称为内存关卡或栅栏。

内存关卡提供了两种属性。首先,它们保留了外部可见的程序顺序,通过确保所有的、关卡两侧的指令表现出正确的程序顺序,如果从其他CPU观察。第二,它们使内存可见,通过确保数据传播到缓存子系统。

内存关卡是一个复杂的主题。它们在不同的 CPU 架构上的实现是非常不同的。Intel CPU 有一个关联的强内存模型。本篇将以 x86 CPU 为基础讲解。

继续阅读