Java并发编程核心笔记
一、synchronized锁的核心原理
1. 锁的基本规则
当一个线程试图访问同步代码块时,必须先获取锁 ,退出代码块或抛出异常时必须释放锁。
- 同步:多个线程对同一个资源轮流操作,需要互相等待
- 异步:多个线程可以同时执行,无需互相等待
2. 底层实现:monitor指令
synchronized的底层是通过JVM的monitorenter和monitorexit指令实现的:
monitorenter:编译后插入到同步代码块的开始位置monitorexit:插入到方法结束处和异常处(保证任何情况下锁都会释放)- 每个
monitorenter必须有对应的monitorexit配对
核心逻辑 :任何Java对象都有一个monitor(监视器)与之关联,当monitor被某个线程持有后,就处于锁定状态。线程执行到monitorenter时,会尝试获取对象对应monitor的所有权,也就是获取对象的锁。
3. 锁的两个关键标记
synchronized的锁本质上是给两个地方加了标记:
- 资源本身加标记 :锁信息存在Java对象头里
- 操作资源的指令范围加标记:同步块内的指令是互斥的,同一时刻只能有一个线程执行这部分代码
4. Java对象头的存储结构
对象头是synchronized实现锁的基础,不同类型的对象头长度不同:
- 非数组对象:2字宽(32位系统=64bit=8字节;64位系统=128bit=16字节)
- 数组对象:3字宽(多了4字节存储数组长度)
32位系统下,对象头中Mark Word(标记字)的核心结构如下:
| 锁状态 | 25bit | 4bit | 1bit是否偏向锁 | 2bit锁标志位 |
|---|---|---|---|---|
| 无锁状态 | 对象的hashCode | 对象分代年龄 | 0 | 01 |
| 偏向锁 | 线程ID+epoch | 对象分代年龄 | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | - | - | 00 |
| 重量级锁 | 指向monitor的指针 | - | - | 10 |
| GC标记 | - | - | - | 11 |
5. 补充:hashCode与对象地址的关系
- 两个对象地址不同 ,hashCode可能相同(hash冲突)
- 两个对象hashCode不同 ,地址肯定不同(默认hashCode基于内存地址生成,重写后不适用)
二、锁的升级机制(synchronized的三种状态)
synchronized的锁会随着竞争程度从低到高升级,升级过程不可逆(只能从偏向锁→轻量级锁→重量级锁)。
1. 为什么需要锁升级?
早期synchronized是重量级锁,线程竞争失败会直接进入阻塞队列,上下文切换开销大。JDK1.6引入锁升级后,根据竞争程度选择不同的锁实现,大幅提升了性能。
2. 三种锁的适用场景与原理
| 锁类型 | 适用场景 | 核心原理 |
|---|---|---|
| 偏向锁 | 无竞争(只有一个线程) | 把线程ID直接记录在对象头中,下次该线程访问时无需CAS,直接获取锁 |
| 轻量级锁 | 轻度竞争(少量线程) | 竞争失败的线程不进入阻塞队列 ,而是通过自旋不断尝试获取锁 |
| 重量级锁 | 重度竞争(大量线程) | 竞争失败的线程进入阻塞队列,等待持有锁的线程唤醒,上下文切换开销大 |
3. 关键问题解答
问:线程没竞争到资源为什么要进阻塞队列?
答:如果让竞争失败的线程留在就绪队列,当持有锁的线程A时间片用完后,CPU可能又会调度到这个线程,但此时资源还被A锁着,会白白浪费CPU时间片。
三、原子操作与CAS
1. 什么是原子操作?
原子操作是不可拆分的操作,要么全部执行成功,要么全部执行失败并回滚。
- 例子:
int a=1是原子操作;a++不是原子操作(分为读a、加1、写回a三步)
2. 如何保证原子性?
CPU层面提供了两种机制保证原子操作:
- 总线锁 :处理器输出
LOCK#信号,阻塞其他处理器的内存请求,独占共享内存(开销大) - 缓存锁:利用缓存一致性协议,阻止多个处理器同时修改同一块缓存行的数据(常用)
Java层面:
- 使用
synchronized关键字 - 使用
java.util.concurrent.atomic包下的原子类(基于CAS实现)
3. CAS(比较并交换)详解
CAS是CPU提供的原子指令,是无锁编程的基础。
- 三个操作数:内存位置V、预期值A、新值B
- 执行逻辑:只有当V的值等于A时,才将V更新为B;否则什么都不做,返回当前V的值
代码示例:用CAS实现线程安全计数器
java
private AtomicInteger atomicI = new AtomicInteger(0);
private void safeCount() {
for (;;) { // 自旋
int i = atomicI.get();
// 比较当前值是否等于i,如果是则更新为i+1
boolean suc = atomicI.compareAndSet(i, ++i);
if (suc) {
break;
}
}
}
4. CAS的优缺点与三大问题
优点 :无锁、并发弱时性能远高于synchronized
缺点:并发激烈时,大量线程自旋会消耗大量CPU
三大核心问题及解决方法:
- ABA问题 :变量的值从A变成B,又变回A,CAS会误以为没有被修改过
- 解决:使用版本号或时间戳,如
AtomicStampedReference(同时比较值和版本号)
- 解决:使用版本号或时间戳,如
- 循环时间长开销大 :并发激烈时,线程自旋很久都获取不到锁
- 解决:自适应自旋(JDK1.6默认),或并发激烈时改用synchronized
- 只能保证一个共享变量的原子操作
- 解决:把多个共享变量封装成一个对象,使用
AtomicReference;或直接用synchronized
- 解决:把多个共享变量封装成一个对象,使用
四、内存可见性与指令重排序
1. 内存可见性问题
定义 :一个线程修改了共享变量,其他线程不能及时看到这个修改。
根源:Java内存模型(JMM)规定:
- 所有变量都存储在主内存中
- 每个线程有自己的工作内存(抽象概念,对应CPU的高速缓存、寄存器)
- 线程对变量的操作必须在工作内存中进行,不能直接读写主内存
当线程A修改了变量后,只是更新了自己的工作内存,还没刷新到主内存;此时线程B读取的还是自己工作内存中的旧值,就会出现可见性问题。
2. 如何保证内存可见性?
Java中通过以下三种方式保证内存可见性:
- volatile关键字 :
- 写操作:修改后立即刷新到主内存
- 读操作:直接从主内存读取,跳过工作内存
- 额外作用:禁止指令重排序
- synchronized关键字 :
- 加锁时:清空工作内存,从主内存读取最新值
- 解锁时:将工作内存的修改刷新到主内存
- final关键字 :
- final修饰的变量一旦初始化完成,其他线程就能看到它的正确值
- JMM禁止final变量的写操作与构造方法重排序,避免出现未初始化的情况
3. 指令重排序
定义:编译器和处理器为了提高执行效率,会对指令进行重新排序,可能导致指令的执行顺序与代码的编写顺序不一致。
重排序的类型:
- 编译器重排序:编译器在不改变单线程语义的前提下,重新安排指令的执行顺序
- 处理器重排序:
- 指令级并行重排序:处理器将多条指令重叠执行
- 内存系统重排序:处理器的缓存和写缓冲区导致指令执行顺序看起来乱序
经典问题示例:
java
// 初始状态:a = b = 0
// 处理器A执行:
a = 1; // A1
x = b; // A2
// 处理器B执行:
b = 2; // B1
y = a; // B2
由于指令重排序,可能出现A2→A1和B2→B1的执行顺序,最终结果为x = y = 0,这就是重排序导致的并发问题。
五、线程与进程通信
1. 基本概念
通信的本质是交换信息。线程是进程内的执行单元,共享进程的地址空间;进程有独立的地址空间,通信需要操作系统介入。
2. 线程间通信的方式
- 共享内存 :多个线程对同一个对象或静态变量进行操作(最常用)
- 注意:必须配合同步机制(synchronized、volatile、原子类)保证线程安全
- 消息传递 :
- 基础方式:
wait()/notify()/notifyAll()(基于对象的等待集) - 工具类:
CountDownLatch、CyclicBarrier、Semaphore、BlockingQueue
- 基础方式:
- 管道流 :
PipedInputStream和PipedOutputStream,用于线程间的字节流传输
3. 进程间通信(IPC)的方式
- 管道 :
- 匿名管道:只能用于父子进程之间的通信
- 命名管道:可用于任意进程之间的通信
- 消息队列:消息的链表,独立于进程,进程可以通过读写消息队列交换数据
- 共享内存:最快的IPC方式,多个进程映射同一块物理内存,直接读写
- 信号量:用于进程间的同步和互斥
- 信号:用于通知进程发生了某个事件(如Ctrl+C发送SIGINT信号)
- 套接字(Socket):跨网络的进程通信,支持不同主机上的进程通信
4. 纠正原文错误
原文"进程有的通信方式线程全都有;线程有的进程就不一定了"表述不准确,正确的是:
- 线程可以使用所有进程间的通信方式(如管道、Socket等)
- 线程有更轻量的通信方式(直接共享变量),而进程不能直接共享变量(地址空间独立)
六、补充知识点
- 静态对象的内存分布 :
- JDK8及以前:静态变量的句柄存储在方法区的静态常量池,实例对象存储在堆内存
- JDK8及以后:方法区被元空间(Metaspace)取代,静态变量和常量池移到了堆内存中
- JMM中的本地内存:是一个抽象概念,对应CPU的寄存器、高速缓存、写缓冲区等硬件
- 锁的选择建议 :
- 并发弱:优先使用CAS(原子类)
- 并发中等:使用轻量级锁(synchronized会自动升级)
- 并发激烈:使用重量级锁或线程池控制并发数