Java 线程安全与锁优化
线程安全
这里讨论的线程安全,将以多个线程之间存在共享数据访问为前提,Java语言中各种操作共享的数据可分为五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。我们通常意义上所讲的线程安全其实是相对线程安全,它需要保证对这个对象单次的操作是线程安全的,我们在调用的时候不需要进行额外的保障措施。在Java语言中,大部分声称线程安全的类都属于这种类型,例如Vector、HashTable、Collections的synchronizedCollection()方法包装的集合等。
线程安全的实现方法
互斥同步
互斥同步(Mutual Exclusion & Synchronization)是一种最常见也是最主要的并发正确性保障手段,指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者是一些,当使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是常见的互斥实现方式。
Synchronized
synchronized是可重入的,持有锁的线程可以反复进入同步代码块。同时也是一个重量级(Heavy-Weight)的操作,Java的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成,这就不可避免地陷入用户态到核心态的转换中,进行这种状态转换需要耗费很多的处理器时间。
synchronized关键字经过Javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java源码中的synchronized明确指定了对象参数,那就以这个对象的引用作为reference;如果没有明确指定,那将根据synchronized修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的Class对象来作为线程要持有的锁。
在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值加一,而在执行monitorexit指令时会将锁计数器的值减一,一旦计数器的值为零,锁随即就被释放了。
ReentrantLock
自JDK 5起(实现了JSR 166),Java类库中新提供了java.util.concurrent包(下文称J.U.C包),其中的
java.util.concurrent.locks.Lock接口便成了Java的另一种全新的互斥同步手段。重入锁(ReentrantLock)是Lock接口最常见的一种实现,它与synchronized一样是可重入的,并且性能不会弱于synchronized。与synchronized相比,ReentrantLock增加了一些高级功能,主要有以下三项:
- 等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
- 公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。
- 锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象。
synchronized中的锁是非公平的,ReentrantLock在默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。不过一旦使用了公平锁,将会导致ReentrantLock的性能急剧下降,会明显影响吞吐量。
非阻塞同步
互斥同步面临的主要问题是进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步(Blocking Synchronization),属于一种悲观的并发策略。随着硬件指令集的发展,有了另外一个选择:基于冲突检测的乐观并发策略。通俗地说就是不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施。这样实现不再需要把线程阻塞挂起,因此这种同步操作被称为非阻塞同步,使用这种措施的代码也常被称为无锁(Lock-Free)编程。
乐观并发策略需要要求操作和冲突检测这两个步骤具备原子性,而这原子性便是靠硬件来实现的,硬件保证某些从语义上看起来需要多次操作的行为可以只通过一条计算机指令就能完成。这类指令常用的有:
- 测试并设置(Test-and-Set)
- 获取并增加(Fetch-and-Increment)
- 交换(Swap)
- 比较并交换(Compare-and-Swap,CAS)
- 加载链接/条件储存(Load-Linked/Store-Conditional,LL/SC)
Java里最终暴露出来的是CAS操作,所以我们以CAS指令为例进行讲解。CAS指令需要有三个操作数,分别是内存位置(在Java中可以简单地理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和准备设置的新值(用B表示)。CAS指令执行时,当且仅当V符合A时,处理器才会用B更新V的值,否则它就不执行更新。
但是,不管是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作,执行期间不会被其他线程中断。
在JDK 5之后,Java类库中才开始使用CAS操作,该操作由sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供。譬如J.U.C包里面的整数原子类,其中的compareAndSet()和getAndIncrement()等方法都使用了Unsafe类的CAS操作来实现。
乐观并发示例:
public class AtomicTest {
public static AtomicInteger race = new AtomicInteger(0);
public static void increase() {
race.incrementAndGet();
}
private static final int THREADS_COUNT = 20;
public static void main(String[] args) throws Exception {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
increase();
}
}
});
threads[i].start();
}
while (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(race);
}
}
使用AtomicInteger代替int后,程序输出了正确的结果,这一切都要归功于incrementAndGet()方法的原子性。它的实现其实非常简单,如下所示:
/**
* Atomically increment by one the current value.
*
* @return the updated value
*/
public final int incrementAndGet() {
for (; ; ) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next)) {
return next;
}
}
}
incrementAndGet()方法在一个无限循环中,不断尝试将一个比当前值大一的新值赋值给自己。如果失败了,那说明在执行CAS操作的时候,旧值已经发生改变,于是再次循环进行下一次操作,直到设置成功为止。
但CAS存在逻辑漏洞,如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然为A值,那就能说明它的值没有被其他线程改变过了吗?这是不能的,因为如果在这段期间它的值曾经被改成B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过,这个漏洞称为CAS操作的ABA问题。
J.U.C包为了解决这个问题,提供了一个带有标记的原子引用类AtomicStampedReference,它可以通过控制变量值的版本来保证CAS的正确性。但大部分情况下ABA问题不会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更为高效。
无同步方案
要保证线程安全,也并非一定要进行阻塞或非阻塞同步,同步与线程安全两者没有必然的联系。同步只是保障存在共享数据争用时正确性的手段,如果能让一个方法本来就不涉及共享数据,那它自然就不需要任何同步措施去保证其正确性,因此会有一些代码天生就是线程安全的。
- 可重入代码(Reentrant Code):这种代码又称纯代码(Pure Code),是指可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响。
- 线程本地存储(Thread Local Storage):如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。
大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程限制在一个线程中消费完,其中最重要的一种应用实例就是经典Web交互模型中的一个请求对应一个服务器线程(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多Web服务端应用都可以使用线程本地存储来解决线程安全问题。
锁优化
自旋锁和自适应自旋锁
互斥同步中对性能影响最大的就是阻塞的实现,挂起线程和恢复线程的操作都需要由用户态转入内核态中完成,这些操作给虚拟机的并发性能增加了很大的压力。但其实在有些应用上,共享数据的锁定状态只会持续很短的一段时间,并不需要去挂起和恢复线程。在多核处理器上,可以让两个及以上的线程同时并行执行,我们可以让后面请求锁的线程'稍等一会',但不放弃处理器的处理时间,看看持有锁的线程是否很快释放锁。为了让线程等待,我们需要让它执行一个忙循环(自旋),这就是自选锁。自选次数默认为十次,可通过参数-XX:PreBlockSpin 来调整。
在JDK6 中对自旋锁又进行了优化,引入了自适应的自旋锁。自适应意味着自旋的时间不再是固定的,它由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。在同一个锁对象上,如果自旋等待刚刚成功获得过锁,那么虚拟机会认为这次自旋也很有可能再次成功,进而允许自旋持续相对更长时间;如果自旋很少成功获取锁,在以后可能会直接忽略自旋过程,以避免浪费处理器资源。
锁消除
即时编译器在运行时,对一些要求同步的代码,检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问,那就可以把它们当作是栈上数据对待,认为它们是线程私有的,同步加锁自然无须进行。
锁粗化
原则上,我们推荐将同步块的作用范围限制得尽量小,只在共享数据的实际作用域才进行同步。这样使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽快的拿到锁。但如果在一系列的连续操作都对一个对象反复加锁和解锁时,即便没有线程竞争也会导致不必要的性能损耗。虚拟机检测到这种情况时,将会把加锁同步的范围扩展到整个操作序列的外部,避免反复加解锁,如StringBuilder对象连续append时。
轻量级锁
JDK6中轻量级锁引入的目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。要理解轻量级锁需要先对HotSpot虚拟机对象头内存布局有所了解,Hotspot 对象头分为两部分,第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄等,这部分称为mark word,如下表所示;第二部分用于存储指向方法区对象类型数据的指针,如果是数组,还会有额外存储数据长度。
32位 HotSpot对象头Mark Word
轻量级锁的加锁过程为:在代码即将进入同步块时,如果此同步对象处于未锁定状态,虚拟机将在当前栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的mark word拷贝。然后虚拟机私用CAS操作尝试把对象的Mark Word 更新为指向Lock Record的指针,如果更新成功,即代表该线程拥有了这个对象的锁,Mark Word锁标志位更改为00。如果更新失败,意味着存在线程竞争,并且锁对象已经被其他线程抢占了。如果存在两条以上的线程竞争同一个锁,那轻量级锁就不再有效,必须膨胀为重量级。
轻量级锁CAS操作之前堆栈与对象的状态
轻量级锁CAS操作之后堆栈与对象的状态
解锁过程:通过CAS,如果对象的Mark Word仍然指向线程的锁记录,那就把对象的Mark Word和线程中拷贝的Mark Word替换回来。如果替换成功,解锁过程就完成了;如果替换失败,说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。
偏向锁
JDK6还引入了偏向锁(默认开启,禁用参数-XX:-UseBiasedLocking),目的是消除数据在无竞争情况下的同步原语,提高程序的运行性能。轻量级锁是在无竞争的情况下使用CAS操作消除同步使用的互斥量,而偏向锁则是在无竞争情况下把整个同步都消除掉,连CAS操作都不需要。
偏向锁中偏的意义时这个锁会偏向于第一个获得它的线程,如果在接下来的过程中,该锁一直没有被其他的线程获得,则持有偏有锁的线程永远不需要进行同步。
偏向锁加锁过程:当锁对象第一次被线程获取的时候,虚拟机会把对象头中的偏向标志由0设为1,同时获取对象头中的线程ID记录在对象的Mark Word中。如果CAS操作成功,持有偏向锁的线程每次进入这个锁相关的同步块时都可以不做任何同步操作。
偏向锁解锁过程:一旦出现另一个线程尝试获取这个锁时,偏向模式马上宣告结束。根据锁对象目前是否处于锁定状态决定是否撤销偏向,锁标志位恢复到未锁定或者轻量级锁定状态。
偏向锁、轻量级锁的状态转换及对象Mark Word关系
偏向模式时使用线程ID占用了HashCode位置,这有个前提是对象没有计算过一致性哈希值,即对象头中没有存储哈希值。线程Id不能覆盖哈希值,所以在锁对象计算过一致性哈希之后,偏向锁就失效了;在持有偏向锁的过程中收到计算一致性哈希请求,偏向状态会立即撤销,锁膨胀为重量级锁。
Object::hashCode()方法,返回的是对象的一致性哈希码(Identity Hash Code),这个值是能强制保证不变的,它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变。而对象重载hashCode方法之后,不能保证一致性也就无需存储在对象头中。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· 没有源码,如何修改代码逻辑?
· 一个奇形怪状的面试题:Bean中的CHM要不要加volatile?
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)