线程安全问题产生的根本原因
多条线程
同时对一个共享资源
进行非原子性操作
时,会诱发线程安全问题
非原子性操作
是导致线程不安全
的因素,比如:i++,一共分三步
- 将 i 从内存加载到
CPU 寄存器
中 - 在
寄存器
中执行 +1 操作(然而,这时发生了线程切换,结果还未保存进内存,i 值又被其他线程
使用了,白加了🤦) - 把结果保存到
内存
中
public class Main {
public static void main(String[] args) throws InterruptedException {
TestSync testSync = new TestSync();
Thread[] threads = new Thread[2];
for (int i = 0; i < 2; i++) {
threads[i] = new Thread(() -> {
testSync.addSum();
});
threads[i].start();
}
threads[0].join();
threads[1].join();
System.out.println(testSync.sum);
}
}
public class TestSync {
public int sum;
void addSum() {
for (int i = 0; i < 10000; i++) {
sum++;
}
}
}
两个线程,同时进行 sum++
操作,从而造成线程问题
是否可以使用一种机制,当一个线程操作 sum
的时候,另一个线程阻塞,直到等到线程操作结束,才继续执行?
于是,引入了锁,可以将锁看成一种抽象:对共享资源的暂时性保护
在堆中存储的对象
被初始化的 Java 对象将会存在于堆上
该对象在堆中存储的信息可分为三个部分:对象头、实例数据、对其填充
-
对象头:固定大小的内存块,存储对象自身的
运行时数据
:标记字、哈希码、锁信息、垃圾回收信息因为对象头是存储一些额外信息,因此占用的空间很小,只有 32/64 bit
-
实例数据(Instance Data):除对象头以外的所有数据,包括成员变量的值、数组元素的值等。(存储属性、方法)
-
对齐填充(Padding):帮助对齐对象而填充的无用字节:Java 对象大小必须是 8bit 的倍数
Mark Word
锁的信息存放在对象头的 Mark Word
中;因此,你可以认为,每个对象都拥有一把锁
Mark Word 的信息如下
使用 2 bit 表示锁的状态,当锁标记为 01 的时候,外加 1 bit 用于判断是否为偏行锁
- 01 - 0 无锁
- 01 - 1 偏向锁
- 00 - 轻量级锁
- 10 - 重量级锁
- 11 - GC 标记
synchronized 的字节码
通过 synchronized 可以对资源进行加锁,因此,可通过使用 synchronized
保护因线程切换
而导致问题的地方
synchronized (this) {
sum++;
}
通过 javap -c ,查看编译后的字节码指令
javac .\TestSync.java
javap -c .\TestSync.class
Compiled from "TestSync.java"
public class cn.zhang.TestSync {
public int sum;
public cn.zhang.TestSync();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
void addSum();
Code:
0: iconst_0
1: istore_1
2: iload_1
3: sipush 10000
6: if_icmpge 39
9: aload_0
10: dup
11: astore_2
12: monitorenter // <----------------------
13: aload_0
14: dup
15: getfield #2 // Field sum:I
18: iconst_1
19: iadd
20: putfield #2 // Field sum:I
23: aload_2
24: monitorexit // <----------------------
25: goto 33
28: astore_3
29: aload_2
30: monitorexit // <----------------------
31: aload_3
32: athrow
33: iinc 1, 1
36: goto 2
39: return
Exception table:
from to target type
13 25 28 any
28 31 28 any
}
synchronized 被编译之后,实质上是 monitorenter 和 monitorexit 两条字节码指令(如上面的 12、24、30)
加锁方式
加锁的方式有两种,一种是加类锁,一种是加对象锁
类锁是全局锁:修饰静态方法或 synchronized 的锁对象是类
public static synchronized void m1() { }
// 锁对象为类
synchronized(Lock.class) { }
对象锁是实例锁:修饰普通方法或 synchronized 的锁对象是对象实例
public synchronized void m1() { }
// 等价于
synchronized(this) { }
// 锁对象为对象实例
synchronized(new Lock()) { }
一、偏向锁
偏向锁场景:没有多线程竞争
的情况下,访问 synchronized 修饰的代码块
不必使用基于操作系统的重量级 Mutex Lock
,以此提高性能
既然都没有多线程竞争了,为什么还要有锁的?
存在的意义:防止可能出现线程安全的问题,添一层保障(兜底)
当没有多线程竞争,通过 CAS
的方式来抢占访问资源
- 抢占成功,修改对象头中的
锁标记
- 偏向锁标记:1
- 锁标记:01
- 当前获取锁的
线程ID
<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
public class BiasedLockDemo {
public static void main(String[] args) {
BiasedLockDemo demo = new BiasedLockDemo();
System.out.println(ClassLayout.parseInstance(demo).toPrintable());
synchronized (demo) {
System.out.println("---------- after lock -----------");
System.out.println(ClassLayout.parseInstance(demo).toPrintable());
}
}
}
👇:对象头第一个字节:00000001 中的最后三位:001 表示无锁,10101000 中的 000 表示轻量级锁
org.BiasedLockDemo object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 无锁 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
---------- after lock -----------
org.BiasedLockDemo object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) a8 f0 90 03 (10101000 轻量级锁 11110000 10010000 00000011) (59830440)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
一开始,无锁
加锁之后,变为轻量级锁
原因:存在偏向锁延时开启时间
(JVM 启动的时候,有很多线程运行(存在线程竞争的场景),这时开启偏向锁意义不大)
解决:虚拟机设置 -XX:BiasedLockingStartupDelay=0,将延时启动时间设置为 0
- idea 中:Modify options -> Add VM options -> -XX:BiasedLockingStartupDelay=0
101:表示偏向锁
---------- after lock -----------
org.BiasedLockDemo object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 28 4b 03 (00000101 偏向锁 00101000 01001011 00000011) (55257093)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
第二种方式:sleep 4 s 以上
try { TimeUnit.SECONDS.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); }
BiasedLockDemo demo = new BiasedLockDemo();
不睡眠:001 -> 000:无锁 -> 轻量级锁
参为零:101 -> 101:偏向锁 -> 偏向锁
睡眠后:101 -> 101:偏向锁 -> 偏向锁
竟态条件
竞态条件(Race Condition):多个线程之间
对同一资源
进行读写操作时
可能发生的不确定性结果
多个线程同时对同一个共享资源进行读写操作,而且这些操作的顺序和时间
无法预测
在这种情况下,不同线程之间的操作顺序和时序可能会发生变化,导致最终的结果与预期不符
竟态条件例子如下
public class RaceConditionDemo {
private static int counter = 0;
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[2];
for (int i = 0; i < 2; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
counter++;
}
});
threads[i].start();
}
threads[0].join();
threads[1].join();
System.out.println("Counter: " + counter);
}
}
CAS 解决 竟态条件
CAS 方法,用于解决:在多线程环境下,对一个共享变量
进行修改或读取,可能引发竟态条件
的问题
基本思想:
- 比较
共享变量的当前值
与期望值
是否相等- 相等:将
共享变量的值
更新为新值
- 不相等:不做任何操作
- 相等:将
以下为使用 CAS 实现一个计数器
public class Counter {
private static AtomicInteger count = new AtomicInteger(0);
public static void increment() {
int expect, update;
do {
expect = count.get();
update = expect + 1;
} while (!count.compareAndSet(expect, update));
}
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[2];
for (int i = 0; i < 2; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
increment();
}
});
threads[i].start();
}
threads[0].join();
threads[1].join();
System.out.println("Counter: " + count.get());
}
}
increment() 可以解决 count++ 的原因
使用 count++
操作时,实际上会分为三步操作:
- 首先读取计数器的当前值
- 然后将其加 1
- 最后将计数器的新值写回内存
在多线程并发执行时
修改后,未写入内存,count 值又被其他线程读取,导致原有线程:白加了(操作结果,没有起作用,被覆盖了)
使用 CAS 算法可以解决这个问题的原因如下:
expect = count.get()
:通过get()
方法获取计数器的当前值,保存到本地变量expect
中。update = expect + 1
:将本地变量expect
加 1,得到新的计数器值update
。while (!count.compareAndSet(expect, update))
:使用compareAndSet()
方法比较计数器的当前值和预期值(即本地变量expect
)- 相等:将计数器的新值(即本地变量
update
)写回内存 - 否则,重新执行步骤 1 和 2,直至成功更新计数器的值
- 在这个过程中,由于只有一个线程能够成功更新计数器的值,因此可以保证线程安全性
- 相等:将计数器的新值(即本地变量
CAS 算法利用了原子性操作 compareAndSet() 的特性,保证对计数器的更新是原子性的
,从而避免了多个线程对同一个计数器进行并发修改的问题。
因此,使用 CAS 算法对计数器进行自增操作时,可以避免 count++ 所导致的竞态条件问题。
CAS 举例:AtomicInteger 源码分析
比较并交换,CAS 其基本思想在于不断比较当前值
与之前计算的预期值
是否相等
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
该方法存在于 unsafe 类下,参数含义
- 当前对象实例
- 实例变量在内存地址的偏移量
- 预期值
- 需变更的值
应用场景如下,初始值为 0 ,之后每次 +1并打印
AtomicInteger atomicInteger = new AtomicInteger();
System.out.println(atomicInteger.getAndIncrement());
System.out.println(atomicInteger.getAndIncrement());
System.out.println(atomicInteger.getAndIncrement());
public class AtomicInteger {
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset // <----- 获取 value 字段在 AtomicInteger 的偏移量
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1); // <----- 调用 Unasfe 类中的 getAndAddInt
}
}
public final class Unsafe {
public final int getAndAddInt(Object var1, long var2, int var4) { // 接收参数为:对象实例、偏移量、需添加的值
int var5;
do {
var5 = this.getIntVolatile(var1, var2); // <----- 当前对象实例 + 偏移量 = getIntVolatile => 当前 value 值
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); // <----- 比较(对象实例 + 偏移量)得到的 value 值与 之前的value(var5)相等
// 相等,进行var5 + var4 操作,并返回 true,退出当前循环
// 不相等,重试,直到成功
return var5;
}
}
二、轻量级锁
同一时刻,如果有多个线程,同时竞争锁资源
没有竞争到锁资源的线程,将会被阻塞,等待被唤醒(按照重量级锁的逻辑,但此时性能低)
有没有更好的方案?轻量级锁
对于没有抢占到锁的线程,进行一定次数的重试(自选)
若重试过程中,抢占到锁,则该线程就不需要阻塞了
线程不断自旋重试,会浪费 CPU 资源
因此,自旋重试,适合持有锁的线程,占有锁的时间
,较短的情况
存在锁竞争,但占用锁的时间较短,可以通过自旋的方式(一定数量的重试),使得不必对当前线程进行切换
当线程获取到某个对象锁时,如果锁标志位
为轻量级锁(00),线程会在自己的虚拟机栈中
(线程私有)开辟一块被称为 Lock Record
的空间,用于存放
- 对象头中 Mark Word 的副本
- owner 指针
Lock_Record
有个 markOop_displaced_header
的属性,用于指向一个无锁状态的 Mark Word
通过 CAS 将锁对象的 Mark Word
替换为指向 Lock_Record
的指针,指向 Lock Record,并将 Mark Word 复制到无锁状态的 Mark Word(释放相反),同时将 owner 指向锁对象,从而实现线程和对象锁的绑定
轻量级锁的释放
释放:通过 CAS,将 Lock Record 中 _displaced_header 中的 Mark Word 替换到 Lock 锁对象的 Mark Word
CAS 失败,锁膨胀,升级为重量级锁
CAS 失败的原因:两个线程相互争夺同一个轻量级锁,未抢到锁的线程会将锁对象的 Mark Word
设置为 inflating(膨胀)
三、重量级锁
没有获取到锁的线程,会通过 park
进行阻塞
之后,会被获取锁的线程
唤醒后,再次抢占锁,直到成功
在 Java 中,重量级锁使用 Mutex Lock 来实现
使用 Mutex Lock,需要将当前线程挂起,并从 用户态
切换到 内核态
,而切换带来的性能开销
是非常大的
Mutex Lock
Mutex Lock 是一种互斥锁(Mutual Exclusion Lock)
,用于保护共享资源
在不同线程之间
的互斥访问
具体来说,Mutex Lock 是一种二元信号量
,它有两个状态:
- 已锁定
- 未锁定
当一个线程尝试获取一个已经被锁定的 Mutex Lock 时,该线程将会被阻塞,直到 Mutex Lock 被解锁为止
只有一个线程
可以获得 Mutex Lock 的锁定状态
,其他线程必须等待该锁被释放后才能继续执行
在 Java 中,Mutex Lock 可以通过
synchronized 关键字
或者Lock 接口
来实现。
- synchronized 关键字是 JVM 提供的内置锁机制,它可以自动获取和释放锁,并且保证了操作的原子性和可见性
- 而 Lock 接口是 JDK 提供的显式锁机制,它提供了更灵活、更高级的锁操作,例如支持公平性和非阻塞获取等特性
由于 Mutex Lock 是一种重量级锁,因此在并发量较高、竞争激烈的场景下,使用 Mutex Lock 可能会导致性能下降
和死锁
等问题
synchronized 如何保证线程安全?
当需要执行同步方法,或同步代码块的时候,尝试获取对象的内部锁
- 成功:可以继续执行
- 失败:等待其他线程释放锁后再次尝试获取
在使用 synchronized 时要注意以下几点:
- 加锁,需相同对象
- 无法保证执行的顺序
- 会降低执行效率(因为每次进入同步方法或代码块,都需要获取锁),竞争激烈,可能会导致线程饥饿的情况
线程饥饿:一个或多个线程无法获得所需的资源以执行其工作,导致线程一直处于阻塞状态,而其他线程占用这些资源并持续执行的情况
synchronized 的使用条件
- 多个线程竞争同一个资源(如果竞争不同资源,不存在竞争关系,则不需要加锁)
- 需要有个标记,标识当前资源的状态(空闲,还是被其他线程占用)
synchronized 底层原理
相比于普通方法,使用 synchronized 修饰的方法会多出 ACC_SYNCHRONIZED
标识符
通过 ACC_SYNCHRONIZED
,会使得当前线程获取到 Monitor 对象
,而其他线程无法获取到 Monitor 对象,从而保证同一时刻只有一个线程进入 synchronized 修饰的方法中
Monitor 对象
任何对象在 JVM 中都会关联一个 Monitor 对象
,对象中 Monitor 对象
被其他对象持有后,将处于🚫锁定状态
Synchronized 在 JVM 底层本质上是基于进入和退出 Monitor 对象来实现同步的
ObjectMonitor
在 HotSpot JVM 中,Monitor 是由 ObjectMonitor 实现的,其数据结构如下
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL; //持有当前 objectMonitor 的线程(通常每个线程对应一个 objectMonitor)
_WaitSet = NULL; //进入到 wait 状态的线程队列
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //进入等待 monitor 的线程队列
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
当一个对象获取到 Monitor 对象
、会在对象头中的 Mark Word
存储指向 Monitor 对象的指针
加锁流程:进入 _EntryLIst,获取到 Monitor 对象
后,该线程会进入 _Owner,并且将 Monitor 对象中的_owner 设置为当前线程,_count +1
锁的释放:该线程调用 wait(),将 Monitor 中的_owner 设置为 null、_count - 1
调用 wait(),可以让处于 _Owner 的线程进入 _WaitSet 中等待,这时其他处于 _EntryList 的线程可尝试竞争锁;假设其他一个线程成功进入 _Owner,并且成功完成任务,那么它可以执行 notify() 去唤醒 _WaitSet 中的线程
ObjectWaiter
//双向链表结构
class ObjectWaiter : public StackObj {
public:
enum TStates { TS_UNDEF, TS_READY, TS_RUN, TS_WAIT, TS_ENTER, TS_CXQ } ;
enum Sorted { PREPEND, APPEND, SORTED } ;
ObjectWaiter * volatile _next; //前指针
ObjectWaiter * volatile _prev; //后指针
Thread* _thread; //当前线程
jlong _notifier_tid;
ParkEvent * _event;
volatile int _notified ;
volatile TStates TState ;
Sorted _Sorted ; // List placement disposition
bool _active ; // Contention monitoring is enabled
public:
ObjectWaiter(Thread* thread);
void wait_reenter_begin(ObjectMonitor *mon);
void wait_reenter_end(ObjectMonitor *mon);
};
死锁
两线程,一个持有 A,需要 B;另一个持有 B,需要 A,导致互相等待
public class DeadLock {
public static void main(String[] args) {
DeadLock a = new DeadLock();
DeadLock b = new DeadLock();
new Thread(() -> {
addLock1AndSleep(a, b, "t1");
}).start();
new Thread(() -> {
addLock1AndSleep(b, a, "t2");
}).start();
}
private static void addLock1AndSleep(DeadLock lock1, DeadLock lock2, String t) {
synchronized (lock1) {
sleep();
addLock2(lock2, t);
}
}
private static void addLock2(DeadLock lock2, String t) {
synchronized (lock2) {
for (int i = 0; i < 100; i++) {
System.out.println("线程" + t + " ......" + i);
}
}
}
private static void sleep() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
解决
-
一次性申请所有资源
public class DeadLock { public static void main(String[] args) { DeadLock a = new DeadLock(); DeadLock b = new DeadLock(); new Thread(() -> { addLock1AndSleep(a, b, "t1"); }).start(); new Thread(() -> { addLock1AndSleep(b, a, "t2"); }).start(); } static LockManager lockManager = new LockManager(); private static void addLock1AndSleep(DeadLock lock1, DeadLock lock2, String t) { if (lockManager.addLock(lock1, lock2)) { synchronized (lock1) { sleep(); addLock2(lock2, t); } lockManager.freeLock(lock1, lock2); // <----- 完成任务后,释放资源 } else { sleep(); addLock1AndSleep(lock1, lock2, t); // <----- 加锁失败后,sleep(100)后,重试 } } private static void addLock2(DeadLock lock2, String t) { synchronized (lock2) { for (int i = 0; i < 100; i++) { System.out.println("线程" + t + " ......" + i); } } } private static void sleep() { try { Thread.sleep(100); } catch (InterruptedException e) { throw new RuntimeException(e); } } } class LockManager { List<Object> list = new ArrayList<>(); // <----- 内部维护一个 list synchronized boolean addLock(DeadLock lock1, DeadLock lock2) { if (list.contains(lock1) || list.contains(lock2)) { // <----- list 中存在锁,返回 flase,表示加锁失败 return false; } list.add(lock1); list.add(lock2); return true; } synchronized void freeLock(DeadLock lock1, DeadLock lock2) { list.remove(lock1); list.remove(lock2); } }
-
使用 ReentrantLock 中的 tryLock(),如果资源抢占成功,返回 true,否则返回 false
static ReentrantLock reentrantLock = new ReentrantLock(); private static void addLock1AndSleep(DeadLock lock1, DeadLock lock2, String t) { if (reentrantLock.tryLock()) { synchronized (lock1) { sleep(); addLock2(lock2, t); } reentrantLock.unlock(); } else { sleep(); addLock1AndSleep(lock1, lock2, t); } }
-
顺序添加,比如根据 hashcode,大的加大锁,小的加小锁
private static void addLock1AndSleep(DeadLock lock1, DeadLock lock2, String t) { DeadLock lock = lock1.hashCode() > lock2.hashCode() ? lock1 : lock2; synchronized (lock) { sleep(); addLock2(lock, t); } }
参考
- B 站视频 - 【Java并发】月薪30K必须知道的Java锁机制
- 彻底理解Java并发编程之Synchronized关键字实现原理剖析
- Java 并发编程深度解析与实战(Mic)
- 深入理解高并发编程:核心原理与案例实战
- 掘金小册 - Java 并发编程- 第 8 章