目录
一、体会线程安全问题
二、线程安全的概念
三、线程安全问题的原因
四、解决线程安全问题的方法
4.1 synchronized 关键字
一、体会线程安全问题
当我们编写一个多线程程序,要求两个线程对同一个变量(共享变量)进行修改,得到的结果是否与预期一致?
创建两个线程,分别对共享变量(count)进行自增5万次操作,最后输出的结果理论上应为10万,但是实际上输出的结果是一个小于10万且不确定的数。
读者可以自行实现一下该多线程程序,运行后看看结果是否符合预期。
public class Demo14_threadSafety { private static int count = 0; public static void main1(String[] args) { Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { count++; } System.out.println("t1-结束"); }); Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { count++; } System.out.println("t2-结束"); }); t1.start(); t2.start(); // 理论上输出的结果应是100000,实际输出的结果是0 // 原因是主线程 main 运行太快了,当 t1 和 t2 线程还在计算时,主线程已经打印结果、运行完毕了 System.out.println(count); } // 让主线程等待 t1 和 t2 线程,等到它们两个都执行完成再打印,故使用 join 方法 public static void main2(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { count++; } System.out.println("t1-结束"); }); Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { count++; } System.out.println("t2-结束"); }); t1.start(); t2.start(); // 在主线程中,通过 t1 和 t2 对象调用 join 方法 // 表示让主线程 main 等待 t1 线程和 t2 线程 t1.join(); t2.join(); // 当两个线程都执行完毕后主线程再继续执行打印操作 System.out.println(count); // 实际输出的结果小于100000,仍不符合预期 } }二、线程安全的概念
通过上面的一个例子,想必读者已经体会到线程安全问题了吧?那究竟什么是线程安全问题呢?其原因是什么?如何解决线程安全问题呢?
不要急,且听小编慢慢道来~
如果在多线程环境下运行的程序其结果符合预期或与在单线程环境下运行的结果一致,就说这个程序是线程安全的,否则是线程不安全的。
上面的例子在单线程环境下运行——比如来两个循环对共享变量进行自增操作,那么结果是符合预期的;但是在多线程环境下运行就不符合预期。因此该程序是线程不安全的,也可以说该程序存在线程安全问题。
三、线程安全问题的原因
究竟是哪里出问题导致程序出现线程安全问题呢?
究其根本,罪魁祸首是操作系统的线程调度有随机性/抢占式执行。
由于操作系统的线程调度是有随机性的,这就会存在这种情况:某一个线程还没执行完呢,就调度到其他线程去执行了,从而导致数据不正确。
当然了,一个巴掌拍不响,还有以下三个导致线程不安全的原因:
- 原子性:指 Java 语句,一条 Java 语句可能对应不止一条指令,若对应一条指令,就是原子的。
- 可见性:一个线程对主内存(共享变量)的修改,可以及时被其他线程看到。
- 有序性:一个线程观察其他线程中指令的执行顺序,由于 JVM 对指令进行了重排序,观察到的顺序一般比较杂乱。因其原理与 CPU 及编译器的底层原理有关,暂不讨论。
之前的例子就是由于原子性没有得到保障而出现线程安全问题:
public static void main2(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { count++; } System.out.println("t1-结束"); }); Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { count++; } System.out.println("t2-结束"); }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); }1. “count ++” 这条语句对应多条指令:读取数据、计算结果、存储数据。
2. t1 线程和 t2 线程分别执行一次“count++”语句,期望的结果是“count = 2”,其过程如下:
初始情况:
当线程执行“count ++”时,总共分三个步骤:load、update、save,由于线程调度的随机性/抢占式执行,可能会出现以下情况(可能出现的情况有很多种,这里只是其中一种):
这时候 t1 正在执行“count ++”这条语句,执行了“load 和 update”指令后,t1 的工作内存(寄存器)存着更新后的值,但是还未被写回内存中:
接着调度到 t2 线程并开始执行“count ++”语句,并且语句中包含的三条指令都执行。此时由于 t1 更新后的 count 的值还未写回内存,因此 t2 执行 load 操作所获取到的 count 仍是 0。接着 t2 执行 update 和 save 指令:
当 t2 执行完成,内存的 count 已被修改为 1 。此时调度回 t1 线程并继续执行 save 指令,但是 t1 线程寄存器中 count 的值也是 1 ,此时写回内存更新后 count 的值依然是 1 。
结果 count = 1,与预期的 count = 2 不符,因此存在线程安全问题,其原因是操作系统的随机线程调度和 count 语句存在非原子性。
四、解决线程安全问题的方法
从上面的例子我们知道,当一条语句的指令被拆开来执行的话是存在线程安全问题的,但是,当我们将“count ++”这条语句的三个指令都放在一起执行怎么样?
当线程调度的情况如下:
此时 t1 线程开始执行“count ++”语句的 load、update 和 save 指令。内存中的 count 为 0,t1 读取到内存中的 count 之后更新至 1 并写回内存中。当 t1 执行完成后内存的 count 由 0 更新至 1:
接着调度至 t2 线程,开始执行“count ++”语句的 load、update 和 save 指令。经过更新后内存中的 count 为 1,此时 t2 读取 count 并更新为 2,然后写回内存中。当 t2 执行完成,内存中的 count 就更新成 2 了:
可以发现,结果与预期相符!说明这个方法可行。
可以将操作顺序改成先让 t1 线程完成“count ++”操作,再让 t2 线程完成该操作——即串行执行。
现在我们对之前的例子进行优化:
// 可以试着让 t1 线程先执行完后,再让 t2 线程执行,改成串行执行 public static void main3(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { count++; } System.out.println("t1-结束"); }); Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { count++; } System.out.println("t2-结束"); }); t1.start(); t1.join(); t2.start(); t2.join(); System.out.println(count); }刚刚是让一个线程一次性执行“count ++”这条语句的三个指令,也就是说,我们是通过这样操作将原本是非原子的三条指令打包成了一个原子指令(即执行过程中不可被打断——调度走)。这样就有效的解决了线程安全问题。
而上述的操作,其实就是 Java 中的加锁操作。
当一个线程执行一个非原子的语句时,通过加锁操作可以防止在执行过程中被调度走或被其他线程打断,若其他线程想要执行该语句,则要进入阻塞等待的状态,当线程执行完毕并将锁释放,操作系统这时唤醒等待中的线程,才可以执行该语句。
就相当于上厕所:当厕所内没有人时(没有线程加锁),就可以使用;当厕所内有人时(已经有线程加锁了),那么就必须等里面的人出来后才能使用。
注意:
- 前一个线程解锁之后,并不是后一个线程立刻获取到锁。而是需要靠操作系统唤醒阻塞等待中的线程的。
- 若 t1、t2 和 t3 三个线程竞争同一个锁,当 t1 线程获取到锁,t2 线程再尝试获取锁,接着 t3 线程尝试获取锁,此时 t2 和 t3 线程都因获取锁失败而处于阻塞等待状态。当 t1 线程释放锁之后,t2 线程并不会因为先进入阻塞状态在被唤醒后比 t3 先拿到锁,而是和 t3 进行公平竞争。(不遵循先来后到原则)
4.1 synchronized 关键字
加锁 / 解锁这些操作本身是在操作系统所提供的 API 中的,很多编程语言对其进行了封装,Java 中使用 synchronized 关键字来进行加锁 / 解锁操作,其底层是使用操作系统的mutex lock来实现的。Java 中的任何一个对象都可以用作“锁”。
synchronized (锁对象){——> 进入代码块,相当于加锁操作
// 一些需要保护的逻辑
}——> 出了代码块,相当于解锁操作
当多个线程针对同一个锁对象竞争的时候,加锁操作才有意义。
对之前的例子进行加锁操作:
public class Demo15_synchronized { private static int count = 0; public static void main1(String[] args) throws InterruptedException { Object locker = new Object(); Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { synchronized (locker) { count++; } } System.out.println("t1-结束"); }); Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { synchronized (locker) { count++; } } System.out.println("t2-结束"); }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); } }synchronized 关键字用来修饰普通方法时,相当于给this加锁;
synchronized 关键字用来修饰静态方法时,相当于给类对象加锁。
于是可以使用另一种写法:
// 写法二: // 将 count++ 所包含的三个操作封装成一个 add 方法 // 使用 synchronized 修饰 add 方法 class Counter { private int count = 0; synchronized public void add () { // synchronized 修饰普通方法相当于给 this 加锁 count++; } // 相当于: // public void add () { // synchronized (this) { // count++; // } // } public int get () { return count; } } public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { counter.add(); } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { counter.add(); } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(counter.get()); }这样一来,就成功解决了多线程程序中的线程安全问题。
今天暂且到这吧~
完