JUC系列:(三)同步

布鸽不鸽 Lv4

临界区

临界资源:一次仅允许一个进程使用的资源

临界区:访问临界资源的代码块

竞态条件:多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测

避免临界区竞态条件发生:

  • 阻塞式:synchronized,lock
  • 非阻塞式:原子变量

synchronized

使用锁完成同步

synchronized是可重入、不公平的重量级锁

同步代码块:

  • 锁对象建议使用共享资源
  • 实例方法使用:this
  • 静态方法使用:类名.class
1
2
3
synchronized(锁对象){
// 访问共享资源的核心代码
}

同步方法:

  • synchronized修饰的方法的不具备继承性,所以子类是线程不安全的

    • 如果子类的方法也被synchronized修饰,两个锁对象其实是一把锁,而且是子类对象作为锁
  • 实例方法:默认用,this作为的锁对象

  • 静态方法:默认用,类名 .class作为的锁对象

1
2
3
4
5
6
7
8
// 同步方法
修饰符 synchronized 返回值类型 方法名(方法参数) {
方法体;
}
// 同步静态方法
修饰符 static synchronized 返回值类型 方法名(方法参数) {
方法体;
}

锁原理

  • 每个 Java 对象都可以关联一个Monitor对象(Monitor也是class,实例存储在堆中)
  • 如果使用synchronized给对象上锁,对象头的Mark World指向Monitor对象(重量级锁)
  • Mark Word结构如下(最后两位是锁标志位)
image-20231204193333591 image-20231204193342067

工作流程:

  1. 开始时,Monitor中,Owner为空
  2. 当Thread-1执行synchronized时
    • 将Monitor的Owner设为Thread-2
    • obj对象的Mark Word指向Monitor
    • obj对象原有的Mark Word存入线程栈中的锁记录中(轻量级锁部分有讲)
  3. 在Thread-1上锁的过程中,后续执行synchronized的线程,会进入EntryList中(BLOCKED状态)
  4. 在Thread-1执行完同步代码后,根据obj对象头找到Monitor
    • 将Monitor的Owner设为空
    • obj对象的Mark Word还原
  5. 唤醒EntryList中等待的线程来竞争
  6. WaitSet中的Thread,是之前获得过锁,但条件不满足进入WAITING状态的线程(wait-notify机制)
image-20231204200257780

字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SyncTest {

public void syncBlock(){
// 代码块
synchronized (this){
System.out.println("hello block");
}
}

// 方法上加synchronized
public synchronized void syncMethod(){
System.out.println("hello method");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 # syncBlock()方法
0 aload_0
1 dup
2 astore_1
3 monitorenter # monitorenter指令进入同步块
4 getstatic #7 <java/lang/System.out : Ljava/io/PrintStream;>
7 ldc #23 <hello block>
9 invokevirtual #17 <java/io/PrintStream.println : (Ljava/lang/String;)V>
12 aload_1
13 monitorexit # monitorexit指令退出同步块(正常退出)
14 goto 22 (+8)
17 astore_2
18 aload_1
19 monitorexit # monitorexit指令退出同步块(异常也能退出;异常表中有写,出现异常会跳到17)
20 aload_2
21 athrow
22 return
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# syncMethod()函数
public synchronized void c();
descriptor: ()V
flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED # 标记了ACC_SYNCHRONIZED,底层还是monitor实现的
Code:
stack=2, locals=1, args_size=1
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #25 // String hello method
5: invokevirtual #17 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 29: 0
line 30: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 this Lorg/example/Test;

锁升级

synchronized是可重入、不公平的重量级锁,可以进行优化

1
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁	// 随着竞争增加,只能锁升级,不能降级

偏向锁

意义:偏向于第一个获取锁的线程,之后重新获取该锁不再需要同步操作

  • 锁对象第一次被获取时,obj对象的Mark Word末尾标记为101;使用CAS操作,将线程ID记录到Mark Word中
  • 以后这个线程进入同步块,如果线程ID还是自己的,证明没有竞争,就不再需要任何同步操作
  • 一旦另一个线程尝试获取锁对象,偏向状态就宣告结束,Mark Word恢复到未锁定或者轻量级锁状态
image-20231204193342067

对象创建时:

  • 默认开启偏向锁,对象创建后,Mark Word后三位为101,thread,epoch,age都为0

  • JDK8默认延迟4s开启偏向锁(避免刚开始,好多线程都来抢锁)

    • -XX:BiasedLockingStartupDelay=0 设置延迟时间
    • -XX:-UseBiasedLocking 禁用偏向锁
  • 如果一个对象计算过hashCode,就再也无法进入偏向状态了

撤销偏向锁的状态:

  • 调用对象的hashCode方法(偏向锁对象的Mark Word存储的是线程ID,调用hashCode将导致偏向锁被撤销)
  • 其他线程尝试获取锁,偏向锁升级为轻量锁
  • 偏向锁状态执行obj.notify()会升级为轻量级锁
  • 调用obj.wait()会升级为重量级锁

批量重偏向:

  • 为了解决:一个线程创建大量对象并执行同步操作,另一个线程将这些对象作为锁对象进行操作,导致大量的偏向锁撤销操作

  • 具体方法:

    • 以class为单位,维护一个偏向锁撤销计数器,每一次该class的对象发生偏向锁撤销时,该计数器+1
    • 当计数器值达到阈值(默认20),JVM认为该class偏向锁有问题,进行批量重偏向

批量撤销:

  • 应用场景:在明显多线程竞争剧烈的场景下,使用偏向锁是不合适的
  • 具体方法:
    • 当计数器值达到阈值(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向
    • 之后对于该class的锁,直接走轻量级锁的逻辑

轻量级锁

意义:一个对象有多个线程要加锁,但加锁的时间是错开的(没有竞争),可以使用轻量级锁来优化

进入同步代码块时:

  • 如果锁对象为无锁状态,JVM在当前线程的栈中创建一个锁记录(Lock Record),存储锁对象的Mark Word拷贝
image-20231204211443069

加锁时:

  • 当前线程使用CAS操作,把当前线程对象Mark Word的锁标志为“00”
  • 如果CAS成功,Mark Word中的指针指向栈中的锁对象,锁对象中的指针指向Mark Word
image-20231204211605472
  • 如果CAS失败,有两种情况
    • 其他线程已经持有该轻量级锁,表明有竞争,进入锁膨胀过程
    • 线程自己执行了锁重入,就加一条Lock Record作为重入的计数
image-20231204211641711

解锁时:

  • 如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减1
  • 如果锁记录的值不为null,使用 CAS 将 Mark Word 的值恢复给对象头
    • 成功,则解锁成功
    • 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

重量级锁

在尝试加轻量级锁的过程中,CAS 操作无法成功。可能是其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁

  • 当Thread-1进行轻量级加锁时,Thread-0已经对该对象加了轻量级锁了(有竞争)
image-20231204212805898
  • Thread-1加锁失败,进入锁膨胀流程
  • 为锁对象申请Monitor锁
  • 通过对象头获取到持锁线程,将Monitor的Owner置为Thread-0,将对象头指向重量级锁地址
  • 自己进入Monitor的EntryList(BLOCKED状态)
image-20231204213052498
  • 当Thread-0退出同步块解锁时,使用CAS将Mark Word的值恢复给对象头失败
    • 这时进入重量级解锁流程,即按照Monitor地址找到Monitor对象,设置Owner为 null,唤醒EntryList中BLOCKED线程

锁优化

自旋锁

重量级锁竞争时,尝试获取锁的线程不会立即阻塞,可以使用自旋(默认10次)来进行优化

注意事项:

  • 单核CPU自旋就是浪费时间,多核才有意义

  • 自旋失败后进入阻塞状态

  • JDK7之后不能控制是否开启自旋功能,由 JVM控制

手动模拟自旋锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class SpinLock {
AtomicReference<Thread> atomicReference = new AtomicReference<>();

public void lock() {
while (!atomicReference.compareAndSet(null, thread)) {
Thread.sleep(1000);
}
}

public void unlock() {
atomicReference.compareAndSet(thread, null);
}
}

锁消除

JVM的JIT编译器的优化。通过逃逸分析来支持。如果堆上的共享数据不可能逃逸出去被其它线程访问到,可以将它们的锁进行消除

锁粗化

如果虚拟机探测到一串的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部

  • 看似没有锁,其实有很多锁
1
2
3
public static String concatString(String s1, String s2, String s3) {
return s1 + s2 + s3;
}
  • StringBuffer的append方法是synchronized修饰的
  • 扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,只需要加锁一次就可以
1
2
3
4
5
6
7
public static String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}

多把锁

将锁的粒度细化:

  • 好处,可以增强并发度
  • 坏处,容易发生死锁

活跃性

死锁

四个必要条件:

  1. 互斥
  2. 不可剥夺
  3. 请求与保持
  4. 循环等待

打破任意一个都可以破坏死锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
new Thread(() -> {
synchronized(resources1){
Thread.sleep(2000);
synchronized (resources2){
System.out.println("Thread-1 do sth");
}
}).start();

new Thread(() -> {
synchronized(resources2){
Thread.sleep(2000);
synchronized (resources1){
System.out.println("Thread-2 do sth");
}
}}
}).start();

定位死锁的方法:

  • 使用jps定位进程ID,再用 jstack 进程ID 定位死锁,找到死锁的线程去查看源码,解决优化
1
2
3
4
5
6
7
8
9
10

C:\Users\xdy>jps
67392 Jps
24836
43272
63688 RemoteMavenServer36
69448 Main2
54684 Launcher

C:\Users\xdy>jstack 69448
  • Linux下,可以通过top定位到CPU占用高的Java进程。再利用top -Hp 进程ID定位是哪个线程。最后用jstack 进程ID查看这个线程的线程栈

避免死锁:

  • 注意加锁的顺序

活锁

两个线程并没有被阻塞,但是互相改变,最后谁也没法结束

  • 比如一个线程++,另一个–

饥饿

一个线程优先级太低,一直得不到CPU调度执行

wait-notify

基本使用

需要获取对象锁后才可以调用 obj.wait(),notify随机唤醒一个线程,notifyAll唤醒所有线程去竞争CPU

1
2
3
4
5
// Object类API
public final void notify() // 唤醒正在等待对象监视器的单个线程。
public final void notifyAll() // 唤醒正在等待对象监视器的所有线程。
public final void wait() // 导致当前线程等待,直到另一个线程调用该对象的 notify() 方法或 notifyAll()方法。
public final native void wait(long timeout) // 有时限的等待, 到n毫秒后结束等待,或是被唤醒

对比Thread.sleep:

  • Thread.sleep()用来控制自己,暂停一段时间,将执行机会让给别的线程;obj.wait()用于线程间通信
  • Thread.sleep()不会释放锁;obj.wait()会放弃对象锁
  • Thread.sleep()可以在任何地方用;obj.wait()必须在同步代码中用(先获取锁)

底层原理

image-20231205154745911
  1. 调用wait方法,线程即可进入WaitSet变为WAITING状态

  2. BLOCKED和WAITING的线程都处于阻塞状态,不占用CPU时间

    • BLOCKED线程会在Owner线程释放锁时唤醒

    • WAITING线程会在Owner线程调用notify或notifyAll时唤醒,进入EntryList重新竞争

代码优化

问题:虚假唤醒,notify只能随机唤醒WaitSet中的一个线程,可能无法唤醒正确的线程

解决方法:采用notifyAll,使用while + wait保证能执行

1
2
3
4
5
6
7
8
9
10
synchronized (room) {
while (!condition) {
try {
room.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// do sth
}

park-unpark

基本使用

LockSupport类方法:

  • LockSupport.park():挂起原语,暂停当前线程
  • LockSupport.unpark(暂停的线程对象):恢复某个线程的运行
1
2
3
4
5
6
7
8
9
10
11
12
13
// 先park再unpark;先unpark再park,最终结果是一样的,都能恢复线程的运行

Thread t1 = new Thread(() -> {
Thread.sleep(2000);
System.out.println("park...");
LockSupport.park();
System.out.println("resume...");
},"t1");

t1.start();
Thread.sleep(1000);

LockSupport.unpark(t1);

出现原因

LockSupport出现就是为了增强wait-notify的功能:

  • wait-notify必须配合Object Monitor一起使用,而 park-unpark 不需要
  • park-unpark以线程为单位来阻塞和唤醒线程,而notify只能随机唤醒一个等待线程,notifyAll是唤醒所有等待线程
  • park-unpark可以先unpark,wait-notify不能先notify
  • wait会释放锁资源,park不会释放锁资源,只会阻塞当前线程释放CPU

底层原理

类似生产者消费者

如果先park:

  1. 当前线程调用Unsafe.park()方法

    • 检查_counter(当前为0),此时获得_mutex互斥锁

    • 线程进入_cond条件变量,挂起

  2. 其他地方调用Unsafe.unpark(thread0)方法

    • 设置_counter为1

    • 唤醒_cond条件变量中的Thread-0,Thread-0恢复运行

    • 设置_counter为0

image-20231205201445173

如果先unpark:

  1. 其他地方调用Unsafe.unpark(thread0)方法
    • 设置_counter为1
  2. 当前线程调用Unsafe.park()方法
    • 检查_counter(当前为1),无需挂起,继续运行
    • 设置_counter为0
image-20231205201454512

安全分析

  • 线程安全:多个线程调用同一个实例的方法时,是线程安全的

  • 即使每个方法是原子的,但多个方法的组合不是原子的,只能保证调用的方法内部安全:

1
2
3
4
5
6
Hashtable table = new Hashtable();

// 线程1,线程2
if (table.get("key") == null) {
table.put("key", value);
}
  • 常见线程安全类:StringIntegerStringBufferRandomVectorHashtablejava.util.concurrent
    • StringInteger 等都是不可变类,内部的状态不可以改变,所以方法是线程安全
    • replace等方法底层是新建一个对象,复制过去

同步模式

保护性暂停

单任务版:

Guarded Suspension,用在一个线程等待另一个线程的执行结果

  • 有一个结果需要从一个线程传递到另一个线程,让它们关联同一个GuardedObject
  • join的实现、Future的实现,采用的就是此模式
  • 注意,由于虚假唤醒,我们需要使用while将wait包裹起来
image-20231205210005209
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
class GuardedObject {
private Object response;
private final Object lock = new Object();

public Object get(long millis) {
synchronized (lock) {
long begin = System.currentTimeMillis();
long timePassed = 0;
while (response == null) {
long waitTime = millis - timePassed;
if (waitTime <= 0) {
break;
}
try {
lock.wait(waitTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
timePassed = System.currentTimeMillis() - begin;
}
return response;
}
}

public void complete(Object response) {
synchronized (lock) {
this.response = response;
lock.notifyAll();
}
}
}
1
2
3
4
5
6
7
8
9
GuardedObject guard = new GuardedObject();

new Thread(() -> {
Thread.sleep(1000);
guard.complete("123");

}).start();

Object object = guard.get(2000); // 阻塞获取object的值,最多等待2000毫秒

多任务版:

  • 其实就是给每个guard一个id,并且将所有gurad保存在一个Map中
image-20231206165809904
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
class Mailboxes {
private static Map<Integer, GuardedObject> boxes = new Hashtable<>();
private static int id = 1;

//产生唯一的id
private static synchronized int generateId() {
return id++;
}

public static GuardedObject getGuardedObject(int id) {
return boxes.remove(id);
}

public static GuardedObject createGuardedObject() {
GuardedObject go = new GuardedObject(generateId());
boxes.put(go.getId(), go);
return go;
}

public static Set<Integer> getIds() {
return boxes.keySet();
}
}

class GuardedObject {
// getter,setter,构造函数
private int id;

// 其他代码和之前一样
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
for (int i = 0; i < 10; i++) {
new Thread(() -> {
GuardedObject guardedObject = Mailboxes.createGuardedObject();
Object object = guardedObject.get(10000);
System.out.println("object = " + object);
}).start();
}

Thread.sleep(1000);


for (Integer id : Mailboxes.getIds()) {
final int finalId = id;
new Thread(() -> {
GuardedObject guard = Mailboxes.getGuardedObject(finalId);
Thread.sleep(1000);
guard.complete("hello" + finalId);
}).start();
}

顺序输出

  • 顺序输出2,1,2,1(也有可能多个2连在一起)

  • t2给t1发放许可(多次连续调用unpark只会发放一个许可)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Thread t1 = new Thread(() -> {
while (true) {
LockSupport.park();
System.out.println("1");
}
});

Thread t2 = new Thread(() -> {
while (true) {
System.out.println("2");
LockSupport.unpark(t1);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});

t1.start();
t2.start();

交替输出

  • 暂时略过

异步模式

  • 暂时略过
  • 标题: JUC系列:(三)同步
  • 作者: 布鸽不鸽
  • 创建于 : 2024-01-10 14:54:58
  • 更新于 : 2024-01-10 14:56:08
  • 链接: https://xuedongyun.cn//post/4016/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论