本文主要记载Volatile,Synchronize,Lock特性与适用场景
Volatile
Volatile是一种相比Synchronized更轻量级的同步机制,它能够保证并发处理的可见性,但它并不能够保证原子性,这意味着Volatile并不是线程安全的。
可为可见性?如果某个线程改变了声明为Volatile的变量的值,那么所有其他线程中的值都应该更新,也就是所有线程对该Volatile变量是保持可见的。为了理解这句话,需要牵扯到JMM(Java内存模型)关于变量的存储读取操作,注意这里所说的变量是对应于堆中对象实例的数据,而并非指局部变量和方法参数,因为这些都是线程私有的,不会共享,也就不会存在线程安全问题。学过操作系统的都应该清楚:CPU和内存的运行速度差别很大,如果CPU每次对于一次数据处理,都需要等待数据从内存中读取,那么性能非常低下,所以,在CPU和内存之间加入了缓冲区,用于解决CPU和内存速度不一致的问题。JMM和这个结构类似,对于每一个线程,都有一块线程私有的“缓冲区”,称之为工作内存。见下图(图片来源):
这里的高速缓存就是我们所说的工作内存。解释一下JMM中的内存交互操作:
Lock:作用于主内存,用于锁定某一个变量为某一个线程所占有。
Read:作用于主内存,将主内存中的变量读取到工作内存中,为load操作铺垫。
Load:作用于工作内存,将read操作中的数据加载到工作内存的变量副本中。
Use:作用于工作内存,将变量副本交给处理机执行。
Assign:作用于工作内存,为工作内存中的变量副本赋值。
Store:作用于工作内存,将变量副本传送到主内存中,为write操作铺垫。
Write:作用于主内存,将Store的值写到主内存中。
Unlock:作用于主内存,释放对于该变量的锁。
如果我们不采取锁操作(例如Lock()),对于非Volatile变量来说,多个线程就可能保存有该变量的多个副本值,如果同时执行读写操作,那么读的结果就可能不一致。怎么解决?利用Volatile关键字,Volatile声明的变量,不允许存储到工作内存中,读取操作都直接在主内存中进行,并且一旦执行了写操作,主内存的值就立刻更新,保证了所有线程都能获取该变量的最新值,也就是保证了对该变量的可见性。
其实,这里面也遵循了JMM中的Happens-Before原则:对一个Volatile变量的写,必须先于所有之后对这个变量的读。什么意思?本人一开始也认为,程序的执行顺序和我们编写代码的顺序应该是一致的,其实不然,在多核处理器下,为了提高程序执行速度,编译器和处理器可能会对指令重排序!从硬件架构来说,将多条指令不按照程序规定的顺序,分开发送给各个相应电路单元处理,从而导致指令的执行顺序发生变化。当然,重排序也需按照一定的规则:如果两个操作之间缺乏Happens-Before关系,那么JVM可以对它们任意地重排序。假设A操作对Volatile变量做写操作,B操作对Volatile变量做读操作,那么JVM就不会对AB操作重排序,因为它们之间存在Happens-Before原则。也就是说,Volatile的另外一个作用就是阻止指令重排序,其实现方法就是在编译器生成字节码时,在指令序列中插入内存屏障来禁止特定的重排序。内存屏障一共有4类:
- LoadLoad Barriers:确保Load1数据的装载先于Load2以及所有后续装载指令。
- StoreStore Barriers:确保Store1的数据对其他处理器可见(会使缓存行无效,并刷新到内存中)先于Store2及所有后续存储指令的装载。
- LoadStore Barriers:确保Load1数据装载先于Store2及所有后续存储指令刷新到内存。
- StoreLoad Barriers:确保Store1数据对其他处理器可见(刷新到内存,并且其他处理器的缓存行无效)先于Load2及所有后续装载指令的装载。该指令会使得该屏障之前的所有内存访问指令完成之后,才能执行该屏障之后的内存访问指令。
那为什么说Volatile不能保证原子性呢?我们看一个例子:
public class Main {
volatile static int a=0;
//令a不断自增
public static void write(){
for(int i=0;i<50000;i++){
a++;
}
}
public static void main(String[] args) throws InterruptedException {
//创建2个线程t1、t2
Thread t1=new Thread(new Runnable(){
public void run() {
write();
}
});
t1.start();
Thread t2=new Thread(new Runnable(){
public void run() {
write();
}
});
t2.start();
//等待所有线程结束
while(t1.isAlive() || t2.isAlive()){};
//打印a的值
System.out.println(a);
}
}
打印结果:
83984
哎?不是说Volatile变量保证对所有线程是可见的么,为什么不是100000。原因在于a++并不是一个原子操作,它分三步执行:取值+修改+赋值。前面也说过,当某一个线程对volatile变量值更改后,也就是写入主内存后,其余所有变量才会更新,如果某个线程还未执行最后赋值步骤,其他线程就来读取主内存中的数据,问题就出现了!当然对于像a=1这样的原子操作是没问题的。
《Java并发编程实战》中写道:
当且仅当满足以下所有条件时,采应该使用volatile变量:
- 对变量的写入操作不依赖变量的的当前值,或者你能确保只有单个线程更新变量的值。
- 该变量不会与其它状态变量一起纳入不变性条件中。
- 在访问变量时不需要加锁。
Synchronized
Synchronized粒度比Volatile要大,因为Synchronized是方法级别的锁,主要用于修饰实例方法,静态方法,同步代码块。其中修饰实例方法锁定的是当前对象,修饰静态方法锁定的是当前类,修饰同步代码块锁定的是某个对象。下面我们分为修饰方法和修饰同步代码块来介绍Synchronized基本原理:
修饰同步代码块
首先需要了解的是每个在堆中的对象都包含了一个称为对象头部分,这一块主要用于存储对象的HashCode、分代年龄、锁标志位等。其中重量锁,也就是我们所说的Synchronized,存放着一个指向monitor的指针变量。每当一个线程获取到该对象的monitor,它就会处于锁定状态。monitor是由C++实现的,封装为ObjectMonitor对象,对象中存有几个关键属性:
- _count:计数器,0指示当前对象没有被线程占用,≥1指示锁已占用,数值可以大于1是因为该锁为重入锁。
- _owner:指向当前占有monitor的线程。
- _WaitSet:处于等待的线程(也就是java中调用wait()方法)就会封装为C++实现的ObjectWaiter对象,存放在该队列中。
- _EntryList:如果有多个对象竞争一把锁,而没有拿到锁的线程就会进入锁池。
思路很简单,当一个线程A发现monitor中count为0,取得锁,并置count为1,此时其它线程只能进入_EntryList等待锁释放。如果线程A调用wait()方法,那么线程A释放锁,将count置为0(不一定是1->0,因为线程A可能重入该锁多次,count值累加1),进入等待池,等待被notify唤醒,唤醒后需要进入锁池与其它线程竞争。由javap反编译可以看到,为了实现对同步代码块的锁控制,在同步代码块前后会加入monitorenter和monitorexit指令,用于控制锁定和释放。
修饰方法体
与修饰同步代码块相比,修饰方法体并不是由指令控制锁定,而是在方法体对应的字节码文件的位置插入ACC_SYNCHRONIZED访问标志来告诉JVM该方法需要同步控制。执行该方法前,线程需要先获取monitor,然后在方法完成后,或者方法抛出异常是释放monitor。
Synchronized属于重量锁,原因是每次进行线程的切换,操作系统都需要在核心态和用户态之间切换,耗费大量资源,为此,JDK1.6也对此进行了优化,加入了几种状态锁:偏向锁,轻量锁,重量锁。关于JVM如何对Synchronized优化,可以看看->深入理解Java并发之synchronized实现原理
Lock
Lock定义了锁的接口规范,有三个实现类ReentrantLock, ReentrantReadWriteLock.ReadLock, ReentrantReadWriteLock.WriteLock,重点为ReentrantLock。
ReentrantLock相比较于Synchronized更加灵活,同时因为其是在代码中实现,避免了像Synchronized需要切换用户态和核心态消耗较多的资源。不过在JDK1.6对Synchronized优化后,性能已经与ReentrantLock差不多,具体使用根据实际情况。
为什么说ReentrantLock更加灵活呢?因为其不仅可以实现公平锁和非公平锁,也可以规定线程等待锁时间,在锁池中的线程是否可以中断。而Synchronized默认为非公平锁并且处于锁池中的线程无法中断。并且,ReentrantLock提供了Condition,对于线程的等待和唤醒更加灵活。具体分析参考这里