1. volatile
1.1 64位写入的原子性(Half Write)
例如,对于一个long型变量的赋值和取值操作而言,在多线程场景下,线程A调用set(100),线程B调用get(),在某些场景下,返回值可能不是100。
public class MyClass {
private long a = 0;
// 线程A调用set(100)
public void set(long a) {
this.a = a;
}
// 线程B调用get(),返回值一定是100吗?
public long get() {
return this.a;
}
}
因为JVM的规范并没有要求64位的long或者double的写入是原子的。在32位的机器上,一个64位变量的写入可能被拆分成两个32位的写操作来执行。这样一来,读取的线程就可能读到“一半的值”。解决办法也很简单,在long前面加上volatile关键字。
1.2 重排序:DCL问题
单例模式的线程安全的写法不止一种,常用写法为DCL(Double Checking Locking),如下所示:
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null) {
// 此处代码有问题
instance = new Singleton();
}
}
}
return instance;
}
}
上述的 instance = new Singleton(); 代码有问题:其底层会分为三个操作:
- 分配一块内存。
- 在内存上初始化成员变量。
- 把instance引用指向内存。
在这三个操作中,操作2和操作3可能重排序,即先把instance指向内存,再初始化成员变量,因为二者并没有先后的依赖关系。此时,另外一个线程可能拿到一个未完全初始化的对象。这时,直接访问里面的成员变量,就可能出错。这就是典型的“构造方法溢出”问题。
解决办法也很简单,就是为instance变量加上volatile修饰。
volatile的三重功效:64位写入的原子性、内存可见性和禁止重排序。
1.3 volatile实现原理
由于不同的CPU架构的缓存体系不一样,重排序的策略不一样,所提供的内存屏障指令也就有差异。
这里只探讨为了实现volatile关键字的语义的一种参考做法:
- 在volatile写操作的前面插入一个StoreStore屏障。保证volatile写操作不会和之前的写操作重排序。
- 在volatile写操作的后面插入一个StoreLoad屏障。保证volatile写操作不会和之后的读操作重排序。
- 在volatile读操作的后面插入一个LoadLoad屏障+LoadStore屏障。保证volatile读操作不会和之后的读操作、写操作重排序。
具体到x86平台上,其实不会有LoadLoad、LoadStore和StoreStore重排序,只有StoreLoad一种重排序(内存屏障),也就是只需要在volatile写操作后面加上StoreLoad屏障。
1.4 4 JSR-133对volatile语义的增强
在JSR -133之前的旧内存模型中,一个64位long/ double型变量的读/ 写操作可以被拆分为两个32位的读/写操作来执行。从JSR -133内存模型开始 (即从JDK5开始),仅仅只允许把一个64位long/double
型变量的写操作拆分为两个32位的写操作来执行,任意的读操作在JSR -133中都必须具有原子性(即任意读操作必须要在单个读事务中执行)。
这也正体现了Java对happen-before规则的严格遵守。
2. synchronized
2.1 锁的对象
synchronized关键字“给某个对象加锁”,示例代码:
public Class MyClass {
public void synchronized method1() {
// ...
}
public static void synchronized method2() {
// ...
}
}
等价于:
public class MyClass {
public void method1() {
synchronized(this) {
// ...
}
}
public static void method2() {
synchronized(MyClass.class) {
// ...
}
}
}
实例方法的锁加在对象myClass上;静态方法的锁加在MyClass.class上。
2.2 锁的本质
如果一份资源需要多个线程同时访问,需要给该资源加锁。加锁之后,可以保证同一时间只能有一个线程访问该资源。资源可以是一个变量、一个对象或一个文件等。
锁是一个“对象”,作用如下:
- 这个对象内部得有一个标志位(state变量),记录自己有没有被某个线程占用。最简单的情况是这个state有0、1两个取值,0表示没有线程占用这个锁,1表示有某个线程占用了这个锁。
- 如果这个对象被某个线程占用,记录这个线程的thread ID。
- 这个对象维护一个thread id list,记录其他所有阻塞的、等待获取拿这个锁的线程。在当前线程释放锁之后从这个thread id list里面
随机
取一个线程唤醒。
要访问的共享资源本身也是一个对象,例如前面的对象myClass,这两个对象可以合成一个对象。代码就变成synchronized(this) {…},要访问的共享资源是对象a,锁加在对象a上。
当然,也可以另外新建一个对象,代码变成synchronized(obj1) {…}。这个时候,访问的共享资源是对象a,而锁加在新建的对象obj1上。
资源和锁合二为一,使得在Java里面,synchronized关键字可以加在任何对象的成员上面。这意味
着,这个对象既是共享资源,同时也具备“锁”的功能!
2.3 实现原理
锁如何实现?
在对象头里,有一块数据叫Mark Word。在64位机器上,Mark Word是8字节(64位)的,这64位中有2个重要字段:锁标志位和占用该锁的thread ID。因为不同版本的JVM实现,对象头的数据结构会有各种差异。
在 Java 中,每个对象都隐式包含⼀个 monitor(监视器)对象,加锁的过程其实就是竞争 monitor 的过程,当线程进⼊字节码 monitorenter 指令之后,线程将持有 monitor 对象, 执⾏ monitorexit 时释放 monitor 对象,当其他线程没有拿到 monitor 对象时,则需要阻塞 等待获取该对象。
2.4 锁的升级
- 偏向锁 在 JDK1.8 中,其实默认是轻量级锁,但如果设定了 -XX:BiasedLockingStartupDelay = 0,那在对⼀个 Object 做 syncronized 的时候,会⽴即上⼀把偏向锁。当处于偏向锁状态时,markwork 会记录当前线程 ID 。
- 升级到轻量级锁 当下⼀个线程参与到偏向锁竞争时,会先判断 markword 中保存的线程 ID 是否与这个 线程 ID 相等,如果不相等,会⽴即撤销偏向锁,升级为轻量级锁。每个线程在⾃⼰的线 程栈中⽣成⼀个 LockRecord ( LR ),然后每个线程通过 CAS (⾃旋)的操作将锁对象头 中的 markwork设置为指向⾃⼰的 LR 的指针,哪个线程设置成功,就意味着获得锁。 关于 synchronized 中此时执⾏的 CAS 操作是通过 native 的调⽤ HotSpot 中 bytecodeInterpreter.cpp ⽂件 C++ 代码实现的,有兴趣的可以继续深挖。
- 升级到重量级锁 如果锁竞争加剧(如线程⾃旋次数或者⾃旋的线程数超过某阈值, JDK1.6 之后,由JVM ⾃⼰控制该规则),就会升级为重量级锁。此时就会向操作系统申请资源,线程挂起,进 ⼊到操作系统内核态的等待队列中,等待操作系统调度,然后映射回⽤户态。在重量级 锁中,由于需要做内核态到⽤户态的转换,⽽这个过程中需要消耗较多时间,也就 是"重"的原因之⼀。
3. reentranlock
3.1 ReentrantLock的特点
- 可重⼊。
ReentrantLock 和 syncronized 关键字⼀样,都是可重⼊锁,不过两者实现原理稍有差别, RetrantLock 利⽤ AQS 的的 state 状态来判断资源是否已锁,同⼀线程重⼊加锁, state 的状态 +1 ; 同⼀线程重⼊解锁, state 状态 -1 (解锁必须为当前独占线程,否则异 常); 当 state 为 0 时解锁成功。 - 需要⼿动加锁、解锁。
synchronized 关键字是⾃动进⾏加锁、解锁的,⽽ ReentrantLock 需要lock() 和 unlock() ⽅法配合 try/finally 语句块来完成,来⼿动加锁、解锁。 - ⽀持设置锁的超时时间。
synchronized 关键字⽆法设置锁的超时时间,如果⼀个获得锁的线程内部发⽣死锁,那 么其他线程就会⼀直进⼊阻塞状态,⽽ ReentrantLock 提供 tryLock ⽅法,允许设置线 程获取锁的超时时间,如果超时,则跳过,不进⾏任何操作,避免死锁的发⽣。 - ⽀持公平/⾮公平锁。
synchronized 关键字是⼀种⾮公平锁,先抢到锁的线程先执⾏。⽽ReentrantLock 的 构造⽅法中允许设置 true/false 来实现公平、⾮公平锁,如果设置为 true ,则线程获取 锁要遵循"先来后到"的规则,每次都会构造⼀个线程 Node ,然后到双向链表的"尾巴"后⾯排队,等待前⾯的 Node 释放锁资源。 - 可中断锁。
ReentrantLock 中的 lockInterruptibly() ⽅法使得线程可以在被阻塞时响应中断,⽐如⼀个线程 t1 通过 lockInterruptibly() ⽅法获取到⼀个可重⼊锁,并执⾏⼀个⻓时间 的任务,另⼀个线程通过 interrupt()⽅法就可以⽴刻打断 t1 线程的执⾏,来获取t1持 有的那个可重⼊锁。⽽通过 ReentrantLock 的 lock()⽅法或者 Synchronized 持有锁 的线程是不会响应其他线程的interrupt() ⽅法的,直到该⽅法主动释放锁之后才会响应 interrupt() ⽅法。
4. CAS
CAS全称是Compare And Swap,即比较替换,是实现并发应用到的一种技术。
操作包含三个操作数:内存位置(V)、预期原值(A)和新值(B)
。
如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 。否则,处理器不做任何操作。
CAS存在三大问题:ABA问题,循环时间长开销大,以及只能保证一个共享变量的原子操作。
CAS是compare and swap的缩写,即我们所说的⽐较交换。
cas是⼀种基于锁的操作,⽽且是乐观锁。
在java中锁分为乐观锁和悲观锁。悲观锁是将资源 锁住,等⼀个之前获得锁的线程释放锁之后,下⼀个线程才可以访问。
⽽乐观锁采取了⼀种宽 泛的态度,通过某种⽅式不加锁来处理资源,⽐如通过给记录加version来获取数据,性能较 悲观锁 有很⼤的提⾼。
CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址⾥ ⾯的值和A的值是⼀样的,那么就将内存⾥⾯的值更新成B。CAS是通过⽆限循环来获取数据 的,若果在第⼀轮循环中,a线程获取地址⾥⾯的值被b线程修改了,那么a线程需要⾃旋,到 下次循环才有可能机会执⾏。
java.util.concurrent.atomic 包下的类⼤多是使⽤CAS操作来实现的(AtomicInteger,AtomicBoolean,AtomicLong)。
5. ReentrantLock和synchronized区别
ReentrantLock 提供了比 synchronized 更加灵活和强大的锁机制。
首先他们肯定具有相同的功能和内存语义。
- 与 synchronized 相比,ReentrantLock提供了更多、更加全面的功能、具备更强的扩展性。例如:时间锁等候,可中断锁等候,锁投票。
- ReentrantLock 还提供了条件 Condition,对线程的等待、唤醒操作更加详细和灵活,所以在多个条件变量和高度竞争锁的地方,ReentrantLock更加适合(以后会阐述Condition)。
- ReentrantLock 提供了可轮询的锁请求。它会尝试着去获取锁,如果成功则继续,否则可以等到下次运行时处理,而 synchronized 则一旦进入锁请求要么成功要么阻塞,所以相比 synchronized 而言,ReentrantLock会不容易产生死锁些。
- ReentrantLock 支持更加灵活的同步代码块,但是使用 synchronized 时,只能在同一个synchronized 块结构中获取和释放。注意,ReentrantLock的锁释放一定要在 fina1ly 中处理,否则可能会产生严重的后果。
- ReentrantLock 支持中断处理,且性能较 synchronized 会好些。
6. synchronized关键字和volatile的区别
- volatile关键字是线程同步的轻量级实现,所以volatile性能⽐synchronized关键字要好。但是volatile关键字只能⽤于变量⽽synchronized关键字可以修饰⽅法以及代码块。
- 多线程访问volatile关键字不会发⽣阻塞,⽽synchronized关键字可能会发⽣阻塞。 volatile关键字主要⽤于解决变量在多个线程之间的可⻅性,⽽ synchronized关键字解决的 是多个线程之间访问资源的同步性。
- volatile关键字能保证数据的可⻅性,但不能保证数据的原⼦性。synchronized关键字两者都 能保证。