【多线程二】——线程安全
Java并发编程核心机制深度解析
Java并发编程是构建高性能、高吞吐量应用的核心,但其复杂性也带来了诸多挑战。理解其底层机制是编写正确、高效并发程序的关键。本文将深入探讨synchronized
、JMM、CAS、volatile
、AQS、Lock
、死锁以及ConcurrentHashMap
等核心概念。
一、synchronized关键字的底层原理
synchronized
是Java中最基本的互斥同步手段。它的底层原理涉及Java对象头和Monitor(监视器)。
实现方式:
- 同步代码块:使用
monitorenter
和monitorenter
指令实现。当线程执行到monitorenter
时,会尝试获取对象的Monitor所有权。获取成功则锁计数器+1;执行到monitorexit
时,计数器-1。计数器为0时,锁被释放。 - 同步方法:方法常量池中有一个
ACC_SYNCHRONIZED
标志。当线程调用方法时,会检查此标志,然后自动先获取对象的Monitor,方法执行完后再释放。
- 同步代码块:使用
对象头与Mark Word:
- Java对象在内存中分为三部分:对象头、实例数据和对齐填充。
- 对象头包含两部分:
Mark Word
和类型指针。 Mark Word
用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、持有锁的线程ID、偏向时间戳等。它是synchronized
实现锁的关键。
锁升级优化(非常重要): 为了减少获得锁和释放锁带来的性能消耗,JDK 1.6后引入了“偏向锁”、“轻量级锁”等优化,锁状态会随着竞争情况逐步升级。锁只能升级,不能降级。
- 无锁状态:初始状态。
- 偏向锁:一段同步代码一直被一个线程访问,那么该线程会自动获取锁,降低获取锁的代价。只需在
Mark Word
中存储锁偏向的线程ID。 - 轻量级锁:当有另一个线程来竞争偏向锁时,偏向锁会升级为轻量级锁。竞争线程会通过CAS自旋(循环尝试)来获取锁,不会阻塞,适用于追求响应时间、锁占用时间很短的场景。
- 重量级锁:轻量级锁自旋到一定次数(或一个线程在自旋,另一个线程又来竞争),会升级为重量级锁。此时,竞争失败的线程会陷入内核态,被挂起,等待操作系统调度,性能损耗最大。
小结:
二、JMM(Java内存模型)
JMM(Java Memory Model)是一个抽象概念,它定义了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量的底层细节。
核心目标:解决由于多线程通过共享内存进行通信时,存在的原子性、可见性和有序性问题。
主内存与工作内存:
- 主内存:所有共享变量都存储在主内存中。
- 工作内存:每个线程都有自己的工作内存,其中保存了该线程使用到的变量的主内存副本拷贝。
- 线程对变量的所有操作(读、写)都必须在工作内存中进行,而不能直接读写主内存中的变量。
- 不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
JMM与硬件内存结构: JMM是逻辑上的规范,它屏蔽了底层不同硬件内存结构的差异(如多核CPU的多级缓存)。工作内存可以粗略理解为CPU的寄存器和高速缓存(L1, L2)。
Happens-Before原则: 这是JMM的核心内容,用于判断数据是否存在竞争,线程是否安全。它规定了如果一个操作
happens-before
于另一个操作,那么第一个操作的结果对第二个操作一定是可见的。 常见的规则包括:- 程序次序规则:一个线程内,书写在前面的操作
happens-before
于后面的操作。 - 监视器锁规则:对一个锁的解锁
happens-before
于随后对这个锁的加锁。 - volatile变量规则:对一个
volatile
域的写happens-before
于任意后续对这个volatile
域的读。 - 传递性:如果A
happens-before
B,且Bhappens-before
C,那么Ahappens-before
C。
- 程序次序规则:一个线程内,书写在前面的操作
三、对CAS的理解
CAS(Compare-And-Swap),即比较并交换,是一种无锁的原子操作算法。
操作过程:它包含三个操作数——内存位置(V)、预期原值(A)和新值(B)。
- 当且仅当V的值等于A时,处理器才会用B更新V的值。
- 否则,它不会执行任何操作(或者返回当前V的值)。
- 整个操作是一个原子操作。
底层实现:CAS是CPU指令级的操作,现代CPU大都支持。在Java中,通过
Unsafe
类的本地方法(如compareAndSwapInt
)调用CPU指令实现。应用:
java.util.concurrent
包中的原子类(如AtomicInteger
)大量使用了CAS操作来实现并发安全。经典问题——ABA问题:
- 描述:一个线程将变量V从A改为B,然后又改回A。另一个线程看到V的值还是A,就误以为它没有被修改过,从而进行了CAS操作。
- 解决方案:使用
AtomicStampedReference
或AtomicMarkableReference
,它们不仅比较值,还会比较一个版本号(Stamp)或标记,从而避免ABA问题。
小结:
四、对 volatile 的理解
代码执行结果:
原因:
1. 可见性
- 问题:根据JMM,普通变量被修改后,不会立即写回主内存,其他线程的工作内存中的副本也就不会立即失效,从而导致其他线程读取到的可能是旧的脏数据。
volatile
的作用:当一个变量被声明为volatile
后:- 写操作:对
volatile
变量的写操作会立即刷新到主内存。 - 读操作:对
volatile
变量的读操作会使当前线程的工作内存中该变量的副本失效,从而必须从主内存中重新读取最新值。
- 写操作:对
- 底层实现:通过在指令序列中插入内存屏障(Memory Barrier)来禁止特定类型的处理器重排序,并强制刷新CPU缓存。
2. 禁止指令重排序
测试:
@JCStressTest:多线程测试提供的测试框架
{"0,0","1,1","0,1"} desc="ACCRPTABLE":设定x,y正常的情况
“1,0” desc="INTERSTING":属于不正常的情况
启动测试:
如果不能运行,就先在maven里面clean一下,再package后再运行
然后就可以在results文件夹下的html查看测试信息
再给y加上volatile测试
volatile应该修饰哪个变量:
- 问题:为了提高性能,编译器和处理器常常会对指令进行重排序。在单线程下,这不会影响结果,但在多线程下,可能导致出乎意料的结果。
volatile
的作用:通过添加内存屏障,volatile
实现了禁止指令重排序优化。- 当程序执行到
volatile
变量的读/写操作时,其前面的所有操作肯定已经完成,且结果对后续操作可见。 - 在进行指令优化时,不能把
volatile
变量前面的语句放在其后面执行,也不能把后面的语句放到其前面执行。
- 当程序执行到
- 经典应用——单例模式的双重检查锁(DCL):
public class Singleton {private static volatile Singleton instance;public static Singleton getInstance() {if (instance == null) { // 第一次检查synchronized (Singleton.class) { // 加锁if (instance == null) { // 第二次检查instance = new Singleton(); // 关键:volatile防止这里的指令重排序}}}return instance;}}
如果没有`volatile`,`new Singleton()`这一行代码可能被重排序(先分配内存地址,后初始化对象),导致其他线程拿到一个未完全初始化的对象。`volatile`禁止了这种重排序,保证了线程安全。
五、什么是AQS
AQS(AbstractQueuedSynchronizer),即抽象队列同步器,是JUC并发包的核心基础组件。
作用:它用一个int类型的volatile变量(
state
) 来表示同步状态,并提供了一个基于FIFO的等待队列(CLH队列的变种),用于构建锁和其他同步组件(如ReentrantLock
、CountDownLatch
、Semaphore
等)。核心思想:
- 如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态(
state=1
)。 - 如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制。AQS使用CLH队列锁来实现,将暂时获取不到锁的线程加入到队列中。
- 如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态(
工作方式:AQS定义了两种资源共享方式:
- 独占(Exclusive):只有一个线程能执行,如
ReentrantLock
。 - 共享(Share):多个线程可同时执行,如
Semaphore
/CountDownLatch
。
自定义同步器只需要实现对
state
的获取和释放即可。- 独占(Exclusive):只有一个线程能执行,如
公平锁和非公平锁:
小结:
六、ReentrantLock的实现原理
ReentrantLock
是基于AQS实现的一个可重入的互斥锁。
可重入性:线程可以再次获取自己已经持有的锁。
state
变量用来记录重入次数。线程第一次获取锁时,state
变为1;再次获取时,state
+1;释放时,state
-1,直到state
为0,锁才被完全释放,其他线程才能获取。公平性与非公平性:
- 非公平锁(默认):当锁可用时,不管等待队列中是否有其他线程在等待,新来的线程都有机会直接竞争锁。吞吐量高,但可能产生线程饥饿。
- 公平锁:锁被释放后,严格按照FIFO的顺序从等待队列中唤醒线程,先到先得。保证了公平性,但性能相对较低,因为要维护队列顺序。
实现:
ReentrantLock
内部有一个继承自AQS的同步器(Sync
),FairSync
和NonfairSync
分别实现了公平和非公平的获取锁逻辑。
小结:
七、synchronized和Lock有什么区别
特性 | synchronized | Lock (如 ReentrantLock ) |
---|---|---|
存在层次 | Java关键字,JVM原生支持 | 是接口,位于java.util.concurrent 包 |
锁的释放 | 自动释放(代码块执行完或异常) | 手动调用unlock() 释放,必须在finally 中执行 |
锁的获取 | 线程会一直阻塞等待,无法中断 | 提供多种获取方式:tryLock() 、lockInterruptibly() |
可中断 | 不可中断 | 可中断,等待锁的线程可以响应中断 |
公平锁 | 非公平 | 两者都可(构造函数指定) |
绑定条件 | 单一,随机唤醒或全部唤醒 | 可关联多个条件(Condition ),实现分组唤醒 |
性能 | JDK 1.6后优化很好,大部分场景够用 | 在高竞争环境下表现更好,API更灵活 |
选择:优先使用synchronized
,因其简洁可靠。只有在需要Lock
提供的高级特性(如可中断、超时尝试、公平锁、多个条件变量)时,才使用Lock
。
1.可打断
2、可超时
3、多条件变量
八、死锁产生的条件以及死锁排查方案
死锁产生的四个必要条件(缺一不可):
- 互斥条件:资源一次只能被一个线程占用。
- 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程已获得的资源,在未使用完之前,不能被强行剥夺。
- 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
死锁排查方案:
- 命令行:
- 使用
jps
命令查找当前Java进程的PID。 - 使用
jstack <pid>
命令打印线程堆栈信息。
- 使用
- 分析堆栈信息:
jstack
工具能自动检测并报告死锁。在输出的最后,如果有Found one Java-level deadlock:
字样,下面会详细列出哪些线程在等待哪些锁,形成了循环等待。 - 图形化工具:使用JConsole、VisualVM等工具,它们也有检测死锁的功能。
- 命令行:
小结:
九、聊一下ConcurrentHashMap
ConcurrentHashMap
是JUC包中提供的线程安全的HashMap。
JDK 1.7 vs JDK 1.8:
- JDK 1.7:采用分段锁(Segment)机制。容器里包含一个Segment数组,每个Segment就是一个锁,类似于一个HashMap。不同的Segment互不干扰。默认有16个Segment。
- JDK 1.8及以后:摒弃了分段锁,改用**
synchronized
+ CAS + volatile**的实现方式。- 锁的粒度更小:只锁住链表或红黑树的头节点(桶),并发度更高。
- 使用
synchronized
:得益于JDK1.6对synchronized
的优化,其性能已大幅提升。 - 大量使用CAS:用于无竞争条件下的put操作,避免加锁开销。
- 数据结构:数组+链表+红黑树,和HashMap一样。
为什么高效:
- 读操作通常无锁(通过
volatile
保证可见性)。 - 写操作只在冲突时加锁,且锁粒度非常小(一个桶)。
- 通过CAS进行无锁化竞争,降低了线程上下文切换的开销。
- 读操作通常无锁(通过
十、导致并发程序出现问题的根本原因是什么
并发程序问题的根源可以归结为JMM试图解决的三大特性被破坏:
可见性:一个线程对共享变量的修改,另一个线程不能立即看到。
- 根源:CPU的多级缓存机制以及线程工作内存与主内存的同步延迟。
原子性:一个或多个操作,要么全部执行成功,要么全部不执行,不会被打断。
- 根源:操作系统线程调度(时间片轮转),高级编程语言中的一句代码可能对应多条CPU指令,在执行过程中可能被中断。
有序性:程序执行的顺序不一定按照代码的先后顺序执行。
- 根源:编译器和处理器为了优化性能,会对指令进行重排序(Instruction Reorder)。
总结:并发编程的所有技术和工具(锁、volatile
、原子变量、AQS等),其最终目的都是为了解决可见性、原子性和有序性这三大问题,从而保证线程安全。