观察线程不安全

// 此处定义⼀个 int 类型的变量 
private static int count = 0;

public static void main(String[] args) throws InterruptedException {
   Thread t1 = new Thread(() -> {
       // 对 count 变量进⾏⾃增 5w 次 
       for (int i = 0; i < 50000; i++) {
          count++;
      }
   });
   Thread t2 = new Thread(() -> {
         // 对 count 变量进⾏⾃增 5w 次 
         for (int i = 0; i < 50000; i++) {
             count++;
         }
      });


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

      // 如果没有这俩 join, 肯定不⾏的. 线程还没⾃增完, 就开始打印了. 很可能打印出来的 
      //count 就是个 0 
      t1.join();
      t2.join();

      // 预期结果应该是 10w 
      System.out.println("count: " + count);
}

线程安全的概念

如果多线程环境下代码运⾏的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是 线程安全的。

线程不安全的原因

线程调度是随机的

这是线程安全问题的罪魁祸⾸

随机调度使⼀个程序在多线程环境下,执⾏顺序存在很多的变数.

程序猿必须保证在任意执⾏顺序下,代码都能正常⼯作.

修改共享数据

多个线程修改同⼀个变量

上⾯的线程不安全的代码中,涉及到多个线程针对count 变量进⾏修改. 此时这个count 是⼀个多个线程都能访问到的"共享数据"

原⼦性

什么是原⼦性

我们把⼀段代码想象成⼀个房间,每个线程就是要进⼊这个房间的⼈。如果没有任何机制保证,A进⼊ 房间之后,还没有出来;B是不是也可以进⼊房间,打断A在房间⾥的隐私。这个就是不具备原⼦性 的。

那我们应该如何解决这个问题呢?是不是只要给房间加⼀把锁,A进去就把⻔锁上,其他⼈是不是就进 不来了。这样就保证了这段代码的原⼦性了。 有时也把这个现象叫做同步互斥,表⽰操作是互相排斥的。

⼀条java语句不⼀定是原⼦的,也不⼀定只是⼀条指令

⽐如刚才我们看到的n++,其实是由三步操作组成的:

1. 从内存把数据读到CPU

2. 进⾏数据更新

3. 把数据写回到CPU

不保证原⼦性会给多线程带来什么问题

如果⼀个线程正在对⼀个变量操作,中途其他线程插⼊进来了,如果这个操作被打断了,结果就可能 是错误的。

这点也和线程的抢占式调度密切相关.如果线程不是"抢占"的,就算没有原⼦性,也问题不⼤.

可⻅性

可⻅性指,⼀个线程对共享变量值的修改,能够及时地被其他线程看到.

Java内存模型(JMM):Java虚拟机规范中定义了Java内存模型.

⽬的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到⼀致的 并发效果.

• 线程之间的共享变量存在主内存(MainMemory).

• 每⼀个线程都有⾃⼰的"⼯作内存"(WorkingMemory).

• 当线程要读取⼀个共享变量的时候,会先把变量从主内存拷⻉到⼯作内存,再从⼯作内存读取数据.

• 当线程要修改⼀个共享变量的时候,也会先修改⼯作内存中的副本,再同步回主内存.

由于每个线程有⾃⼰的⼯作内存,这些⼯作内存中的内容相当于同⼀个共享变量的"副本".此时修改线 程1的⼯作内存中的值,线程2的⼯作内存不⼀定会及时变化.

1)初始情况下,两个线程的⼯作内存内容⼀致.

2)⼀旦线程1修改了a的值,此时主内存不⼀定能及时同步.对应的线程2的⼯作内存的a的值也不⼀定 能及时同步.

这个时候代码中就容易出现问题.

此时引⼊了两个问题:

• 为啥要整这么多内存?

• 为啥要这么⿇烦的拷来拷去?

1)为啥整这么多内存?

实际并没有这么多"内存".这只是Java规范中的⼀个术语,是属于"抽象"的叫法. 所谓的"主内存"才是真正硬件⻆度的"内存".⽽所谓的"⼯作内存",则是指CPU的寄存器和⾼速缓存.

2)为啥要这么⿇烦的拷来拷去?

因为CPU访问⾃⾝寄存器的速度以及⾼速缓存的速度,远远超过访问内存的速度(快了3-4个数量级, 也就是⼏千倍,上万倍).

⽐如某个代码中要连续10次读取某个变量的值,如果10次都从内存读,速度是很慢的.但是如果只是 第⼀次从内存读,读到的结果缓存到CPU的某个寄存器中,那么后9次读数据就不必直接访问内存了. 效率就⼤⼤提⾼了.

快和慢都是相对的.CPU访问寄存器速度远远快于内存,但是内存的访问速度⼜远远快 于硬盘. 对应的,CPU的价格最贵,内存次之,硬盘最便宜.

指令重排序

什么是代码重排序

⼀段代码是这样的:

1. 去前台取下U盘

2. 去教室写10分钟作业

3. 去前台取下快递

如果是在单线程情况下,JVM、CPU指令集会对其进⾏优化,⽐如,按1->3->2的⽅式执⾏,也是没问 题,可以少跑⼀次前台。这种叫做指令重排序

编译器对于指令重排序的前提是"保持逻辑不发⽣变化".这⼀点在单线程环境下⽐较容易判断,但是 在多线程环境下就没那么容易了,多线程的代码执⾏复杂程度更⾼,编译器很难在编译阶段对代码的 执⾏效果进⾏预测,因此激进的重排序很容易导致优化后的逻辑和之前不等价.

重排序是⼀个⽐较复杂的话题,涉及到CPU以及编译器的⼀些底层⼯作原理,此处不做过多讨论

解决之前的线程不安全问题

// 此处定义⼀个 int 类型的变量 
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
  Object locker = new Object();

  Thread t1 = new Thread(() -> {
    // 对 count 变量进⾏⾃增 5w 次 
    for (int i = 0; i < 50000; i++) {
        synchronized (locker) {
           count++;
        }
     }
  });
  Thread t2 = new Thread(() -> {
     // 对 count 变量进⾏⾃增 5w 次 
     for (int i = 0; i < 50000; i++) {
         synchronized (locker) {
            count++;
        }
     }
 });

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

 // 如果没有这俩 join, 肯定不⾏的. 线程还没⾃增完, 就开始打印了. 很可能打印出来的 
 //count 就是个 0 

 t1.join();
 t2.join();

 // 预期结果应该是 10w 
 System.out.println("count: " + count);
}

Logo

葡萄城是专业的软件开发技术和低代码平台提供商,聚焦软件开发技术,以“赋能开发者”为使命,致力于通过表格控件、低代码和BI等各类软件开发工具和服务

更多推荐