线程冲突、volatile、synchronized、Lock、ThreadLocal
多线程操作同一个变量的问题
Java 的变量默认是所有线程共享的,存在共享内存中
线程从共享内存读取变量到自己的工作内存,执行后再写回共享内存,什么时候读,什么时候写,由系统决定,是不确定的
这样会导致变量值的不确定性
比如 T1 和 T2 两个线程
假设有下面步骤
- T1 读取 counter 到自己的工作内存,修改值为 10,再写回共享内存
- T2 读取 counter 到自己的工作内存,修改值为 20,再写回共享内存
- T1 读取 counter 到自己的工作内存,这时值为 20,从 T1 自己的角度出发,counter 的值不完全由自己控制,会受到其他线程的干扰而变化
再比如这两个线程都对 counter 做 counter++ 操作,执行 10 次
顺利的话可能是这样执行
- T1 读取 counter 到自己的工作内存,当前值为 0,自增 5 次变成 5,再写回共享内存
- T2 读取 counter 到自己的工作内存,当前值为 5,自增 5 次变成 10,再写回共享内存
- T1 读取 counter 到自己的工作内存,当前值为 10,自增 5 次变成 15,再写回共享内存
- T2 读取 counter 到自己的工作内存,当前值为 15,自增 5 次变成 20,再写回共享内存
最终变成 20 结果正确,尽管从每个线程自己的角度出发其实是受到了干扰,但不影响最终结果
但也可能这样执行
- T1 读取 counter 到自己的工作内存,当前值为 0,做了 5 次自增变成 5,再写回共享内存
- T2 读取 counter 到自己的工作内存,当前值为 5,做了 3 次自增变成 8,但还没写回共享内存
- T1 读取 counter 到自己的工作内存,当前值为 5,做了 5 次自增变成 10,再写回共享内存
- T2 继续使用工作内存的 counter 为 8,做了 2 次自增变成 10,再写回共享内存
可以看到最终结果是 10 这是不正确的,原因在于无法保证读取、修改、写回这三个动作作为一个原子操作被执行
volatile
volatile 修饰的变量会保证每次读写该变量都需要去共享内存
volatile int counter;
注意 volatile 只是保证必然到共享内存读写数据,但同样无法保证原子性,无法保证数据的同步,比如
- T1 读取 counter 到自己的工作内存,当前值为 0,自增 1 次变成 1,再写回共享内存
- T2 读取 counter 到自己的工作内存,当前值为 1,自增 1 次变成 2,再写回共享内存
- T1 读取 counter 到自己的工作内存,当前值为 2
- T2 读取 counter 到自己的工作内存,当前值为 2,自增 1 次变成 3,再写回共享内存
- T1 继续使用工作内存的 counter 为 2,自增 1 次变成 3,再写回共享内存
可以看到这里少自增了一次,volatile 只能保证每次读和写都必须操作共享内存,防止读进来后,后面的读和写都在自己的工作内存执行(比如做了 5 次 counter++ 再写回共享内存)的情况,但实际上无法保证数据同步,除非操作是原子性的,比如如果 counter++ 是原子性的,即读共享内存,加1,写回共享内存,这一系列操作不会被其他线程打断,那就没问题,但 java 的 int 变量的 ++ 操作不是原子性的
AtomicInteger
java 也有提供支持原子操作的类,比如 AtomicInteger,它包含一个 volatile int 变量,并提供 getAndIncrement 函数实现原子性的自增
synchronized
要实现数据的同步和互斥操作,可以使用 synchronized 关键字,保证由 synchronized 修饰的函数或代码块,同一时间只能被一个线程执行,即 synchronized 修饰的函数或代码块有互斥性,在前面的例子中,如果把全部 10 次 counter++ 放到 synchronized,或是每次 counter++ 放到 synchronized 下同时 counter++ 是 volatile 变量,就不会出错
synchronized 用于函数
public synchronized void syncMethod() {
}
表示该函数每次只能被一个线程调用,其他线程需要等待
synchronized 用于代码块,括号里是对象
synchronized (this) {
}
表示这个代码块每次只能被一个线程调用,其他线程需要等待
更进一步讲,synchronized (this) 是获得了对象锁,该对象的所有其他 synchronized 代码都不能被其他线程调用,比如
public class Test {
public void func_a() {
synchronized (this) {
// 代码块 A
}
}
public void func_b() {
synchronized (this) {
// 代码块 B
}
}
public synchronized void func_c() {
}
public synchronized void func_d() {
}
}
当代码块 A 被某个线程调用时,其他线程不仅不能调用代码块 A,同时也不能调用代码块 B,也不能调用函数 func_c,同样的,如果函数 func_c 被某个线程调用,其他线程不仅不能调用函数 func_c,也不能调用代码块 A 或 B,也不能调用函数 func_d
实际上这四个函数会获取同一个对象锁,执行需要获取对象锁的代码就会受影响,如果执行的是不需要获取对象锁的代码就不影响
为了避免锁住整个对象,可以创建专门用于锁住小段代码的对象,比如
Object lock1 = new Object();
Object lock2 = new Object();
public void func_a() {
synchronized (lock1) {
// 代码块 A
}
}
public void func_b() {
synchronized (lock2) {
// 代码块 B
}
}
此外还可以锁住类,比如
public class SyncTest {
public void func_a() {
synchronized (this) {
}
}
public synchronized static void func_b() {
}
public void func_c() {
synchronized(SyncTest.class) {
}
}
}
这里 func_b 和 func_c 互斥,但 func_a 和 func_b、func_c 互不影响
也就是类锁和对象锁,是不一样的
Lock
synchronized 是 java 的内置关键字,使用方便,但有一些不足,比如
- 无法中断,如果线程 A 没释放锁(比如执行太久),线程 B 需要一直等待,不很适合大量代码
- 无法判断是否获取锁的状态
- 非公平,即锁释放后,等待的线程需要竞争
为了提供更多功能就引入了 Lock 和 ReadWriteLock 接口,优点有
- 可以中断,可以设置超时,能应付大量代码
- 可以判断锁的状态,可以在无法获取锁的时候阻塞或立刻返回
- 可以是公平锁,也可以是非公平锁
- 支持读写锁
Lock 的实现是 ReentrantLock,而 ReadWriteLock 的实现是 ReentrantReadWriteLock
这里的 Reentrant 是指可重入性,就是当前线程成功获取锁后,如果再获取锁,不会被 block
synchronized 同样是可重入锁
ReentrantLock 的基本使用
ReentrantLock lock = new ReentrantLock();
lock.lock();
// 其他操作
lock.unlock();
最好是在 try...catch 的 finally 里面做 unlock 防止没有释放
其他功能比如
new ReentrantLock(true); // 创建公平锁,就是等待锁是先到先得机制,默认是非公平,即释放锁后需要竞争
lock.isLocked() // 是否被当前线程锁住
lock.getHoldCount() // 当前线程每 lock 一次就 +1,每 unlock 一次就 -1
lock.getQueueLength() // 有多少个线程在等这个锁
lock.tryLock() // 尝试获取锁,成功返回 true,失败返回 false,不阻塞
lock.tryLock(10, TimeUnit.SECONDS) // 尝试获取锁,一定时间内无法获取才返回 false
lock.lockInterruptibly(); // 等待锁的时候可以被 Thread.interrupt 中断并抛异常,而 lock() 无法被中断
// 等待和唤醒机制
Condition c = lock.newCondition();
c.await(); // 线程 1 等待
c.signal(); // 线程 2 唤醒线程 1
ReentrantReadWriteLock 的基本应用
ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
ReadLock readLock = readWriteLock.readLock();
readLock.lock();
readLock.unlock();
WriteLock writeLock = readWriteLock.writeLock();
writeLock.lock();
writeLock.unlock();
读锁被获取后,其他线程还是可以获取读锁
读锁被获取后,其他线程不可以获取写锁
写锁被获取后,其他线程不可以获取写锁或读锁
悲观锁、乐观锁、锁的选择
synchronized 和 Lock 属于悲观锁,会真正锁住资源,比较耗性能,悲观锁适合于写多读少的场景
乐观锁实际上不加锁,如果在最后更新数据时发现冲突了再进行处理(依据不同算法,或者重试,或者抛出异常),由于不加锁,如果很少冲突的话,性能就大大提升,冲突频发就不行,乐观锁适合于写少读多的场景
比如前面提到的 AtomicInteger 用的就是乐观锁,采用 CAS (Compare And Swap) 方法
java.util.concurrent 包中的原子类就是通过 CAS 实现乐观锁
CAS 要用到三个值
- 内存位置(V)
- 预期原值(A)
- 新值(B)
初始化时获取内存位置 V
执行操作时,先获取 V 的当前值到 A,修改并形成新的值 B
然后调用 CPU 的一条原子性指令,实现功能:如果位置 V 的值是 A,将其替换为 B,否则返回位置 V 的当前值
如果这个原子性命令执行失败,也就是 V 的值发生了变化,意味着和其他线程冲突了,那就循环执行操作,直到成功
java 代码如下
AtomicInteger atomicInteger = new AtomicInteger();
int counter = atomicInteger.getAndIncrement();
AtomicInteger 的定义和初始化代码
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset; // value 的位置
private volatile int value; // 要操作的值
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
通过乐观锁实现原子性的自增
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2); // 获取当前 valueOffset 位置的值 (也就是 value 的值)
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); // 执行原子性 CAS,失败就重试
return var5;
}
// 这个函数实现原子性的 CAS 操作
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
这里 native 表示这个函数是 java 的 JNI (Java Native Interface) 实现,也就是说这个函数不是 java 实现的,是其他语言实现的,比如 C 语言,因为 java 本身无法操作 CPU 指令,而这个 CAS 需要指定特殊的 CPU 指令,所以要用 JNI
这样的 CAS 算法存在 ABA 问题,就是位置 V 的值原来是 A 然后变成 B 又再变回 A,这样误以为 V 的值没变,但实际上发生过变化,一种解决方法是在值前面添加版本号,每次更新都把版本号 +1,这样就变成 1A -2B-3A,避免判断失误
顺便说一下,数据库也有类似的乐观锁实现,比如通过执行
select version, value_column from [table] where key_column=[key]
-- 其他操作形成新的值
update [table] set version=[操作前的值+1], value_column=[new_value] where key_column=[key] and version=[操作前的值]
这样执行成功就是没有冲突,失败就重试或报错
一些其他概念
无锁:遇到冲突不退出不阻塞,而是循环尝试直到成功,比如 CAS
自旋锁:不阻塞而循环尝试也叫自旋,自旋锁有自旋次数的限定,防止一直循环浪费 CPU
偏向锁:多数情况下锁总是由同一线程多次获得,偏向锁设置线程 ID,下次该线程只需要做一次 CAS 操作判断线程 ID,偏向锁只有遇到竞争才释放
轻量级锁:锁的获取和释放比偏向锁操作多,通过自旋等待,不阻塞
重量级锁:需要阻塞
选择锁的考虑
- 需不需要锁住资源(乐观,悲观)
- 需不需要阻塞(乐观,悲观)
- 乐观锁性能(无锁、偏向锁、轻量锁)
- 竞争锁需不需要排队(公平锁,非公平锁)
- 同一个线程能不能获取同一把锁(可重入锁,不可重入锁)
- 多个线程能不能共享同一把锁 (共享锁、读锁,互斥锁、写锁)
synchronized 经过优化,能判断并从无锁 - 偏向锁 - 轻量级锁 - 重量级锁做升级,优化性能
ThreadLocal
如果希望同一个变量名,在不同的线程有不同的值,并且不会冲突,那么可以使用 ThreadLocal
ThreadLocal<String> localVar = new ThreadLocal<>();
localVar.set("value");
localVar.get();
localVar.remove();
实现
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
可以简单理解为一个两层的字典 (线程ID,(ThreadLocal 变量,值))
当然实际情况更复杂些
类的加载和初始化
有三个阶段
- 装载
- 连接
- 初始化
其中装载阶段有三个动作
- 通过类型的完全限定名,产生一个代表该类型的二进制数据流
- 解析这个二进制数据流为方法区内的内部数据结
- 构创建一个表示该类型的 java.lang.Class 类的实例
连接阶段又分为三部分
- 验证,确认类型符合 Java 语言的语义
- 准备,分配内存,设置默认初始值
- 解析(可选的) ,在类型的常量池中寻找类,接口,字段和方法的符号引用,把这些符号引用替换成直接引用的过程。
只有当类被主动使用时,才会做初始化,执行 static 变量,执行 static 代码块
- 当创建类的新实例时
- 当调用类的静态方法时
- 当使用类的静态字段时
- 当调用 Java API 中的某些反射方法时
- 当初始化某个子类时
- 当虚拟机启动某个被标明为启动类的类(即包含 main 方法的那个类)
Java编译器会收集所有的类变量初始化语句和类型的静态初始化器,将这些放到一个特殊的方法中:clinit
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· DeepSeek在M芯片Mac上本地化部署