目录

JUC - 共享模型之管程安全

# 共享带来的问题

# 小故事

  • 老王(操作系统)有一个功能强大的算盘(CPU),现在想把它租出去,赚一点外快

image-20220508174629843

  • 小南、小女(线程)来使用这个算盘来进行一些计算,并按照时间给老王支付费用
  • 但小南不能一天24小时使用算盘,他经常要小憩一会(sleep),又或是去吃饭上厕所(阻塞 io 操作),有时还需要一根烟,没烟时思路全无(wait)这些情况统称为(阻塞)

image-20220508174703844

  • 在这些时候,算盘没利用起来(不能收钱了),老王觉得有点不划算
  • 另外,小女也想用用算盘,如果总是小南占着算盘,让小女觉得不公平
  • 于是,老王灵机一动,想了个办法 [ 让他们每人用一会,轮流使用算盘 ]
  • 这样,当小南阻塞的时候,算盘可以分给小女使用,不会浪费,反之亦然
  • 最近执行的计算比较复杂,需要存储一些中间结果,而学生们的脑容量(工作内存)不够,所以老王申请了 一个笔记本(主存),把一些中间结果先记在本上
  • 计算流程是这样的

image-20220508174741388

  • 但是由于分时系统,有一天还是发生了事故

  • 小南刚读取了初始值 0 做了个 +1 运算,还没来得及写回结果

  • 老王说 [ 小南,你的时间到了,该别人了,记住结果走吧 ],于是小南念叨着 [ 结果是1,结果是1...],不甘心地到一边待着去了(上下文切换)

  • 老王说 [ 小女,该你了 ],小女看到了笔记本上还写着 0 做了一个 -1 运算,将结果 -1 写入笔记本

  • 这时小女的时间也用完了,老王又叫醒了小南:[小南,把你上次的题目算完吧],小南将他脑海中的结果 1 写 入了笔记本

  • 小南和小女都觉得自己没做错,但笔记本里的结果是 1 而不是 0

故事主要说明:A 获取初始值 0,修改为 1,但是没有及时放回去,B 就获取了初始值 0,修改为 -1,然后放回去,结果为 -1。最后 A 再将 1 放回去,覆盖了 -1。导致结果为 1 而不是 0。

# Java 的体现

两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?

public class Test {
    static int counter = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter++;
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter--;
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.debug("{}",counter);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 问题分析

以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理解,必须从字节码来进行分析。

例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:

getstatic i // 获取静态变量 i 的值
iconst_1 // 准备常量 1
iadd // 自增
putstatic i // 将修改后的值存入静态变量 i
1
2
3
4

而对应 i-- 也是类似:

getstatic i // 获取静态变量 i 的值
iconst_1 // 准备常量 1
isub // 自减
putstatic i // 将修改后的值存入静态变量 i
1
2
3
4

所以极有可能 i 自增后,没有将修改后的值存入 i,就被 i 自减后存入了,导致最后自增的值覆盖掉自减的值。

而 Java 的内存模型如下,完成静态变量的自增,自减需要在主存和工作内存中进行数据交换(双向箭头):

image-20220508175352097

如果是 单线程 以上 8 行代码是顺序执行(不会交错)没有问题:

image-20220508175441709

多线程 下这 8 行代码可能交错运行:

i = 0,先 i++ 然后 i--,结果应该为 0,但是多线程有如下结果:

出现负数的情况:

  • 线程 2 先获取 i = 0,然后进行 i - 1 = -1,来不及将 -1 放回去(覆盖 0),就被线程 1 获取 i = 0,然后进行 i + 1 = 1,将 1 放回去,最后线程 2 才将 -1 返回去,导致 1 被 -1 覆盖,结果为 -1

image-20220508175521614

出现正数的情况:

  • 线程 1 先获取 i = 0,然后进行 i + 1 = 1,来不及将 1 放回去(覆盖 0),就被线程 2 获取 i = 0,然后进行 i - 1 = -1,将 -1 放回去,最后线程 1 才将 1 返回去,导致 -1 被 1 覆盖,结果为 1

image-20220508175634846

# 临界区 Critical Section

  • 一个程序运行多个线程本身是没有问题的
  • 问题出在多个线程访问 共享资源
    • 多个线程读 共享资源 其实也没有问题
    • 在多个线程对 共享资源 读写操作时发生指令交错,就会出现问题
  • 一段代码块内如果存在对 共享资源 的多线程读写操作,称这段代码块为 临界区

例如,下面代码中的临界区:

public class Test {
    static int counter = 0;
    static void increment() 
    // 临界区
    { 
        counter++;
    }
    static void decrement() 
    // 临界区
    { 
        counter--;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 竞态条件 Race Condition

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

上面代码的 counter++counter-- 就是两个线程对 counter 进行竞争获取。

# Synchronized 解决方案

# 应用之互斥

为了避免临界区的竞态条件发生,有多种手段可以达到目的。

  • 阻塞式的解决方案:Synchronized,Lock
  • 非阻塞式的解决方案:原子变量

本内容使用阻塞式的解决方案:Synchronized,来解决上述问题,即俗称的「对象锁」,它采用互斥的方式让同一时刻至多只有一个线程能持有「对象锁」,其它线程再想获取这个「对象锁」时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。

虽然 Java 中互斥和同步都可以采用 Synchronized 关键字来完成,但它们还是有区别的:

  • 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
  • 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点

# 解决

语法:

synchronized(对象) // 线程 1 获取对象后, 线程 2来到这里就 blocked 阻塞
{
    临界区
}
1
2
3
4

代码:

public class Test {
    static int counter = 0;
    // 创建一个锁
    static final Object room = new Object();
    public static void main(String[] args) throws InterruptedException {
        
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (room) {
                    counter++;
                }
            }
        }, "t1");
        
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (room) {
                    counter--;
                }
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.debug("{}",counter);
    }
}
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

如下图,当第一个人抢到钥匙进入 Owner 房子后,就锁住门,导致其他人无法进入,当第一个人执行完临界区(房子)代码,就开门唤醒其他两个人,并把钥匙放到一个地方,让两个人争抢。

image-20220508181822085

你可以做这样的类比:

  • Synchronized(对象) 中的对象,可以想象为一个房间(room),有唯一入口(门)房间只能一次进入一人进行计算,线程 t1,t2 想象成两个人
  • Synchronized(对象) 中的对象,可以想象为一个房间(room),有唯一入口(门)房间只能一次进入一人进行计算,线程 t1,t2 想象成两个人
  • 这时候如果 t2 也运行到了 synchronized(room) 时,它发现门被锁住了,只能在门外等待,发生了上下文切换,阻塞住了
  • 这中间即使 t1 的 cpu 时间片不幸用完,被踢出了门外(不要错误理解为锁住了对象就能一直执行下去哦),这时门还是锁住的,t1 仍拿着钥匙,t2 线程还在阻塞状态进不来,只有下次轮到 t1 自己再次获得时间片时才能开门进入
  • 当 t1 执行完 synchronized{} 块内的代码,这时候才会从 obj 房间出来并解开门上的锁,唤醒 t2 线程把钥匙给他。t2 线程这时才可以进入 obj 房间,锁住了门拿上钥匙,执行它的 count-- 代码

用图来表示:

image-20220508183907285

Synchronized 实际是用 对象锁 保证了临界区内代码的 原子性,原子性:临界区内的代码对外是不可分割的,不会被线程切换所打断。

如果把 synchronized(obj) 放在 for 循环的外面,如何理解?

原子性问题,这样锁住的临界区就包含在 for 循环,如果以 i++ 的四个字节码来说,那就是锁住了 2w 个字节码,结果是对的,性能差些。

如果 t1 执行 synchronized(obj1) 而 t2 执行 synchronized(obj2) 会怎样运作?

锁对象问题,锁对象不一样,t2 进入 t1 不会被阻塞,同理 t1 进入 t2 不会被阻塞。

形象说就是每个锁都是一个钥匙,钥匙不一样,那么 t1 和 t2 就可以进入不同的房间,都可以对同一个 count 进行操作,导致结果不对。

如果 t1 执行 synchronized(obj) 而 t2 没有加会怎么样?如何理解?

锁对象问题,t2 进入临界区时,没有被 synchronized 锁限制,所以直接进去操作 count,导致结果不对。

# 面向对象改进

把需要保护的共享变量放入一个类

// 锁类
class Room {
    int value = 0;
    public void increment() {
        synchronized (this) {
            value++;
        }
    }
    public void decrement() {
        synchronized (this) {
            value--;
        }
    }
    // 获取时,也要锁住,防止正在 + 或者 - 时,返回值
    public int get() {
        synchronized (this) {
            return value;
        }
    }
}
@Slf4j
public class Test1 {

    public static void main(String[] args) throws InterruptedException {
        Room room = new Room();
        
        Thread t1 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                room.increment();
            }
        }, "t1");
        
        Thread t2 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                room.decrement();
            }
        }, "t2");
        
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.debug("count: {}" , room.get());
    }
}
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

# 方法上的 Synchronized

加在实例方法上的 Synchronized 等价于在方法里加 synchronized(this)

class Test{
    public synchronized void test() {

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

        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

如上面的锁类 Room 可以写成:

// 锁类
class Room {
    int value = 0;
    public synchronized void increment() {
        value++;
    }
    public synchronized void decrement() {
        value--;
    }
    // 获取时,也要锁住,防止正在 + 或者 - 时,返回值
    public synchronized int get() {
        return value;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

加在静态方法上的 Synchronized 等价于在方法里加 synchronized(类.class)

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

        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

# 不加 synchronized 的方法

不加 Synchronzied 的方法就好比不遵守规则的人,不去老实排队,无法保证原子性,即无法保证线程安全。

# 线程八锁

其实就是考察 Synchronized 锁住的是哪个对象,下面给出八个例子:

例子 1

代码:

public class Test {
    class Number{
        public synchronized void a() {
            log.debug("1");
        }
        public synchronized void b() {
            log.debug("2");
        }
    }
    public static void main(String[] args) {
        Number n1 = new Number();
        new Thread(()->{ n1.a(); }).start();
        new Thread(()->{ n1.b(); }).start();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

等价于 synchronized(this),所以输出:

1 22 1
1
2

例子 2

代码:

public class Test {
    class Number{
        public synchronized void a() {
            sleep(1);
            log.debug("1");
        }
        public synchronized void b() {
            log.debug("2");
        }
    }
    public static void main(String[] args) {
        Number n1 = new Number();
        new Thread(()->{ n1.a(); }).start();
        new Thread(()->{ n1.b(); }).start();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

等价于 synchronized(this),所以输出:

1s 后 122 1s 后 1
1
2

例子 3

代码:

class Number{
    public synchronized void a() {
        sleep(1);
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
    public void c() {
        log.debug("3");
    }
}
public class Test {
    public static void main(String[] args) {
        Number n1 = new Number();
        new Thread(()->{ n1.a(); }).start();
        new Thread(()->{ n1.b(); }).start();
        new Thread(()->{ n1.c(); }).start();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

c() 方法没有锁,因为 a() 睡眠 1s,所以 c() 必然先输出,所以输出:

3 1s 后 1223 1s 后 132 1s 后 1
1
2
3

例子 4

代码:

class Number{
    public synchronized void a() {
        sleep(1);
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
}
public class Test {
    public static void main(String[] args) {
        Number n1 = new Number();
        Number n2 = new Number();
        new Thread(()->{ n1.a(); }).start();
        new Thread(()->{ n2.b(); }).start();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

等价于 synchronized(this),this 是对象,而 n1 和 n2 是两个对象,所以锁不影响,输出:

2 1s 后 1
1

例子 5

代码:

class Number{
    public static synchronized void a() {
        sleep(1);
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
}
public class Test {
    public static void main(String[] args) {
        Number n1 = new Number();
        new Thread(()->{ n1.a(); }).start();
        new Thread(()->{ n1.b(); }).start();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

输出:

2 1s 后 1
1

例子 6

代码:

class Number{
    public static synchronized void a() {
        sleep(1);
        log.debug("1");
    }
    public static synchronized void b() {
        log.debug("2");
    }
}
public class Test {
    public static void main(String[] args) {
        Number n1 = new Number();
        new Thread(()->{ n1.a(); }).start();
        new Thread(()->{ n1.b(); }).start();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

等价于 synchronized(Number.class),所以输出:

1s 后 122 1s 后 1
1
2

例子 7

代码:

class Number{
    class Number{
        public static synchronized void a() {
            sleep(1);
            log.debug("1");
        }
        public synchronized void b() {
            log.debug("2");
        }
    }
}
public class Test {
    public static void main(String[] args) {
        Number n1 = new Number();
        Number n2 = new Number();
        new Thread(()->{ n1.a(); }).start();
        new Thread(()->{ n2.b(); }).start();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

输出:

2 1s 后 1
1

例子 8

代码:

class Number{
    public static synchronized void a() {
        sleep(1);
        log.debug("1");
    }
    public static synchronized void b() {
        log.debug("2");
    }
}
public class Test {
    public static void main(String[] args) {
        Number n1 = new Number();
        Number n2 = new Number();
        new Thread(()->{ n1.a(); }).start();
        new Thread(()->{ n2.b(); }).start();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

等价于 synchronized(Number.class),而 n1 和 n2 都属于 Number 类,所以谁拿到锁,就先输出谁,所以输出:

1s 后 122 1s 后 1
1
2

# 变量的线程安全分析

# 成员变量和静态变量是否线程安全?

  • 如果它们没有共享,则线程安全
  • 如果它们被共享了,根据它们的状态是否能够改变,又分两种情况
    • 如果只有读操作,则线程安全
    • 如果有读写操作,则这段代码是临界区,需要考虑线程安全

# 局部变量是否线程安全?

  • 局部变量是线程安全的
  • 但局部变量引用的对象则未必安全
    • 如果该对象没有逃离方法的作用访问,它是线程安全的
    • 如果该对象逃离方法的作用范围,需要考虑线程安全

# 局部变量线程安全分析

public static void test1() {
    int i = 10;
    i++;
}
1
2
3
4

每个线程调用 test1() 方法时局部变量 i,会在每个线程的栈帧内存中被创建多份,因此不存在共享。

如图:

image-20220508214945625

局部变量的引用稍有不同。

先看一个成员变量的例子:

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);
    }
}
public class Test {
    static final int THREAD_NUMBER = 2;
    static final int LOOP_NUMBER = 200;
    public static void main(String[] args) {
        ThreadUnsafe test = new ThreadUnsafe();
        for (int i = 0; i < THREAD_NUMBER; i++) {
            new Thread(() -> {
                test.method1(LOOP_NUMBER);
            }, "Thread" + i).start();
        }
    }
}
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

其中一种情况是,如果线程 2 还未 add,线程 1 remove 就会报错:

Exception in thread "Thread1" java.lang.IndexOutOfBoundsException: Index: 0, Size: 0 
 at java.util.ArrayList.rangeCheck(ArrayList.java:657) 
 at java.util.ArrayList.remove(ArrayList.java:496) 
 at cn.itcast.n6.ThreadUnsafe.method3(TestThreadSafe.java:35) 
 at cn.itcast.n6.ThreadUnsafe.method1(TestThreadSafe.java:26) 
 at cn.itcast.n6.TestThreadSafe.lambda$main$0(TestThreadSafe.java:14) 
 at java.lang.Thread.run(Thread.java:748) 
1
2
3
4
5
6
7

分析:

  • 无论哪个线程中的 method2 引用的都是同一个对象中的 list 成员变量
  • method3 与 method2 分析相同

image-20220508222851139

将 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);
        }
    }
    private void method2(ArrayList<String> list) {
        list.add("1");
    }
    private void method3(ArrayList<String> list) {
        list.remove(0);
    }
}
public class Test {
    static final int THREAD_NUMBER = 2;
    static final int LOOP_NUMBER = 200;
    public static void main(String[] args) {
        ThreadSafe test = new ThreadSafe();
        for (int i = 0; i < THREAD_NUMBER; i++) {
            new Thread(() -> {
                test.method1(LOOP_NUMBER);
            }, "Thread" + i).start();
        }
    }
}
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

分析:

  • list 是局部变量,每个线程调用时会创建其不同实例,没有共享
  • 而 method2 的参数是从 method1 中传递过来的,与 method1 中引用同一个对象
  • method3 的参数分析与 method2 相同

image-20220508222808931

方法访问修饰符带来的思考,如果把 method2 和 method3 的方法修改为 public 会不会代理线程安全问题?

情况 1:有其它线程调用 method2 和 method3。

不会引起线程安全问题,因为 参数 list 没有对外暴露,是局部的。

情况 2:在情况 1 的基础上,为 ThreadSafe 类添加子类,子类覆盖 method2 或 method3 方法。

如下代码:

class ThreadSafe {
    public 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");
    }
    public void method3(ArrayList<String> list) {
        list.remove(0);
    }
}
class ThreadSafeSubClass extends ThreadSafe{
    @Override
    public void method3(ArrayList<String> list) {
        new Thread(() -> {
            list.remove(0);
        }).start();
    }
}
public class Test {
    static final int THREAD_NUMBER = 2;
    static final int LOOP_NUMBER = 200;
    public static void main(String[] args) {
        ThreadSafeSubClass test = new ThreadSafeSubClass();
        for (int i = 0; i < THREAD_NUMBER; i++) {
            new Thread(() -> {
                test.method1(LOOP_NUMBER);
            }, "Thread" + i).start();
        }
    }
}
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

test 是子类的对象,这样子类暴露出了 list,可能导致 list 为空时调用 list.remove(0) 报错。

从这个例子可以看出 private 或 final 提供「安全」的意义所在,请体会开闭原则中的「闭」,如下:

class ThreadSafe {
    public final  void method1(int loopNumber) {
        ArrayList<String> list = new ArrayList<>();
        for (int i = 0; i < loopNumber; i++) {
            method2(list);
            method3(list);
        }
    }
    private void method2(ArrayList<String> list) {
        list.add("1");
    }
    private void method3(ArrayList<String> list) {
        list.remove(0);
    }
}
class ThreadSafeSubClass extends ThreadSafe{
    @Override
    public void method3(ArrayList<String> list) {
        new Thread(() -> {
            list.remove(0);
        }).start();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 常见线程安全类

JDK 提供的大部分线程安全类:

  • String
  • Integer
  • StringBuffer
  • Random
  • Vector
  • Hashtable
  • java.util.concurrent 包下的类

这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。也可以理解为

public void test() {
    Hashtable table = new Hashtable();
    
    new Thread(()->{
        table.put("key", "value1");
    }).start();
    
    new Thread(()->{
        table.put("key", "value2");
    }).start();
}
1
2
3
4
5
6
7
8
9
10
11
  • 它们的每个方法是原子的
  • 但注意它们多个方法的组合不是原子的,如下

# 线程安全类方法的组合

分析下面代码是否线程安全?

public void test() {
    Hashtable table = new Hashtable();
    // 线程 1,线程 2
    if( table.get("key") == null) {
        table.put("key", value);
    }
}
1
2
3
4
5
6
7

上面代码是不安全的,可能线程 1 和 线程 2 都先进入 if 里,然后线程 2 先 put 值,最后线程 1 再 put 值,分析如下图:

image-20220508224539004

# 不可变类线程安全性

String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的。

疑问:String 有 replacesubstring 等方法 可以 改变值,那么这些方法又是如何保证线程安全的呢?

看这些方法的源码就发现,这些方法的返回值都是 new 一个新的 String 对象,也就是返回新的改变值的 String 对象。

如下自定义不可变类:

public class Immutable{
    private int value = 0;
    public Immutable(int value){
        this.value = value;
    }
    public int getValue(){
        return this.value;
    }
	// 增加一个修改值的方法,返回一个新的 Immutable 类
    public Immutable setValue(int value){
        return new Immutable(this.value);
    } 
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 实例分析

例 1

引用对象不管是不是 final 修饰,只要对象的很多方法不是线程安全的(不加锁),那就是不安全的。

public class MyServlet extends HttpServlet {
    // 是否安全?不安全
    Map<String,Object> map = new HashMap<>();
    // 是否安全?安全,因为 String 是不可变类
    String S1 = "...";
    // 是否安全?安全,因为 String 是不可变类
    final String S2 = "...";
    
    // 是否安全?不安全
    Date D1 = new Date();
    // 是否安全?不安全,Date 是引用对象,里面有很多方法不是线程安全的
    final Date D2 = new Date();
}
1
2
3
4
5
6
7
8
9
10
11
12
13

例 2

public class MyServlet extends HttpServlet {
    // 是否安全?不安全,
    private UserService userService = new UserServiceImpl();

    public void doGet(HttpServletRequest request, HttpServletResponse response) {
        userService.update(...);
    }
}
public class UserServiceImpl implements UserService {
    // 记录调用次数
    private int count = 0;

    public void update() {
        // ...
        count++;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

userService 是线程不安全的,因为可能有多个线程同时调用一个 MyServlet 对象,导致 count++ 临界区发送改变。

例 3

@Aspect
@Component
public class MyAspect {
    // 是否安全?不安全
    private long start = 0L;

    @Before("execution(* *(..))")
    public void before() {
        start = System.nanoTime();
    }

    @After("execution(* *(..))")
    public void after() {
        long end = System.nanoTime();
        System.out.println("cost time:" + (end-start));
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

Spring 默认都是单例对象,导致 start 会被多个线程 同时 使用,所以不安全。

解决:使用环绕通知,将 start 改为 局部变量

例 4

public class MyServlet extends HttpServlet {
    // 是否安全?安全
    private UserService userService = new UserServiceImpl();

    public void doGet(HttpServletRequest request, HttpServletResponse response) {
        userService.update(...);
    }
}
public class UserServiceImpl implements UserService {
    // 是否安全?安全
    private UserDao userDao = new UserDaoImpl();

    public void update() {
        userDao.update();
    }
}
public class UserDaoImpl implements UserDao { 
    public void update() {
        String sql = "update user set password = ? where username = ?";
        // 是否安全?安全
        try (Connection conn = DriverManager.getConnection("","","")){
            // ...
        } catch (Exception e) {
            // ...
        }
    }
}
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

MyServlet、UserServiceImpl、UserDaoImpl 没有 公有 成员变量,所以无法被多线程进行共享。

例 5

public class MyServlet extends HttpServlet {
    // 是否安全?不安全
    private UserService userService = new UserServiceImpl();

    public void doGet(HttpServletRequest request, HttpServletResponse response) {
        userService.update(...);
    }
}
public class UserServiceImpl implements UserService {
    // 是否安全?不安全
    private UserDao userDao = new UserDaoImpl();

    public void update() {
        userDao.update();
    }
}
public class UserDaoImpl implements UserDao {
    // 是否安全?不安全
    private Connection conn = null;
    public void update() throws SQLException {
        String sql = "update user set password = ? where username = ?";
        conn = DriverManager.getConnection("","","");
        // ...
        conn.close();
    }
}
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

19 行 Connection 为成员变量,导致被多个线程共享,可能线程 1 执行到 22 行,线程 2 已经执行到 24 行,把 conn 关掉,导致线程 1 的 22 行报错。

例 6

public class MyServlet extends HttpServlet {
    // 是否安全?安全
    private UserService userService = new UserServiceImpl();

    public void doGet(HttpServletRequest request, HttpServletResponse response) {
        userService.update(...);
    }
}
public class UserServiceImpl implements UserService { 
    public void update() {
        UserDao userDao = new UserDaoImpl();
        userDao.update();
    }
}
public class UserDaoImpl implements UserDao {
    // 是否安全?安全
    private Connection conn = null;
    public void update() throws SQLException {
        String sql = "update user set password = ? where username = ?";
        conn = DriverManager.getConnection("","","");
        // ...
        conn.close();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

虽然 17 行 conn 依然是成员变量,但是 11 行的 userDao 是局部变量,所以每个线程都有一个 userDao 局部变量,导致都有一个 conn 互不影响。

例 7

public abstract class Test {

    public void bar() {
        // 是否安全?
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        foo(sdf);
    }

    public void foo(SimpleDateFormat sdf);

    public static void main(String[] args) {
        new Test().bar();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

其中 foo 的行为是不确定的,可能导致不安全的发生,被称之为 外星方法

public void foo(SimpleDateFormat sdf) {
    String dateStr = "1999-10-11 00:00:00";
    for (int i = 0; i < 20; i++) {
        new Thread(() -> {
            try {
                sdf.parse(dateStr);
            } catch (ParseException e) {
                e.printStackTrace();
            }
        }).start();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

线程不安全原因:sdf 对象在 main 线程创建,却在子类的另一个线程执行 parse 方法,所以线程不安全。

String 类为什么有 final 修饰,就是为了防止我们写子类继承 String,然后重写 String 的方法,导致线程不安全。

例 8

public class Test {
    private static Integer i = 0;
    public static void main(String[] args) throws InterruptedException {
        List<Thread> list = new ArrayList<>();
        for (int j = 0; j < 2; j++) {
            Thread thread = new Thread(() -> {
                for (int k = 0; k < 5000; k++) {
                    synchronized (i) {
                        i++;
                    }
                }
            }, "" + j);
            list.add(thread);
        }
        list.stream().forEach(t -> t.start());
        list.stream().forEach(t -> {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        log.debug("{}", i);
    }
}
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

# 习题

# 卖票练习

public class ExerciseSell {
    public static void main(String[] args) {
        TicketWindow ticketWindow = new TicketWindow(2000);
        List<Thread> list = new ArrayList<>();
        // 用来存储买出去多少张票,Vector 是线程安全类
        List<Integer> sellCount = new Vector<>();
        for (int i = 0; i < 2000; i++) {
            Thread t = new Thread(() -> {
                // 分析这里的竞态条件
                int count = ticketWindow.sell(randomAmount());
                // 虽然多个线程都操作 sellCount,但 sellCount 是 Vector 线程安全类
                sellCount.add(count);
            });
            list.add(t);
            t.start();
        }
        list.forEach((t) -> {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        // 买出去的票求和
        log.debug("selled count:{}",sellCount.stream().mapToInt(c -> c).sum());
        // 剩余票数
        log.debug("remainder count:{}", ticketWindow.getCount());
    }
    // Random 为线程安全
    static Random random = new Random();
    // 随机 1~5
    public static int randomAmount() {
        return random.nextInt(5) + 1;
    }
}
class TicketWindow {
    private int count;
    public TicketWindow(int count) {
        this.count = count;
    }
    public int getCount() {
        return count;
    }
    public int sell(int amount) {
        if (this.count >= amount) {
            this.count -= amount;
            return amount;
        } else {
            return 0;
        }
    }
}
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

sell 方法是临界区,因为多个线程都能同时进入 sell 方法操作 count,所以锁住该方法即可。

public synchronized int sell(int amount) {
    if (this.count >= amount) {
        this.count -= amount;
        return amount;
    } else {
        return 0;
    }
}
1
2
3
4
5
6
7
8

# 转账练习

public class ExerciseTransfer {
    public static void main(String[] args) throws InterruptedException {
        Account a = new Account(1000);
        Account b = new Account(1000);
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                a.transfer(b, randomAmount());
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                b.transfer(a, randomAmount());
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        // 查看转账 2000 次后的总金额
        log.debug("total:{}",(a.getMoney() + b.getMoney()));
    }
    // Random 为线程安全
    static Random random = new Random();
    // 随机 1~100
    public static int randomAmount() {
        return random.nextInt(100) +1;
    }
}
class Account {
    private int money;
    public Account(int money) {
        this.money = money;
    }
    public int getMoney() {
        return money;
    }
    public void setMoney(int money) {
        this.money = money;
    }
    public void transfer(Account target, int amount) {
        if (this.money > amount) {
            this.setMoney(this.getMoney() - amount);
            target.setMoney(target.getMoney() + amount);
        }
    }
}
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

transfer 方法是临界区,所以我们需要针对 transfer 方法内部进行加锁,那么这样可以吗?为什么?

public synchronized void transfer(Account target, int amount) {
    if (this.money > amount) {
        this.setMoney(this.getMoney() - amount);
        target.setMoney(target.getMoney() + amount);
    }
}
1
2
3
4
5
6

直接在方法上加 synchronized,是不行的,因为有两个 Account 类,在方法上加 synchronized 等价于 synchronized(this),仅仅锁住自己的对象,也就是 a 锁住自己,b 锁住自己,互不影响。如何解决呢?

因为 a 和 b 都是 Account 类,所以锁住 Account 类即可,如下:

public void transfer(Account target, int amount) {
    synchronized(Account.class) {
        if (this.money > amount) {
            this.setMoney(this.getMoney() - amount);
            target.setMoney(target.getMoney() + amount);
        }
    }
}
1
2
3
4
5
6
7
8
更新时间: 2024/01/17, 05:48:13
最近更新
01
JVM调优
12-10
02
jenkins
12-10
03
Arthas
12-10
更多文章>