线程安全与锁优化

线程安全与锁优化

锁可以用来保证线程安全,线程安全却未必一定需要锁。

线程安全

简单来说:如果多线程访问一个对象时,无需进行额外的同步或协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。

Brian Goetz(Java 并发编程实战的作者)将 Java 语言中各种操作共享的数据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

不可变

如果一个对象是 final 修饰的基本类型,在构造方法中初始化完成,那其外部的可见状态永远都不会改变。“不可变”带来的安全性是最直接、 最纯粹的。

在 Java 类库 API 中符合不可变要求的类型,除了 String 之外,常用的还有枚举类型及 java.lang.Number 的部分子类,如 Long 和 Double 等数值包装类型、BigInteger 和 BigDecimal 等大数据类型。

绝对线程安全

绝对的线程安全其实是很难满足的,Java 中几乎没有真正的绝对线程安全类。

像我们所熟知的 Collections 的 synchronizedCollection() 方法包装的集合,即使所有的方法都被 synchronized 修饰,也不意味着访问时可以完全不考虑同步。

举个例子:一个被 synchronizedCollection() 包装的 ArrayList,一个线程不断的删除元素,一个线程不断的遍历元素,有几率碰到 ConcurrentModificationException。原因很简单,一个方法是安全的,不意味着后续的操作也安全,例如 .size() 方法返回了当前精确的元素数,但真正进行操作时可能这个位置的元素已经被删除了,synchronized 能保证每个方法本身是安全的,不能保证业务逻辑也是线程安全的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class Test {
public static void main(String[] args) throws InterruptedException {
// 创建一个同步 List
List<Integer> syncList = Collections.synchronizedList(new ArrayList<>());
for (int i = 0; i < 100; i++) {
syncList.add(i);
}

// 线程1:不断删除元素
Thread remover = new Thread(() -> {
Random random = new Random();
while (true) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
break;
}
if (!syncList.isEmpty()) {
int index = random.nextInt(syncList.size());
syncList.remove(index);
System.out.println("删除了索引 " + index);
}
}
});

// 线程2:遍历集合
Thread iterator = new Thread(() -> {
while (true) {
try {
Thread.sleep(15);
} catch (InterruptedException e) {
break;
}
// 危险操作:没有外部同步的迭代
for (Integer num : syncList) {
System.out.println("遍历到: " + num);
}
}
});

remover.start();
iterator.start();

// 运行3秒后停止
Thread.sleep(3000);
remover.interrupt();
iterator.interrupt();
}
}

相对线程安全

相对线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单次的操作是线程安全的,我们在调用的时候不需要进行额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。

在 Java 语言中,大部分声称线程安全的类都属于这种类型,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包装的集合等。

线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。我们平时说的线程不安全通常就是指这种情况。

Java 类库 API 中大部分的类都是线程兼容的,例如 ArrayList、HashMap 等。

线程对立

线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。这种情况很少,而且通常是有害的,Java 中的一些方法已经被声明废弃了。

实现线程安全

这里并不是演示如何使用锁的代码来实现线程安全,那是并发编程的内容,这里侧重于描述虚拟机提供的同步和锁机制,可以结合 系统性能优化之锁篇 一起看。

互斥同步

互斥同步(Mutual Exclusion & Synchronization)是一种最常见也是最主要的并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者是一些, 当使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量 (Mutex)和信号量(Semaphore)都是常见的互斥实现方式

在“互斥同步”这四个字里面,互斥是因,同步是果;互斥是方法,同步是目的。

Java 里面,最基本的互斥同步手段就是 synchronized 关键字,它会在同步块的前后分别形成 monitorenter 和monitorexit 这两个字节码指令。关于 synchronized 到底锁的是什么这里不再赘述,锁的对象是这两个字节码指令的参数。

根据《Java 虚拟机规范》的要求,在执行 monitorenter 指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行 monitorexit 指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。

根据上述描述,我们可以得出如下结论:

  • 被 synchronized 修饰的同步块对同一条线程来说是可重入的;
  • 被 synchronized 修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。

那么我们可以思考一个问题,如果锁的代码块执行速度非常快,比阻塞切换线程还要快,那么这样是不是太重了?于是 synchronized 后来进行了优化,会先进行一定次数的自旋才进行阻塞。

除 synchronized 外,JDK5 还引入了一些新的并发工具,如 Lock 接口也可以用来实现互斥同步,重入锁(ReentrantLock)是 Lock 接口最常见的一种实现,它也是可重入的。引入新的工具自然要和老的工具进行比对,Lock 比 synchronized 多了一些高级特性:

  • 等待可中断:线程执行到 synchronized 修饰到代码块时如果取不到锁会一致阻塞,而 Lock 可以通过 tryLock 尝试获取锁指定时间;
  • 公平锁:锁是否公平是指是否按照请求锁的顺序获得锁。synchronized 中的锁是非公平的,ReentrantLock 默认也是非公平的,但可以通过构造参数要求使用公平锁,不过公平锁一般会影响吞吐量;
  • 锁绑定多个条件:一个 ReentrantLock 对象可以同时绑定多个 Condition 对象被唤醒,而 synchronized 只能通过 wait 和 notify/notifyall 实现一个隐含的条件。

不过在使用 Lock 时要注意,Lock 是无法自动释放的,一定要在 finally 中手动释放。

即使 Lock 的功能如此强大,性能也不逊色于 synchronized(JDK6 优化之前更是远优),但依然推荐在满足条件的情况下优先使用 synchronized,原因如下:

  • synchronized 是在 Java 语法层面的同步,足够清晰,也足够简单;
  • synchronized 可以由 Java 虚拟机来确保即使出现异常,锁也能被自动释放;
  • Java 虚拟机更容易针对 synchronized 来进行优化,因为 Java 虚拟机可以在线程和对象的元数据中记录synchronized 中锁的相关信息。

非阻塞同步

互斥同步面临的主要问题是进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步(Blocking Synchronization)。阻塞同步是一种悲观的实现,因为即使不存在并发问题,也会去获取锁,执行完后释放锁。

随着硬件指令集的发展,我们有了其他选项:例如不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止。

相比大家都听说或用过大名鼎鼎的 CAS(compare and swap,比较并交换),这个函数需要有三个操作数,分别是内存位置(在 Java 中可以简单地理解为变量的内存地址,用 V 表示)、旧的预期值(用 A 表示)和准备设置的新值(用 B 表示)。CAS 指令执行时,当且仅当 V 符合 A 时,处理器才会用 B 更新 V 的值,否则它就不执行更新。但是,不管是否更新了 V 的值,都会返回 V 的旧值,上述的处理过程是一个原子操作,执行期间不会被其他线程中断。

JDK 中原子类的 incrementAndGet 就是这么实现的:

1
2
3
4
5
6
7
8
public final int incrementAndGet() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return next;
}
}

CAS 存在一个 ABA 问题,即我在读取时是 A,但在我操作前它先被修改为 B,又被修改为 A,那么依然符合指令的执行条件。为了解决这个问题,出现了一个带有标记的原子引用类 AtomicStampedReference,它可以通过控制变量值的版本来保证 CAS 的正确性。不过大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更为高效。

无同步方案

如果提倡的微服务无状态化一样,如果代码块满足不依赖全局变量、存储在堆上的数据和公用的系统资源, 用到的状态量都由参数中传入,不调用非可重入的方法等条件,那它也是线程安全的。

可重入代码块的定义是:可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响。

另外像 Thread Local Storage(线程本地存储),如果能把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。常见的使用有 ThreadLocalMap,详细了解可以参见:FastThreadLocal快在哪

锁优化

作为能同时保证原子性、可见性和有序性的 synchronized,自然值得被优化。

HotSpot 虚拟机开发团队在 JDK6 上花费了大量的资源去实现各种锁优化技术,如适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁膨胀(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)等,这些技术都是为了在线程之间更高效地共享数据及解决竞争问题,从而提高程序的执行效率。

自适应自旋

像我们前面说的那样,有时候要同步的代码块执行时间比线程切换阻塞要短的多,那么线程没必要阻塞,只须让线程执行一个忙循环(自旋)即可,这就是所谓的自旋锁。

JDK6 中就默认开启了自旋,自旋虽然避免了线程切换的开销,但它是要占用处理器时间的,所以自旋只适合阻塞的代码执行非常快。自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。自旋次数的默认值是十次,用户也可以使用参数-XX:PreBlockSpin来更改。

不过无论是默认值还是用户指定的自旋次数,对整个 Java 虚拟机中所有的锁来说都是相同的。在 JDK6 中对自旋锁的优化,引入了自适应的自旋。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。

有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越精准,虚拟机就会变得越来越“聪明”了。

锁消除

举个例子:

1
2
3
4
5
6
7
public String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}

StringBuffer.append() 方法中有一个同步块,锁就是对象本身。虚拟机经过逃逸分析后会发现它的动态作用域被限制在 concatString() 方法内部。也就是 sb 的所有引用都永远不会逃逸到 concatString() 方法之外,其他线程无法访问到它,所以这里虽然有锁,但是可以被安全地消除掉(例如 StringBuilder)。

锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小(只在共享数据的实际作用域中才进行同步),这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快地拿到锁。

大多数情况下,这个原则都是正确的,但有时候如果在循环体内对同一个对象反复加锁和解锁,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

1
2
3
4
5
6
7
public 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 之前到最后一个 append 之后),这样只加一次锁就可以了。

偏向锁与轻量级锁

“轻量”是相比之前锁的实现(遇到竞争就阻塞等待,涉及状态切换和线程调度),它不是为了代替重量级锁的,其初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗 。

Java Object 中介绍了 Java 中对象的格式,其中有对象头(Header),Header 中存储了对象运行时数据的部分被称为 “Mark Word”,这是实现轻量级锁和偏向锁的关键。

image-20260525111615505

偏向锁中的“偏”,就是偏心的“偏”、偏袒的“偏”。偏向锁主要用来优化同一线程多次申请同一个锁的竞争。可能大部分时间一个锁都是被一个线程持有和竞争。假如一个锁被线程 A 持有,后释放;接下来又被线程 A 持有、释放……如果使用 monitor,则每次都会发生用户态和内核态的切换,性能低下。

但在高并发场景下,大量线程同时竞争同一个锁资源,偏向锁会被撤销,发生 stop the world后,开启偏向锁会带来更大的性能开销(于是 Java15 默认禁用了偏向锁)。

锁的升级过程比较复杂,如果考虑细枝末节(例如锁的过程中还被计算了哈希值,对象头会如何变化)就更复杂了,推荐阅读《深入理解Java虚拟机》锁优化章节原文或参考:浅析synchronized锁升级的原理与实现


线程安全与锁优化
https://zhuwenjie0716.github.io/2026/05/26/线程安全与锁优化/
作者
Wenjie Zhu
发布于
2026年5月26日
许可协议