1. 前言
在当今的计算机世界中,多线程编程已经成为提高应用程序性能和效率的关键技术之一。然而,伴随多线程技术而来的一个问题就是线程安全。在多线程环境下,多个线程可能同时访问和修改共享数据,这时就可能出现数据的不一致性问题,也就是线程安全问题。今天,我将在这篇博客中详细探讨线程安全性的重要性,以及如何确保线程安全。
2. 线程安全的概念
线程安全是多线程编程中的重要概念,它保证了多个线程可以同时执行而不会导致数据损坏或程序错误。当一个程序在多线程环境下运行时,如果有多个线程同时访问共享数据,就可能发生数据竞争,即两个或多个线程对同一数据进行修改,导致数据的不一致。
这是一个线程不安全的例子:
public class Demo4 { private static int n; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for(int i = 0; i < 10000; i++) { n++; } }); Thread t2 = new Thread(() -> { for(int i = 0; i < 10000; i++) { n++; } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(n); } }
我们使用了两个线程分别对变量进行了一万次的自增,但是结果却不是两万,这就是线程不安全的典型例子。
3. 造成线程不安全的原因
我们来分析一下上面例子中造成线程不安全的原因。首先我们需要知道 ++ 的过程:
- 从内存中获得变量存储的信息
- 对变量进行修改
- 将修改后的变量再存入到内存当中
但是由于线程的抢占式执行,会导致以上的三个步骤出现的顺序出现变化。
根据这个例子,我们可以总结出造成线程不安全的原因:
- 操作系统对于线程的调度是随机的(线程的抢占式执行)
- 多个线程同时修改同一个变量
- 修改操作不是原子性的(跟上面的n++分为三个过程一样,修改不具有原子性)
- 内存可见性
- 指令重排序
4. 如何解决出现的线程不安全问题
针对上面的出现的线程不安全问题,我们可以使用 synchronized 对代码块或者方法进行加锁,使得线程执行由 并发执行 -> 串行执行。
4.1 如何使用 synchronized 加锁?
(1)对指定代码块进行加锁
synchronized 权限(public/private) 返回值类型(void、int……) 方法名(参数) { 方法体 …… }
(2)对指定代码块进行加锁
synchronied (锁对象) { 代码块 …… }
当加锁之后,进入代码块的时候,会对锁对象进行加锁,出了该代码块之后则会对该锁对象解锁。
为什么要指定锁对象?
其实锁对象是谁无所谓,重要的是当两个线程对同一个对象进行加锁的时候,就会出现“锁竞争”/“锁冲突”的现象,而一旦出现“锁竞争”的情况时,就只有一个线程能拿到这个锁,拿到这个锁的线程就可以继续执行下面的代码,而没有拿到锁的线程则会进入阻塞等待的状态,直到前面拿到锁的这个线程释放锁之后,这些处于阻塞等待状态的线程才会继续竞争锁,剩下的线程才有机会拿到锁。
使用加锁这种行为来解决线程安全问题就可以使得线程由 并发执行->串行执行 这样就不会出现穿插执行的现象了。
4.2 解决上面自增问题导致的线程安全问题
(1)对指定代码块进行加锁
因为要显示出两种加锁方式,所以对上面的代码稍做了修改,将自增操作封装成一个类。
class Counter6 { //创建一个锁对象 private static Object locker = new Object(); public int count = 0; public void increase() { synchronized (locker) { count++; } } } public class Demo4 { public static void main(String[] args) throws InterruptedException { Counter6 counter6 = new Counter6(); Thread t1 = new Thread(() -> { for(int i = 0; i < 10000; i++) { counter6.increase(); } }); Thread t2 = new Thread(() -> { for(int i = 0; i < 10000; i++) { counter6.increase(); } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(counter6.count); } }
(2)对方法进行加锁
class Counter6 { public int count = 0; //对方法进行加锁操作 synchronized public void increase() { count++; } } public class Demo4 { public static void main(String[] args) throws InterruptedException { Counter6 counter6 = new Counter6(); Thread t1 = new Thread(() -> { for(int i = 0; i < 10000; i++) { counter6.increase(); } }); Thread t2 = new Thread(() -> { for(int i = 0; i < 10000; i++) { counter6.increase(); } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(counter6.count); } }
对实例方法进行封装也就相当于——对该类的实例对象进行加锁。
class Counter6 { public int count; public void increase() { //对类的实例对象进行加锁 synchronized (this) { count++; } } }
方法又分为实例方法和静态方法,如果对静态方法加锁的话,也就相当于对该类的类对象进行加锁。
class Counter6 { public static int count; //对静态方法进行加锁 synchronized public static void increase() { count++; } }
class Counter6 { public static int count; public static void increase() { //对类对象进行加锁 synchronized (Counter6.class) { count++; } } }
什么是类对象?
一个类只有一个类对象
类对象中包含:
- 类的属性有哪些,都是啥名字,啥类型,啥权限
- 类的方法有哪些,都是啥名字,啥类型,啥权限
- 类本身继承自哪个类,实现了哪些接口
- ……
5. synchronized 的特性
- 互斥性
- 可重入性
5.1 互斥性
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待。
进入到 synchronized 修饰的代码块,相当于加锁
退出 synchronized 修饰的代码块,相当于解锁
Java的一个对象,对应的内存空间中,除了我们自己定义的一些属性之外,还会有一些自带的属性,这些自带的属性就存放在我们的对象头当中。(synchronized 用的锁也是存放在对象头当中的)
当线程进行加锁的时候,首先会先看对象头当中的锁对象是否已经被其他线程加锁,如果已经被其他线程加锁了,那么该线程就进入阻塞等待状态;如果该锁对象没有被其他线程加锁,那么该线程则会对该锁对象进行加锁。
5.2 可重入性
什么叫做可重入性呢?我们先来看一个例子:
public class Demo5 { private static Object locker = new Object(); public static void main(String[] args) { Thread t1 = new Thread(() -> { synchronized (locker) { synchronized (locker) { System.out.println(666); } } }); t1.start(); } }
这里线程 t1 先对 locker 对象进行加锁,但是呢?t1 对 locker 加锁之后又对 locker 进行了加锁,一般来说这个加锁会成功吗?不会的,为什么呢?因为第二次对 locker 进行加锁需要第一次加锁之后解锁才能再次进行加锁,第一次加锁之后要想解锁则必须要执行完被 synchronized 修饰代码块,但是因为第二次加锁会进入阻塞状态也就导致了第一次加锁之后不能够解锁成功,最终就会出现 死锁 的现象,出现了死锁的现象会导致线程一直处于阻塞状态,是比较严重的线程安全问题。
但是我们 Java 的synchronized 锁具有可重入性,也就是说同一个线程同时对同一个锁对象进行加锁不会造成死锁的现象。
public class Demo5 { private static Object locker = new Object(); public static void main(String[] args) { Thread t1 = new Thread(() -> { synchronized (locker) { synchronized (locker) { System.out.println(666); } } }); t1.start(); } }
当同一个线程执行完一个被 synchronized 修饰的代码块的时候,并不会立刻将该锁对象进行解锁,而是会在该线程最后执行完被 synchronized 修饰的代码块的时候才会将锁对象进行解锁,这种方式的实现就需要依赖记住对这个锁对象加锁的线程以及记录该线程对这个锁对象进行了多少次加锁的计数器。
6. 死锁
如果 Java 的 synchronized 锁没有可重入性的话,那么同一个线程同时对一个对象加锁的话就会形成死锁。我们要想避免在 Java 多线程的情况下出现死锁的情况,就需要知道在什么情况下会造成死锁。
6.1 什么情况下会造成死锁
- 两个线程两把锁
- N 个线程 M 把锁
6.1.1 两个线程两把锁
线程 t1 已经获取到了 A 锁,线程 t2 已经获取到了 B 锁,但是 t1 线程还想要获取到 B 锁,同时线程 t2 也想获取到 A 锁,当出现这种情况的时候,就会发生死锁的情况。
public class Demo6 { private static Object locker1 = new Object(); private static Object locker2 = new Object(); public static void main(String[] args) { Thread t1 = new Thread(() -> { synchronized (locker1) { try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (locker2) { System.out.println("t1 线程成功获取到锁"); } } }); Thread t2 = new Thread(() -> { synchronized (locker2) { try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (locker1) { System.out.println("t2 线程成功获取到锁"); } } }); t1.start(); t2.start(); } }
通过 jconsle 可以观察到两个线程处于 BLOCK 状态。
当线程 t1 获取到 A 锁的时候,继续想获取到 B 锁,但是 B 锁已经被线程 t2 获取了,所以线程 t1 就会进入阻塞等待状态,等待线程 t2 释放 B 锁,但是呢,线程 t2 在获取到 B 锁之后,还想继续获取到 A 锁,但是 A 锁已经被线程 t1 获取了,所以线程 t2 也会进入阻塞等待状态,最终导致两个线程进入死锁状态。
6.1.2 N 个线程 M 把锁
当提到 N 个线程 M 把锁出现死锁的问题的时候就不免提起哲学家就餐问题了,什么是哲学家就餐问题呢?就是有 N 个哲学家就餐,但是餐桌上只有少于 2*N 根筷子,并且哲学家们只能使用自己左右两旁的筷子,还有更重要的就是哲学家非常固执,一旦拿到了筷子,除了吃到了东西,否则就不会放下筷子,这就会导致其中的哲学家一直吃不到东西。
这些哲学家们会做两件事:1.吃餐桌上的东西;2.停下来思考人生。并且这些哲学家们什么时候吃东西是不确定的,那么可能就会出现两个相邻的哲学家同时想吃东西,但是因为每个哲学家只能使用左右两旁的筷子就餐,而且这两个哲学家谁都不让谁,这样就导致了两个哲学家一直吃不到东西。更严重的情况就是:当五个哲学家同时想要进餐的时候,并且同时只拿到了左手边的一根筷子,那么这就会导致五个哲学家谁都吃不到东西。
那么应该如何解决哲学家就餐时出现的一直吃不到东西的情况呢?
我们可以规定:如果左手边的筷子没有使用则哲学家先拿左边的筷子,如果左手边的筷子被使用了,则该哲学家需要等待左手边的哲学家放下筷子之后再拿起左手的筷子;当拿起左手的筷子时,看右手边的筷子是否有人使用,如果没有,则拿起右手边的筷子进行进餐,如果右手边的筷子被人使用的话,则需要等待右手边的哲学家放下筷子之后再拿起右手边的筷子进行进餐。这样就可以解决 N 个哲学家就餐的问题了。
6.2 造成死锁的必要条件
互斥使用(锁的基本特性)。当一个线程持有一把锁的时候,另一个线程也想获取到该锁,那么这个后面这个线程就会进入阻塞状态。
不可抢占(锁的基本特性)。当锁已经被线程 t1 持有时,线程 t2 只能等 t1 线程主动释放锁,而不能强行抢过来。
请求保持(代码结构)。一个线程尝试获取多把锁(先拿到锁 1 之后,还想要获取锁 2 ,在获取的过程中锁 1 不会被释放。(吃着碗里的,看着锅里的)
循环等待/环路等待(代码结构)。等待的依赖关系形成了环路。
6.3 如何避免出现死锁
要想形成死锁,则需要满足上面的四条必要条件,但是因为第 1、2 条是锁的基本特性,也就是说,当满足 3、4 条件的时候就会形成死锁,那么要想不形成死锁就需要使得不满足 3、4 条件中的任意一条或两条条件。
要想打破第三条必要条件,可以更改代码结构,避免出现“锁嵌套”的情况,但是这个方案可能有时候行不通,因为某些特殊情况下需要使用到“锁嵌套”的情况。那么最好的避免死锁的情况就是破坏第 4 条必要条件,如何避免第 4 个条件的成立呢?我们可以约定加锁的顺序,将锁进行编号,当加多把锁的时候,先加编号小的锁,后加编号大的锁,并且要保证所有的线程都要遵守这个规则。
public class Demo6 { private static Object locker1 = new Object(); private static Object locker2 = new Object(); public static void main(String[] args) { Thread t1 = new Thread(() -> { //规定加锁顺序由小到大 synchronized (locker1) { try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (locker2) { System.out.println("t1 线程成功获取到锁"); } } }); Thread t2 = new Thread(() -> { //规定加锁顺序由小到大 synchronized (locker1) { try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (locker2) { System.out.println("t2 线程成功获取到锁"); } } }); t1.start(); t2.start(); } }
7. volatile 关键字
volatile 关键字修饰的变量,能够保证“内存的可见性”。
先来看一段代码:
import java.util.Scanner; public class Demo7 { private static int isQuit = 0; public static void main(String[] args) { Thread t1 = new Thread(() -> { while(isQuit == 0) { ; } System.out.println("线程 t1 结束"); }); t1.start(); Thread t2 = new Thread(() -> { Scanner scanner = new Scanner(System.in); System.out.print("请输入isQuit的值:"); isQuit = scanner.nextInt(); }); t2.start(); } }
线程 t1 会读取 isQuit 的值,当我们启动线程 t1 之后,再启动线程 t2 来修改 isQuit 的值,以达到停止线程 t1 的作用,但是当我们运行代码的时候,我们会发现:
线程 t1 并没有结束,而且处于 RUNNABLE 执行的状态。我们不是使用线程 t2 修改了 isQuit 的值吗?为什么线程 t1 没有停止呢?
我们都知道,变量是存储在内存当中的,当我们需要使用这个变量的时候,计算机会将变量从内存中读取到 cpu的寄存器当中,然后再从 cpu的寄存器 将变量的值读取到我们的工作代码当中,但是从内存中读取变量的速度比从寄存器中读取变量速度要慢上几千甚至几万倍,所以当需要多次读取变量值的时候,我们的编译器会对其做出优化:只从内存中读取一次变量的值到cpu寄存器当中,剩下的 n 次则直接从寄存器当中读取变量的值。所以当我们先启动线程 t1 的时候,由于计算机的速度很快,线程 t1 里面的循环可能已经执行了几万甚至几千万次,这时,编译器就会对其做出优化,当我们启动线程 t2 来修改 isQuit 的值的时候,isQuit 修改后的结果会更新到内存中,但是由于编译器已经做出了优化,只从寄存器中读取变量的值,所以就导致线程 t2 做出的 isQuit 的修改并没有实际的作用,最终导致线程 t1 一直执行,而不会结束。
要想解决因编译器的优化问题导致的”内存不可见“问题,就需要使用到 volatile 关键字 来停止编译器的优化行为,从而达到内存的可见性。
import java.util.Scanner; public class Demo7 { private volatile static int isQuit = 0; public static void main(String[] args) { Thread t1 = new Thread(() -> { while(isQuit == 0) { ; } System.out.println("线程 t1 结束"); }); t1.start(); Thread t2 = new Thread(() -> { Scanner scanner = new Scanner(System.in); System.out.print("请输入isQuit的值:"); isQuit = scanner.nextInt(); }); t2.start(); } }
==volatile 虽然能够保证内存的可见性,但却不能保证原子性。==所以在进行其他不具有原子性的操作时,不能保证线程的安全性。
8. wait 和 notify 关键字
由于线程之间是抢占式执行的,所以线程之间运行的顺序是不可预测的,但是在实际生活中我们希望合理的协调多个线程之间的执行先后顺序,这里就需要用到 wait 和 notify 关键字来控制线程的执行顺序。
wait 和 notify 都是 Object 对象的方法,也就是说任何一个对象都可以使用它。
wait 在执行的时候会做三件事:
- 释放当前的锁
- 使该线程进入阻塞状态
- 当线程被唤醒的时候,重新获取到锁
也就是说:wait 方法需要在加锁的代码中使用。
public class Demo1 { public static void main(String[] args) { Object object = new Object(); Thread t = new Thread(() -> { synchronized (object) { System.out.println("wiat 之前"); try { object.wait(); } catch (InterruptedException e) { throw new RuntimeException(e); } System.out.println("wait 之后"); } }); t.start(); } }
当加锁之后,使用 wait 方法可以使得释放当前的锁,使当前线程进入等待状态,并且这个等待也不是一直等待,我们可以在wait中传入参数作为等待的最大时间,那么又将如何唤醒当前线程呢?唤醒处于 wait 状态的线程需要使用到 notify 方法。
public class Demo1 { public static void main(String[] args) { Object object = new Object(); Thread t1 = new Thread(() -> { synchronized (object) { System.out.println("wiat 之前"); try { object.wait(); } catch (InterruptedException e) { throw new RuntimeException(e); } System.out.println("wait 之后"); } }); Thread t2 = new Thread(() -> { synchronized (object) { System.out.println("notify 之前"); object.notify(); System.out.println("notify 之后"); } }); t1.start(); t2.start(); } }
使用 wait 和 notify 可以防止发生线程饿死的现象。什么是线程饿死。
线程饿死(ThreadStarvation)是指在多线程环境中,某个线程长时间无法获得所需的资源或被调度执行的情况,从而导致该线程无法继续正常运行的现象。
产生线程饿死的主要原因是资源竞争或调度策略不合理。当多个线程同时竞争有限的资源时,如果某个线程无法获得所需的资源,它就会一直等待。如果这种情况持续发生,可能会导致该线程一直处于等待状态,无法得到执行机会,从而产生线程饿死。
线程饿死可能导致系统性能下降或系统崩溃。如果一个线程饿死,它将无法完成其工作,并且可能会持续占用系统资源,进而影响其他线程的正常运行。如果过多的线程饿死,系统资源消耗过大,可能导致系统崩溃。
避免线程饿死的方法包括合理设计并发控制机制、合理分配系统资源、优化调度策略等。合理的并发控制机制可以避免线程对共享资源的长时间竞争,例如使用锁、信号量等机制对共享资源进行同步访问。合理分配系统资源可以避免资源分配不均导致某个线程长时间无法获得所需资源的情况。优化调度策略可以确保所有线程都能够得到公平的执行机会,避免某个线程被其他线程长时间抢占执行的情况。
加入有一群人在银行 ATM 机上需要等待办理业务,这时先进去了一位老铁,他需要要办理取钱业务,但是呢,正好 ATM 中没有钱了,所以他就需要从 ATM 机中出来等待,但是呢,他刚出来就想再次进去看看有钱了没有,所以他就需要再次与其他老铁进行竞争,因为这位老铁是刚从 ATM 机出来的,他距离 ATM 机的距离最近,所以他可以再次进去,进去发现还是没钱,然后再出来、再进去,一直重复这样的操作,这样就会导致其他办理非取钱操作的老铁一直不能够进入 ATM 机进行操作,这就会导致其他线程饿死,这就是线程饿死的现象。
我们的 wait 和 notify 则可以让这位取钱的老铁出来 ATM 机之后,进入等待,不与其他的老铁竞争这个 ATM 机了,这样就不会导致出现线程饿死的现象了。
notifyAll() 可以唤醒所有处于等待状态的线程,但是这样也会使得线程不安全,还是建议使用 notify() 一个个唤醒线程。