• 剑指JUC原理-4.共享资源和线程安全性


    • 👏作者简介:大家好,我是爱吃芝士的土豆倪,24届校招生Java选手,很高兴认识大家
    • 📕系列专栏:Spring源码、JUC源码
    • 🔥如果感觉博主的文章还不错的话,请👍三连支持👍一下博主哦
    • 🍂博主正在努力完成2023计划中:源码溯源,一探究竟
    • 📝联系方式:nhs19990716,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬👀

    共享问题

    小故事

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

    在这里插入图片描述

    小南、小女(线程)来使用这个算盘来进行一些计算,并按照时间给老王支付费用

    但小南不能一天24小时使用算盘,他经常要小憩一会(sleep),又或是去吃饭上厕所(阻塞 io 操作),有
    时还需要一根烟,没烟时思路全无(wait)这些情况统称为(阻塞)

    在这里插入图片描述

    在这些时候,算盘没利用起来(不能收钱了),老王觉得有点不划算

    另外,小女也想用用算盘,如果总是小南占着算盘,让小女觉得不公平

    于是,老王灵机一动,想了个办法 [ 让他们每人用一会,轮流使用算盘 ](分时系统)

    这样,当小南阻塞的时候,算盘可以分给小女使用,不会浪费,反之亦然

    最近执行的计算比较复杂,需要存储一些中间结果,而学生们的脑容量(工作内存)不够,所以老王申请了
    一个笔记本(主存),把一些中间结果先记在本上

    计算流程是这样的

    在这里插入图片描述

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

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

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

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

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

    在这里插入图片描述

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

    Java的体现

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

    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
    问题分析

    以上的结果可能是正数、负数、零。为什么呢?因为 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

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

    在这里插入图片描述

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

    在这里插入图片描述

    但多线程下这 8 行代码可能交错运行:
    出现负数的情况:

    在这里插入图片描述

    出现正数的情况:

    在这里插入图片描述

    临界区 Critical Section

    一个程序运行多个线程本身是没有问题的

    问题出在多个线程访问共享资源

    • 多个线程读共享资源其实也没有问题
    • 在多个线程对共享资源读写操作时发生指令交错,就会出现问题

    一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区

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

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

    竞态条件 Race Condition

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

    synchronized 解决方案

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

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

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

    synchronized

    语法

    synchronized(对象) // 线程1, 线程2(blocked)
    {
     临界区
    }
    
    • 1
    • 2
    • 3
    • 4

    解决

    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

    在这里插入图片描述

    你可以做这样的类比:

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

    用图来表示

    在这里插入图片描述

    思考

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

    为了加深理解,请思考下面的问题

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

    其实很好回答,就是相当于保证了整个for循环的原子性。

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

    相当于没有作用,因为想要保护共享资源,需要让多个资源保护同一把锁

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

    由于线程2没有加锁,根据上图所示,第一次上下文切换的时候,就不会出现获取锁被阻塞的情况,所以就会直接写入,导致出现问题。

    面向对象的改进

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

    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

    这里其实需要关注的是为什么get方法也上锁了,其实是为了保证获取值是一个准确的结果,而不是一个中间的结果,需要加锁。

    方法上的synchronized

    class Test{
     public synchronized void test() {
     
     }
    }
    等价于
    class Test{
     public void test() {
     synchronized(this) { // 实际上是对 对象实例上锁,而不是对象本身
     
     }
     }
    }
    
    
    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
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    所谓的线程八锁

    其实就是考察 synchronized 锁住的是哪个对象

    情况1:
    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

    先1后2 或者 先2后1 原因是因为 同一个对象实例,都是同一把锁,谁先抢到锁谁先输出

    情况2:
    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

    1s后1 2 或者 2 1s后 1 情况与情况1一致

    情况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 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

    先 3 1s后 1 2 或者 2 3 1s 后 1 或者 3 2 1s 后 1 这个总体上还是和情况1一致

    情况4:
    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();
     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

    2 1s 后 1 之所以会出现这样的情况是因为,因为 synchronized(this)锁住的是 实例对象,而main中有两个不一样的实例,那么其实就相当于没加锁,所以自然是这个执行顺序

    情况5:
    class Number{
     public static 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

    2 1s 后 1 这个情况其实和前面说的一样,实际上是关于锁对象的概念,就是如果给静态方法加锁,实际上的锁对象是类本身,而不是实例对象,而给普通方法加锁,锁对象是实例对象,所以自然是这个结果

    情况6:
    class Number{
     public static synchronized void a() {
     sleep(1);
     log.debug("1");
     }
     public static 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

    1s 后12, 或 2 1s后 1 实际上和情况1类似,只不过 锁对象由 实例对象 替换成了 类对象

    情况7:
    class Number{
     public static 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();
     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

    2 1s 后 1 这个情况也非常好分析,锁对象不同

    情况8:
    class Number{
     public static synchronized void a() {
     sleep(1);
     log.debug("1");
     }
     public static synchronized void b() {
     log.debug("2");
     }
    }
    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

    1s 后12, 或 2 1s后 1 该情况和前面情况重复,不在介绍

    变量的线程安全分析

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

    如果它们没有共享,则线程安全

    如果它们被共享了,根据它们的状态是否能够改变,又分两种情况

    • 如果只有读操作,则线程安全
    • 如果有读写操作,则这段代码是临界区,需要考虑线程安全

    局部变量是否线程安全?

    局部变量是线程安全的

    但局部变量引用的对象则未必

    • 如果该对象没有逃离方法的作用访问,它是线程安全的
    • 如果该对象逃离方法的作用范围,需要考虑线程安全

    局部变量线程安全分析

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

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

    在这里插入图片描述

    局部变量的引用稍有不同

    先看一个成员变量的例子

    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);
     }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    执行

    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

    其中一种情况是,如果线程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 分析相同

    在这里插入图片描述

    将 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);
     }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    那么就不会有上述问题了

    分析:

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

    在这里插入图片描述

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

    情况1:有其它线程调用 method2 和 method3,有可能被其他线程调用到,此时的list不一定是局部变量了

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

    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");
    }
     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();
     }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

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

    常见的线程安全类

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

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

    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

    它们的每个方法是原子的

    但注意它们多个方法的组合不是原子的

    线程安全类方法的组合

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

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

    在这里插入图片描述

    不可变类线程安全性

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

    有同学或许有疑问,String 有 replace,substring 等方法【可以】改变值啊,那么这些方法又是如何保证线程安
    全的呢?

    进入replace源码查看:

    public String replace(char oldChar, char newChar) {
            if (oldChar != newChar) {
                int len = value.length;
                int i = -1;
                char[] val = value; /* avoid getfield opcode */
    
                while (++i < len) {
                    if (val[i] == oldChar) {
                        break;
                    }
                }
                if (i < len) {
                    char buf[] = new char[len];
                    for (int j = 0; j < i; j++) {
                        buf[j] = val[j];
                    }
                    while (i < len) {
                        char c = val[i];
                        buf[i] = (c == oldChar) ? newChar : c;
                        i++;
                    }
                    return new String(buf, true);
                }
            }
            return this;
        }
    
    • 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

    其实可以看到最终的返回是 return 了一个 新的String对象,并不是改变值。

    以一个例子来举例:

    public class Immutable{
     private int value = 0;
     public Immutable(int value){
     this.value = value;
     }
     public int getValue(){
     return this.value;
     }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    如果想增加一个增加的方法呢?

    public class Immutable{
     private int value = 0;
     public Immutable(int value){
     this.value = value;
     }
     public int getValue(){
     return this.value;
     }
     
     public Immutable add(int v){
     return new Immutable(this.value + v);
     } 
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    实例分析

    例1:
    // 是否安全?
     Map<String,Object> map = new HashMap<>();
     // 是否安全?
     String S1 = "...";
     // 是否安全?
     final String S2 = "...";
     // 是否安全?
     Date D1 = new Date();
     // 是否安全?
     final Date D2 = new Date();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    其中map不是线程安全的,map的线程安全由其他方式实现

    s1 和 s2都是线程安全的

    D1 不是线程安全的

    D2 虽然加了final,但是日期是可变类,里面的值是可以改变的

    例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

    并不是线程安全的,原因是 impl中有 临界区资源没被控制

    例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 和 after都是局部变量,那么此时就不存在线程安全问题了。

    例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

    本质上是线程安全的,没有成员变量,Conn 属于一个方法内部的局部变量,每个线程得到的都是一个新的。

    例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 { 
     public void update() {
     UserDao userDao = new UserDaoImpl();
     userDao.update();
     }
    }
    public class UserDaoImpl implements UserDao {
     // 是否安全
     private Connection = 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

    其中虽然UserDao 线程不安全,但是 UserServiceImpl它在局部变量中,不存在线程不安全

    例7:
    public void bar() {
     // 是否安全
     SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
     foo(sdf);
    }
     
     public abstract foo(SimpleDateFormat sdf);
     
     
     public static void main(String[] args) {
     new Test().bar();
     }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    其中还是有可能存在线程安全的问题,原因是因为,是局部变量还需要看看会不会暴漏给其他线程,也要看对象的引用是否泄露了

    其中 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

    其实在这里面也就可以理解了,String不设置为null,其子类就有可能覆盖其中的一些行为,导致线程不安全性。

    卖票问题

    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;
     }
     }
    }
    
    public static void main(String[] args) {
     TicketWindow ticketWindow = new TicketWindow(2000);
     List<Thread> list = new ArrayList<>();
     // 用来存储买出去多少张票
     List<Integer> sellCount = new Vector<>();
     for (int i = 0; i < 2000; i++) {
     Thread t = new Thread(() -> {
     // 分析这里的竞态条件
     int count = ticketWindow.sell(randomAmount());
     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;
     }
    
    • 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

    这段代码中是存在线程安全的问题的,但是线程安全的问题不好复现,可以加上sleep来方便复现问题。

    通过分析代码,发现是sell方法里面的临界资源出现了 线程不安全的问题

    解决方法就是再方法上加入 synchronized。

    但是其实代码中还是存在很多细节的:

    比如说,再线程中有一个sellCount.add的操作,这个操作其实是多线程操作,每一个线程都需要执行,而普通的list集合是线程不安全的,所以代码中使用的是 Vector,具体再java中源码如下:

    ArrayList
    public boolean add(E e) {
            ensureCapacityInternal(size + 1);  // Increments modCount!!
            elementData[size++] = e;
            return true;
        }
        
    Vector
    public synchronized boolean add(E e) {
            modCount++;
            ensureCapacityHelper(elementCount + 1);
            elementData[elementCount++] = e;
            return true;
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    而 后面还有一个list.add 这个没有用线程安全的集合实现,原因就是 因为它是执行在主线程中,本身就不存在线程安全的问题。

    转账问题

    测试下面代码是否存在线程安全问题,并尝试改正

    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);
     	}
     }
    }
    
    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;
     }
    
    • 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

    其实根据卖票问题,代码中也是存在一个线程不安全问题的。但是不能像卖票问题一样,加一个锁就可以了,仔细分析一下发现 transfer中存在两个共享变量,money 和 target,如果对方法加synchronized ,实际上只对money这个共享变量上锁了,而 target并没有,解决办法其实就是对他们两个的父类上锁,也就是 锁对象是Account.class。

    相关推荐博客

    👉👉👉 剑指JUC原理-1.进程与线程-CSDN博客

    👉👉👉 剑指JUC原理-2.线程-CSDN博客

    👉👉👉 剑指JUC原理-3.线程常用方法及状态-CSDN博客

    👉👉👉 剑指JUC原理-5.synchronized底层原理-CSDN博客

    👉👉👉 剑指JUC原理-6.wait notify-CSDN博客

    👉👉👉 剑指JUC原理-7.线程状态与ReentrantLock-CSDN博客

    👉👉👉 剑指JUC原理-8.Java内存模型-CSDN博客

    👉👉👉 剑指JUC原理-9.Java无锁模型-CSDN博客

    👉👉👉 剑指JUC原理-10.并发编程大师的原子累加器底层优化原理(与人类的优秀灵魂对话)-CSDN博客

    👉👉👉 剑指JUC原理-11.不可变设计-CSDN博客

    👉👉👉 剑指JUC原理-12.手写简易版线程池思路-CSDN博客

    👉👉👉 剑指JUC原理-13.线程池-CSDN博客

    👉👉👉 剑指JUC原理-14.ReentrantLock原理-CSDN博客

    👉👉👉 剑指JUC原理-15.ThreadLocal-CSDN博客

    👉👉👉 剑指JUC原理-16.读写锁-CSDN博客

    👉👉👉 剑指JUC原理-17.CompletableFuture-CSDN博客

    👉👉👉 剑指JUC原理-18.同步协作-CSDN博客

    👉👉👉 剑指JUC原理-19.线程安全集合-CSDN博客

    👉👉👉 剑指JUC原理-20.并发编程实践-CSDN博客

  • 相关阅读:
    写一篇nginx配置指南
    C语言结构体中成员的引用
    pandas是什么以及pandas的三种数据结构Series、DataFrame、MultiIndex的创建方式函数代码
    Gomodule和GoPath
    基于神经网络的智能系统,神经元网络控制的作用
    thinkphp websocket 开发实时聊天系统的用户群组与订阅功能实现 (一)
    测试的核心价值到底是什么?
    模块语法笔记
    痞子衡嵌入式:浅谈i.MXRT1xxx系列MCU时钟相关功能引脚的作用
    【JavaWeb】简易权限校验、Filter-程序跳转
  • 原文地址:https://blog.csdn.net/qq_40851232/article/details/134083088