在进行并发操作时为保证线程安全会使用各种锁,此文初步介绍各种锁的概念。
1. 悲观锁 (阻塞同步)
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞(非旋转)直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized
(JDK 1.6 以前)和ReentrantLock
等独占锁就是悲观锁思想的实现。
读/写锁属于悲观锁:
- 排它锁(Exclusive),简写为 X 锁,又称写锁。
- 共享锁(Shared),简写为 S 锁,又称读锁。
- 一个事务对数据对象 A 加了 X 锁,就可以对 A 进行读取和更新。加锁期间其它事务不能对 A 加任何锁。
- 一个事务对数据对象 A 加了 S 锁,可以对 A 进行读取操作,但是不能进行更新操作。加锁期间其它事务能对 A 加 S 锁,但是不能加 X 锁。
2. 乐观锁 (非阻塞同步)
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制或CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
CAS(Compare and swap)(比较并交换)
- CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
- CAS可以用一条CPU指令来完成,从最低层上实现CAS操作的原子性。
- CAS不能避免ABA问题(被其他线程两次操作重新变回原值),如果需求依赖过程值则可以用版本号机制来解决此问题。
两种锁的使用场景
从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。
3. 公平锁/非公平锁
公平锁和非公平锁的区别就在于,公平锁是FIFO机制,谁先来谁就在队列的前面,就能优先获得锁。非公平锁支持抢占模式,先来的不一定能得到锁。
Java中synchronized
是非公平锁, ReentrantLock
是否公平可选。
4. 自旋锁 (属于乐观锁)
互斥同步进入阻塞状态的开销很大,应该尽量避免。在许多应用中,共享数据的锁定状态只会持续很短的一段时间。自旋锁的思想是让一个线程在请求一个共享数据的锁时执行忙循环(自旋)一段时间,如果在这段时间内能获得锁,就可以避免进入阻塞状态。
自旋锁虽然能避免进入阻塞状态从而减少开销,但是它需要进行忙循环操作占用 CPU 时间,它只适用于共享数据的锁定状态很短的场景。
在 JDK 1.6 中引入了自适应的自旋锁。自适应意味着自旋的次数不再固定了,而是由前一次在同一个锁上的自旋次数及锁的拥有者的状态来决定。
自旋锁的优点:
如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
自旋锁的缺点:
但是线程自旋是需要消耗CPU的,说白了就是让CPU在做无用功,如果一直获取不到锁,那线程也不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。
Java线程阻塞的代价
Java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在用户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作(线程上下文切换)。
synchronized
会导致争用不到锁的线程进入阻塞状态,所以说它是Java语言中一个重量级的同步操纵,被称为重量级锁。
5. 可重入锁/不可重入锁
可重入锁,也叫做递归锁,指的是同一线程获得锁之后 ,之后仍然有获取该锁的代码,但不受影响,不会造成死锁,如造成死锁则为不可重入锁。
在Java环境下 ReentrantLock
和 synchronized
都是可重入锁。
6. Java SE 1.6 以后锁一共有四种状态:无锁状态、偏向锁状态、轻量锁状态、重量级锁状态,级别依次递增。
Java SE 1.6 为了减少获得锁和释放锁带来的性能消耗,synchronized
引入了“偏向锁”和“轻量锁”。
对象头 Object Header
的内存布局,官方称为 Mark Word
。
其中hash为哈希码HashCode,age 为 GC分代年龄。
对象头的布局:
五种锁状态之间的转换:
6.1 偏向锁(乐观锁)(CAS)
- 大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程反复获得,为了让这种情况下获得锁的代价更低,引入了偏向锁。
- 当一个线程访问同步块并获得锁时,会在对象头和栈帧中的锁记录里存储偏向的线程ID,以后进入和退出同步块时不需要进行CAS操作来加锁或解锁,只需测试对象头的Mark Word里是否存储着指向当前线程的偏向锁。
- 当出现另一线程尝试获取锁时,撤销偏向锁,升级为轻量级锁。撤销时需要等到全局安全点(没有正在执行的字节码)。
- JDK 1.6 以来当锁对象第一次被线程获取的时候,默认即为偏向锁。
- 如果程序中大多数的锁总是被多个不同的线程访问,则可以用
-XX:-UseBiasedLocking
来禁止偏向锁优化,这样反而会提升性能。
6.2 轻量级锁(乐观锁)(CAS自旋)
- 加锁过程:线程执行同步块前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。
- 解锁过程:使用CAS操作将Displaced Mark Word替换回到对象头。如有另一线程竞争锁,自旋一段时间不成功后会把锁升级为重量级锁,即没有锁的线程会进入阻塞状态。
6.3 重量级锁
锁标识位为10
,没有获得锁的线程阻塞,不进行自旋。
6.4 三种锁的优缺点对比:
- 偏向锁:加锁和解锁不需要额外的消耗(除第一次加锁,其他无需进行CAS),适用于只有一个线程访问同步块的场景。
- 轻量级锁:竞争的线程不会阻塞,通过自旋CAS等待,适用于追求相应时间,同步块执行速度非常快的情况。
- 重量级锁:线程会阻塞,响应时间缓慢,适用于追求吞吐量,同步块执行速度较长的情况。
7. 锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。
锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。
对于一些看起来没有加锁的代码,其实隐式的加了很多锁。例如下面的字符串拼接代码就隐式加了锁:
public static String concatString(String s1, String s2, String s3) {
return s1 + s2 + s3;
}
String 是一个不可变的类,编译器会对 String 的拼接自动优化。在 JDK 1.5 之前,会转化为 StringBuffer 对象的连续 append() 操作, JDK 1.5 之后会转化为 StringBuilder:
public static String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
每个 append() 方法中都有一个同步块。虚拟机观察变量 sb,很快就会发现它的动态作用域被限制在 concatString() 方法内部。也就是说,sb 的所有引用永远不会逃逸到 concatString() 方法之外,其他线程无法访问到它,因此可以进行消除。
8. 锁粗化
如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。
上一节的示例代码中连续的 append() 方法就属于这类情况。如果虚拟机探测到由这样的一串零碎的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部。对于上一节的示例代码就是扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,这样只需要加锁一次就可以了。