1. Java内存模型
1.1 硬件的效率与一致性
基于高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但也为计算机系统带来更高的复杂度,因为它引入了新的问题:缓存一致性。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一个主存,如上图所示,为了保证主存的一致性,在高速缓存和主存之间引入了缓存一致性协议,这类协议有MSI,MESI等。
所谓的内存模型是指在特定的操作协议下,对特定的内存或告诉缓存进行读写访问的过程抽象。
另外为了使得处理器内部的运算单元能尽量被充分使用,处理器会在保证最终输出结果一致的情况下,对输入代码进行指令重排序。
1.2 Java的内存模型
Java虚拟机规范中定义了一种Java内存模型来屏蔽各种硬件和操作系统的内存访问差异,如下图所示。
主内存可以理解为硬件内存,每条线程有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写在主内存中的变量。JVM为了保证缓存的一致性,Java内存模型中定义了8中操作,通过对这8中操作添加一些约束条件来实现。这八种操作为:
操作 | 介绍 |
---|---|
lock | 作用于主内存的变量,它把一个变量标识为一条线程独占 |
unlock | 作用于主内存的变量,它把一个处于锁定状态的变量释放出来 |
read | 从主内存加载到工作内存 |
load | 将read操作得到的变量放入工作内存的变量副本中 |
use | 使用工作内存的变量 |
assign | 对工作内存中的变量进行赋值 |
store | 把工作内存中的一个变量的值传送到主内存 |
write | 将store传送过来的变量写入到主内存的变量中 |
约束条件有很多,这里说比较重要的几条
- read和load,store和write不能单独出现,每一对之间可以插入其他命令,但每一对中两个指令出现的顺序不能改变;
- 不允许一个线程丢弃assign操作。
- 如果对一个变量执行lock操作,会清空工作内存中此变量的值,在执行引擎使用这个变量前,重新read和load操作
- 对一个变量执行unlock操作时,必须执行store和write操作将变量同步回主内存
- 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
1.3 对于volatile型变量的特殊规则
volatile关键字有两个作用,第一保证变量对所有的线程都是可见的,第二防止指令重排序。
为了保证volatile的可见性,另外附加了两条规则:1. use前必须read和load 2. assign后必须store和write
在DLC中,我们使用了一个volatile来修饰instance变量,原因为何?
public class Singleton{
private volatile static Singleton instance;
private Singleton(){};
public static Singleton getInstance(){
if(instance == null){
synchronized(Singleton.class){
if(instance==null)
instance=new Singleton();
}
}
return instance;
}
}
instance = new Singleton() 这个操作可能会发生如下形式的重排序
1. //分配内存
2. //给Singleton对象的成员变量分配“零值”
3. //将新键的对象赋值给instance
4. //按照构造方法以及程序块对成员变量重新设值
如果这样的话,就会使得其他线程访问instance变量时,得到一个“未经过构造方法”的错误对象。
但是有一点也是需要注意的,volatile并不能保证线程安全,如把一个变量race声明为volatile,10个线程对race各执行100次自增操作,race并不能达到1000,而是比1000要少,这是为什么?因为race++虽然看起来只有一条指令,但是它不是原子操作。虽然volatile能够保证每次读取到栈顶的变量时一致的,但是因为没有锁,所以并不是线程安全的。
1.4 原子性、可见性与有序性
Java的内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这3个特征来建立的,我们逐个来看哪些操作实现了这3个特性。
- 原子性:保证原子性变量操作包括read,load,assign,use,store和write。如果应用场景需要一个更大范围的原子性保证,则可以使用lock和unlock操作。synchronized关键字就是使用lock和unlock保证原子性的。
- 可见性:除了volatile,synchronized和final同样保证可见性,synchronized的可见性是依靠java内存模型中的unlock时必须先把变量同步回内存中这条规则获得的。被final修饰的字段在构造器中一旦初始化完成,那么其他线程就能看到final的值
- 有序性:在单线程中,有序性是可以得到天然的保证的,然而在多线程中,由于指令重排序现象的存在,有序性需要依靠volatile和synchronized进行保证。volatile是依靠禁止指令重排序保证了有序性,而synchronized则是依靠“一个变量在同一时刻只允许一个线程对其进行lock操作”进行保证的。
1.5 先行发生原则(happens-before)
如果A先行发生于B,那么在发生操作B之前,操作A产生的影响能被操作B看到。在Java内存模型下,有一些“天然的”先行发生关系,这些先行发生关系无须任何同步器协助就可以在编码中直接使用
- 程序次序规则:单线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 管程锁定规则:一个unlock操作先行发生于对后面(时间上的概念)的同一个锁的lock操作
- volatile变量规则:对一个volatile变量的写操作先行发生于后面(时间上的概念)的对这个变量的读操作
- 线程启动规则:Thread对象的start()方法先行发生于此线程的其他动作
- 线程终止规则:线程中所有操作都先行发生于对此线程的终止检测,我们可以使用Thread.join()方法结束、Thread.isAlive()的返回值等手段,检测到线程已经终止执行
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断时间的发生
- 对象终结规则:一个对象的初始化完成(构造方法执行结束)先行发生于它的finalize()方法的开始
- 传递性:A先行发生于B,B先行发生于C,A先行发生于C
注:一个操作“时间上的先发生”不代表这个操作会“先行发生”。
2. 高效并发
2.1 线程安全的实现方法
线程安全:当多个线程访问同一个类的时候,如果不用考虑这些线程在运行时环境下的调度和交替,并且不需要额外的同步(线程安全的类封装了必要的同步,因此调用方不需要额外的同步),这个类的行为仍然是正确的,那么称这个类是线程安全的。
1. 互斥同步
同步是指在多个线程并发访问共享数据的时候,保证共享数据在同一时刻只被一个(或者一些线程使用)。而互斥是实现同步的一种手段。互斥是因,同步是果。
在Java中常见的互斥同步手段就是synchronized关键字。synchronized 关键字修饰的代码块,在编译期会自动生成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象,在成员方法中默认是this
,在静态方法中则默认是.class
对象。而这个要被锁定的对象正是管程中的条件变量,只不过只有一个条件变量。
Synchronized同步块对于同一条线程而言是可重入的,不会出现自己把自己锁死的问题。Java的线程都是映射到操作系统的原生线程之上的,阻塞或者唤醒一个线程都需要操作系统来帮忙完成,这就需要用户态到内核态的转换,这是相当耗时的。所以synchronized是Java语言中一个重量级的操作。
除了synchronized之外,我们还可以使用java.util.concurrent包中的重入锁(ReentrantLock)来实现同步。synchronized是jvm(原生语法层面的互斥锁),而另一个则是api层面的。除此之外ReentrantLock增加了一些高级特性。
- 等待可中断是指当持有锁的线程长期不释放时,正在等待的线程可以选择放弃等待,改为处理其他事情。
- 公平锁:多个线程在等待同一个锁时,必须按照申请的时间顺序来依次获得锁;
- 绑定多个条件变量:通过newCondition()方法可以绑定多个条件变量。
2. 非阻塞同步
通俗的说,先进行操作,如果没有其他线程竞争共享数据,操作就成功了;如果共享数据有争用,产生了冲突,那就采取其他补偿措施,比如不断的重试。
非阻塞的同步需要需要硬件指令集的支持,因为我们需要操作和冲突检测这两个步骤都具有原子性,比如CAS(Compare-and-Swap)。
CAS需要3个操作数,分别是内存位置、旧的预期值和新的预期值。CAS指令操作中,当且仅当V符合旧预期值的时,处理器用新值更新旧值。JDK1.5后支持了CAS,该操作由sun.misc.Unsafe类中的compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供。
Unsafe类不是提供给用户程序调用的类,但我们可以通过JUC包里的compareAndSet()和getAndIncrement()等方法使用Unsafe类的CAS操作。
incrementAndGet()的jdk源码如下,可以看到incrementAndGet通过忙等,实现自增的操作。
public final int incrementAndGet(){
for(;;){
int current = get();
int next = current + 1;
if (compareAndSet(current,next))
return next;
}
}
CAS看起来尽管很美,但是可能会出现ABA的这种情况,也就是如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,能排除它曾经是B然后被改为A么?为此JUC包提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量的版本来保证CAS的正确性。
3. 无同步方案——ThreadLocal类
使共享数据在同一个线程中执行,例如Java中的ThreadLocal类。ThreadLocal类中可以设置一个线程独享的变量。关于ThreadLocal类可以看下这篇文章
2.2 锁优化
Java的锁优化是对synchronized加锁的优化。
1. 自旋锁
在互斥同步中,对线程的挂起和恢复需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力。如果共享数据的锁定状态只会持续很短的时间,为了这段时间去挂起和恢复线程并不值得。此时,我们可以让等待线程执行一个忙循环来等待。
如果锁被占用的时间很短,自选等待的效果会很好,反之,如果锁被占用的时间很长,则自选的线程会白白浪费消耗处理器的资源,因此会有一个自旋上限。
2. 锁消除
锁消除主要基于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,就可以把它当做栈上数据对待,同步加锁操作自然就无需进行。比如下面这个例子
public String concatString(String s1, String s2, String s3){
return s1+s2+s3;
}
在jdk1.5之前,该语句会转换成如下,StringBuffer是一个线程安全的类,也就意味着以下三个append语句都是加锁的。
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
然而sb对象是不会逃逸到concatString方法之外的,其他线程无法访问它,因此就会把此处的加锁去除。
3. 锁粗化
频繁地加锁解锁(特别是对循环块内加锁)很耗费性能,JVM会将锁范围扩大
4. 轻量级锁
如果对象没有被锁定,虚拟机首先将在当前线程的栈帧中创建一个名为锁记录Lock Record的空间,用于存储锁对象目前的Mark Word拷贝,然后使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新成功了则获得锁,并且对象的Mark Word会变成轻量级锁。如果更新失败,JVM会检查对象的Mark Word是否指向当前线程的栈帧,如果是,则直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占。如果有两条以上的线程争用这个锁,轻量级锁膨胀为重量级锁。
解锁过程也是通过CAS操作来进行的,如果对象的Mark Word仍然指向着线程的锁记录,那就用CAS操作把对象的当前Mark Word和线程中复制的Displaced Mark Word替换回来,如果替换成功,整个同步过程就完成了。
5. 偏向锁
如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做。
当锁对象第一次被线程获取的时候,虚拟机会把对象头中的标志位设为“01”,即偏向模式。同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Word之中,如果CAS获取成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。
当有另外线程试图获取这个锁时,偏向锁模式就宣告结束。偏向锁、轻量级锁的状态转化及对象Mark Word的关系如图所示。