JUC-03-共享模型之管程

1. 共享带来的问题

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
@Slf4j
public class CodeTest {
static int count=0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 999999; i++) {
count++;

}
}, "t1");

Thread t2 = new Thread(() -> {
for (int i = 0; i < 999999; i++) {
count--;
}
}, "t2");

t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}

}
  • 共享资源在被两个线程使用的时候,可能会遇见线程1要对变量i=0进行+1操作,但是在进行+1的途中cpu时间片到期,cpu切换交给了线程2,线程2也对变量i进行-1操作。然后把结果-1返回到共享变量。但是切换回线程1中的变量已经+1也就是局部变量已经是1,并且重新赋值给共享变量导致的并发问题
  • 第二个是共享资源如果一直被一个线程使用,线程可能会由于需要sleep,wait,io等操作浪费cpu的使用时间,那么可以把这段时间让给别人。

1.1 分析

image-20211012152027370

1.2 临界区

  • 实际上就是多个线程访问的代码里面有共享资源,那么这段代码就是临界区

1.3 竞态条件

  • 如果在临界区中多线程执行发生执行指令序列不同导致结果无法预测的状况就是竞态条件

2. sychronized解决方案

对临界区上锁,加上sychronized,这样保证,每次只会有一个共享资源进行操作

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
static int count=0;
static Object lock=new Object();
public static void main(String[] args) throws InterruptedException {
Room room = new Room();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (lock){
count++;
}

}
}, "t1");

Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (lock){
count--;
}

}
}, "t2");

t1.start();
t2.start();
t1.join();
t2.join();
log.debug("{}",count);
}

3. 方法上的synchronized

3.1 加在成员方法上

1
2
3
4
5
6
7
8
9
10
11
12
public class Demo {
//在方法上加上synchronized关键字
public synchronized void test() {

}
//等价于
public void test() {
synchronized(this) {

}
}
}

3.2 加在静态方法上

1
2
3
4
5
6
7
8
9
10
11
12
public class Demo {
//在静态方法上加上synchronized关键字
public synchronized static void test() {

}
//等价于
public void test() {
synchronized(Demo.class) {

}
}
}

经典的线程八锁问题

4. 变量的线程安全分析

4.1 静态变量和成员变量是否有线程安全问题

如果只是对静态变量和成员变量进行读操作,是没有现成安全问题的。

但是只要有写操作,就需要看临界区,大概率会出现线程安全问题。

4.2 局部变量

如果是引用类型的话那么就有。

局部变量的值存储在线程的栈帧里面,也就是私有的。而不是像static变量那样先从方法区中取出这个变量然后再进行对应的修改。

image-20211012161844837

即:每个线程会创建自己的栈帧,在每个栈帧里面,都有一份局部变量。

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public class TestThreadSafe {

static final int THREAD_NUMBER = 2;
static final int LOOP_NUMBER = 200;
public static void main(String[] args) {
// ThreadSafeSubClass test = new ThreadSafeSubClass();
ThreadUnsafe test=new ThreadUnsafe();
for (int i = 0; i < THREAD_NUMBER; i++) {
new Thread(() -> {
test.method1(LOOP_NUMBER);
}, "Thread" + (i+1)).start();
}
}
}

// 由于list是在该类创建的,每个线程都是对这个list进行共享,因此会出现线程安全问题
class ThreadUnsafe {
ArrayList<String> list = new ArrayList<>();
public void method1(int loopNumber) {
for (int i = 0; i < loopNumber; i++) {
method2();
method3();
}
}

private void method2() {
list.add("1");
}

private void method3() {
list.remove(0);
}
}

// list是在调用方法method1时候创建的,属于方法中的局部变量,那么每个线程进入method1的时候都会创建一个list,因此不会出现线程安全问题
class ThreadSafe {
public final void method1(int loopNumber) {
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < loopNumber; i++) {
method2(list);
method3(list);
}
}

public void method2(ArrayList<String> list) {
list.add("1");
}

private void method3(ArrayList<String> list) {
System.out.println(1);
list.remove(0);
}
}

// 由于子类采用继承的方式重写了method3,此时list会被多个线程操作,会出现线程安全问题。解决办法就是给方法加上final防止子类重写。
class ThreadSafeSubClass extends ThreadSafe{
// @Override
public void method3(ArrayList<String> list) {
System.out.println(2);
new Thread(() -> {
list.remove(0);
}).start();
}
}

分析

  1. 对于第一种使用ThreadUnsafe类的方式,图解:

    image-20240425215800329

    此时所有的线程的list应用都指向了堆区中的那唯一一个list对象,因此list对象就是一个共享资源。

  2. 对于第二种使用ThreadSafe类的方式,图解:

    image-20240425215936544

    每个线程运行method1方法都是new list,这样list就是局部变量,没有共享。同时使用传参的方式,那么在同一个线程中的method2和method3使用的都是同一个list对象

  3. 对于使用ThreadSafeSubClass,此时虽然List是局部变量,但是由于重写了method3,在method3中新创建了一个线程去操作这个list,原本这个List由最外层的线程创建,那么此时list就是一个共享资源,会引发线程安全问题

4.3 线程安全类

  • Integer
  • HashTable
  • String
  • Random
  • Vector
  • JUC下的类

它们单个方法是线程安全的,但是如果多个方法执行的时候就不一样了。

同时不可变类线程安全(String、Integer),String和Integer都是不可变的,String本质上就是一个char[]数组。如果是substring方法实际上就是复制一个新的数组出来,然后再给String的char数组进行赋值。replace也实际上只是创建数组,然后对比原数组的旧值,如果是旧值那么直接给新的数组的那个位置赋值新值

5. Monitor

5.1 Java对象头

包括了markword主要就是存储hashcode,age(gc生命值),biase_lock是不是偏向锁,01加锁的情况

还有就是klassword只要就是指向类对象(类的信息)。

如果是数组那么就还包括了数组的长度。

image-20240427092856254

image-20240427092918408

5.2 Monitor锁

monitor锁是由操作系统提供的。

5.2.1 原理

Mark Word占32bit,实际上就是把obj的markword前面30bit记录monitor的地址,指向monitor。

如果有线程要执行临时区的时候那么就把monitor的owner指向对应的线程。

如果又有线程进来,那么会看看obj是否关联锁,然后再看看锁是否有owner,如果有那么就进入到EntryList阻塞等待。等待线程释放锁之后,唤醒entryList然后重新开始竞争。

image-20211012190808458

5.2.2 字节码角度

就是先把lock的引用复制放到slot1,然后就是monitorenter,把lock的markword指向monitor,并且把hashcode等存入monitor。接着执行业务代码,最后就是取出引用slot1,然后就是monitorexit,解锁。而且对业务代码也就是同步块进行了异常监视,如果出现异常,那么还是会进行解锁操作的。

image-20211012191838416

6. synchronized优化

偏向锁是单个线程专属的,如果单个线程处理某个代码没有竞争,那么就可以使用偏向锁,如果有竞争那么就可以升级为轻量级锁。

6.1 轻量级锁

本质就是线程的调用临时区方法的栈帧的锁记录保存对象的引用和对象markword的信息。

接着就是把对应锁记录的锁信息与obj进行交换,比如说把01改成了00告诉obj这是一个轻量级锁,而且告诉了obj锁记录的地址,相当于就是给obj贴上是谁的锁的标签。如果是可重入锁,那么锁记录markword部分就是null表示的是这是可重入的,用的是同一个锁。

image-20211012194226364

6.2 锁膨胀

  • 其实就是竞争轻量级锁的时候,没有地方给竞争的线程放着,那么这个时候就需要把轻量锁转换成重量级锁monitor,其实就是把obj的markword指向monitor。然后就是monitor的owner指向当前线程的锁记录。把阻塞线程放到等待队列里面。
  • 恢复的时候,CAS尝试把线程的锁记录给恢复过去,但是发现失败。这个时候恢复方式改成了重量级锁的恢复方式,唤醒list,然后owner设置为null,线程重新竞争monitor。如果没有就把monitor保存的hashcode信息恢复。

image-20211012195850961

6.3 自旋优化

  • 自旋就是旋多一会等等别人释放重量级锁,如果成功一次,那么下次就会确定成功几率加大,自旋多几次。如果没有等到那么就阻塞。
  • 自旋的原因:阻塞会导致线程的上下文切换需要消耗cpu时间和资源。速度相对比较慢。

6.4 偏向锁

之所以要用偏向锁是因为轻量级锁的锁重入每次都调用CAS进行对比,CAS是一个OS指令操作,速度很慢。所以偏向锁是把ThreadId直接赋值给markword,那么下次能直接在java上对比这个markword。

  • 偏向锁带有延迟性,通常对象创建过一会才会生成
  • 生成偏向锁->轻量级锁->重量级锁
  • 如果给临时区使用偏向锁,那么对应执行线程的id赋值给markword
  • 如果使用了锁的hashcode,那么偏向锁就会被禁止,因为hashcode占用的bit太多。
  • 轻量级在锁记录上记录hashcode,重量级在monitor上记录
  • 如果两个线程用同一个偏向级锁,那么锁会变成不可偏向,升级为轻量级锁。

6.4.1 批量重偏向

其实就是多个没有竞争的线程,使用同一个锁,如果jvm发现撤销的偏向次数超过20次,那么就会自动偏向另外一个线程。比如t1线程使用一堆锁,锁偏向t1。但是如果t2使用这些锁,并且需要撤销锁的偏向超过20次,那么这些锁会全部偏向t2

6.4.2 批量撤销

如果撤销超过40次那么jvm就会撤销所有对象的偏向

6.5 锁消除

在JVM中有一个即时编译器(JIT)发现锁的临界区里面根本就没有共享资源,那么就取消了这个锁。

image-20211012210126414

7. wait/notify

image-20240427133739558

  • Owner线程发现条件不满足,调用wait方法,即可进入WaitSet3变为WAITING状态
  • BLOCKED和WAITING的线程都处于阻塞状态,不占用CPU时间片
  • BLOCKED线程会在Owner线程释放锁时唤醒
  • WAITING线程会在Owner线程调用notify或notifyAll时唤醒,但唤醒后并不意味者立刻获得锁,仍需进入
    EntryList重新竞争

7.1 API介绍

  • obj.wait():让进入object监视器的线程到waitSet等待
  • obj.notify():让object上正在waitSet等待的线程中随机挑选一个唤醒
  • obj.notifyAll():让object上正在waiteSet的所有等待的线程被唤醒

8. wait/notify正确使用

8.1 sleep和wait区别

  1. sleep是Thread的静态方法,wait是Object的方法。
  2. sleep不需要强制和synchronized配合使用,但是wait需要和synchronized一起使用。
  3. sleep不会释放对象锁,wait在等待的时候会释放对象锁。
  4. 不管调用sleep或者wait,当前线程都会进入TIMED_WAITING状态。

8.2 正确使用

case升级过程建议观看视频

总结:

可以通过while多次判断条件是否成立,直接使用notifyAll来唤醒所有的线程。然后线程被唤醒之后先再次判断条件是否成立,成立那么往下面执行,如果不成立那么继续执行wait。

1
2
3
4
5
6
7
8
9
10
11
12
synchronized(lock) {
while(条件不成立) {
lock.wait();
}
// 条件成立
doSth();
}

//其他线程
synchronized(lock) {
lock.notifyAll();
}

9. 生产者消费者

10. park/unPark

11. 线程状态转换

12. 多把锁

13. 活跃性

14. ReentrantLock


JUC-03-共享模型之管程
https://baijianglai.cn/JUC-03-共享模型之管程/3d1ad634835b/
作者
Lai Baijiang
发布于
2024年4月24日
许可协议