系列文章目录
初步了解多线程-CSDN博客
目录
系列文章目录
前言
一、线程安全
1. 线程安全问题
2. 问题原因分析
3. 问题解决办法
4. synchronized 的优势
1. 自动解锁
2. 是可重入锁
二、死锁
1. 一个线程一把锁
2. 两个线程两把锁
3. N 个线程 M 把锁
4. 死锁的必要条件
5. 死锁的解决思路
三、Java 标准库中的线程安全类
四、内存可见性引起的线程安全问题
1. 线程安全问题及原因
2. 解决方法
前言
本文摘要: 文章系统讲解了Java多线程中的线程安全问题及解决方案。主要内容包括:1)线程安全问题的产生原因,如多线程修改共享变量、操作非原子性等;2)使用synchronized关键字的加锁机制解决线程安全问题,分析其优势(自动解锁、可重入锁);3)死锁问题及其四种必要条件,提出通过破坏循环等待条件来避免死锁;4)Java标准库中线程安全与不安全类的对比;5)内存可见性问题及volatile关键字的解决方法。文章通过代码示例详细阐述了线程安全相关概念及实践方案。
一、线程安全
1. 线程安全问题
以下面代码为例:
public class ThreadDemo14 {private static int count = 0;public static void main(String[] args) {Thread t1 = new Thread(() -> {for(int i = 0; i < 50000; i++){count++;}});Thread t2 = new Thread(() -> {for(int i = 0; i < 50000; i++){count++;}});t1.start();t2.start();try {t1.join();t2.join();} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println("count = " + count);}
}
上述代码的运行结果并不是 10w,而是一个小于 10w 的数字;
自增操作在 CPU 上分为 3 步:
- load:将内存中的数字加载到寄存器;
- add():寄存器中的数值实现自增;
- save():将寄存器中的值保存到内存中;
假设 t1 线程在 t2 线程 save 之前,就执行了 load 操作,那么 t1 线程在 save 时,就会覆盖掉 t2 线程之前 save 的结果,导致 t2 之前的自增失效;同理 t2 也会覆盖掉 t1 save 的结果;两个线程出现互相覆盖的情况,就会让最终结果小于 10w;
2. 问题原因分析
出现线程安全问题的原因有以下几点:
1. 线程的调度是随机的,这是问题的根本原因;
2. 多个线程同时修改同一个变量;
3. 自增操作本质上是三个 CPU 指令构成的,指令穿插容易发生结果覆盖;
3. 问题解决办法
针对原因 1,线程的调度是随机的,这是操作系统内部实现的,不能进行干预;
针对原因 2,需要根据实际情况分析,但是不一定都能避免;
针对原因 3,可以通过加锁的方式,将这几个 CPU 指令打包成一个整体;
虽然在随机调度的过程中,仍然有可能执行一部分指令后将线程调度下 CPU,但是加锁之后,其它线程就会处于阻塞状态,即使线程被调度走,其它线程也不能进行插队,直到这个线程释放锁之后,其余线程才能尝试获取锁;、
注意:
加锁需要针对某个具体的锁对象进行加锁,加锁操作是需要基于锁对象的;
在 Java 中,任何一个对象都可以作为锁对象;
多个线程必须针对同一个锁对象加锁,才能产生锁竞争/锁冲突,才能解决线程安全问题;
如果针对的是不同的锁对象加锁,不会产生锁竞争/锁冲突,线程安全问题仍然存在;
Java 中加锁推荐使用 synchronized 关键字实现;
如下:
public class ThreadDemo15 {private static int count = 0;public static void main(String[] args) throws InterruptedException {Object locker = new Object();Thread t1 = new Thread(() -> {for(int i = 0; i < 50000; i++){synchronized(locker){count++;}}});Thread t2 = new Thread(() -> {for(int i = 0; i < 50000; i++){synchronized(locker){count++;}}});t1.start();t2.start();t1.join();t2.join();System.out.println("count = " + count);}
}
也可以在方法中,针对 this 进行加锁:
public class ThreadDemo16 {private static int count = 0;public static void main(String[] args) throws InterruptedException {ThreadDemo16 t = new ThreadDemo16();Thread t1 = new Thread(() -> {for(int i = 0; i < 50000; i++){t.add();}});Thread t2 = new Thread(() -> {for(int i = 0; i < 50000; i++){t.add();}});t1.start();t2.start();t1.join();t2.join();System.out.println("count = " + count);}public void add(){synchronized(this){count++;}}
}
针对 this 进行加锁,就等同于针对方法加锁:
synchronized public void add(){count++;}
注意:针对 this 加锁时,要判断不同线程中 this 表示的对象是否为同一个对象,同一个对象才能产生锁竞争/锁冲突,不同的对象不会产生;
也可以针对类对象进行加锁:
public void add(){synchronized (ThreadDemo16.class){count++;}}
可以在静态方法进行加锁:
public class ThreadDemo17 {private static int count = 0;public static void main(String[] args) throws InterruptedException {private static int count = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for(int i = 0; i < 50000; i++){add();}});Thread t2 = new Thread(() -> {for(int i = 0; i < 50000; i++){add();}});t1.start();t2.start();t1.join();t2.join();System.out.println("count = " + count);}synchronized public static void add(){count++;}
}
针对静态方法进行加锁,就相当于给类对象加锁:
public static void add(){synchronized(ThreadDemo17.class){count++;}}
4. synchronized 的优势
1. 自动解锁
synchronized 加锁,可以不考虑释放锁的问题,方法体中的代码执行完毕后,自动解锁;
如果是通过 lock() 加锁,unlock() 解锁,类似这种方式,就需要代码中考虑解锁的时机;
如果程序中间 break 了,或者抛出异常了,都需要把解锁考虑好,出现异常之前要把锁解了;
2. 是可重入锁
public class ThreadDemo18 {public static void main(String[] args) {Thread t = new Thread(() -> {Object locker = new Object();synchronized (locker){synchronized (locker){System.out.println("hello thread");}}});t.start();}
}
上述代码,仍然可以打印 “hello thread”,原因是 synchronized 是可重入锁;
t 线程已经获取了锁 locker,第二次再获取锁 locker 仍然可以获取到,而不会出现阻塞等待的问题,这样的热性就称为“可重入”;
实现可重入锁的原理:
实现可重入锁,需要在锁对象中加两个字段,一个记录持有锁的线程,另一个记录加锁的次数;
第一次加锁时,记录持有锁的线程,并将将加锁的次数置为 1;
后续再次或者多次加锁时,检测持有锁的线程是否为原来的线程,如果不是,尝试获取锁的线程就要阻塞等待;如果是原来的线程,就将计数器加 1;
释放锁时,要注意如果计数器不为 1,就将计数器减 1,并且不真的释放锁;
当计数器为 1,表示已经是最后一层锁,将计数器减 1,并释放锁,此时锁才真正被释放;
二、死锁
1. 一个线程一把锁
如果锁不是可重入锁,同一个线程先后对同一个对象两次加锁,就会产生死锁问题;
2. 两个线程两把锁
如果线程 t1 持有锁 A,线程 t2 持有锁 B,t1 线程尝试获取锁 B,同时 t2 线程尝试获取锁 A,此时两个线程都会进入阻塞等待,都在等待对方释放锁,就会出现死锁问题;
public class ThreadDemo19 {public static void main(String[] args) {Object locker1 = new Object();Object locker2 = new Object();Thread t1 = new Thread(() -> {synchronized(locker1){try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (locker2){System.out.println("线程 t1 获取到 locker2");}}});Thread t2 = new Thread(() -> {synchronized(locker2){try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (locker1){System.out.println("线程 t2 获取到 locker1");}}});t1.start();t2.start();}
}
3. N 个线程 M 把锁
假设有 5 个线程 t1, t2, t3, t4, t5,以及 5 把锁 locker1, locker2, locker3, locker4, locker5;
按照 t1, locker1, t2, locker2, t3, locker3, ..., t5, locker5 呈环形排列;
每个线程都必须获取到相邻的两把锁之后才能完成工作;
正常情况下,只要有一个线程先获取到相邻的两把锁,就能完成工作,之后释放锁,其余线程也都能完成工作;
特殊情况下,如果 t1, t2, t3, t4, t5 分别同时获取到了 locker5, locker1, loecker2, locker3, locker4,那么每个线程都无法完成工作,就会出现死锁的问题;
4. 死锁的必要条件
死锁有 4 个必要条件:
1. 获取锁的过程是互斥的,同一把锁只能被一个线程获取,其它线程想要尝试获取锁,会进入阻塞等待;
2. 锁无法抢占,一个线程拿到锁之后,必须要主动释放锁之后,其它线程才能获取;
3. 请求保持,线程拿到锁 A 之后,在持有锁 A 的前提下,再尝试获取锁 B;
4. 循环等待/环路等待,多个线程获取到不同的锁后,还需要再获取其余线程持有的锁,才能完成工作,多个线程获取锁的逻辑上形成一个环路;
5. 死锁的解决思路
死锁的解决思路要从死锁的必要条件入手,只要可以破坏死锁的必要条件,就能避免死锁;
条件 1 和条件 2 都是锁的基本特性,是不能破坏的;
条件 3 有时候可以在代码层面避免,有时候必须要同时持有多把锁才能完成工作,是否可以破坏取决于具体的业务逻辑;
条件 4 是最容易破坏的,只要给获取锁的顺序制定规则,就能有效避免循环等待;比如,每个线程都要优先获取编号小的锁,那么 t1 就不会先获取 locker5,而是会和 t2 竞争 locker1,不管是谁先获取到了 locker1,另外一个线程都会进入阻塞等待,而不会去获取其它的锁,这样就避免了环路,也就解决了死锁问题;
三、Java 标准库中的线程安全类
线程不安全的类:
- ArrayList
- LinkedList
- HashMap
- TreeMap
- HashSet
- TreeSet
- StringBuilder
当有多个线程,同时修改上述对象,就容易出现线程安全问题;
线程安全的类:
- Vecter(不推荐使用)
- HashTable(不推荐使用)
- ConcurrentHashMap
- StringBuffer
- String
这几个类都自带了锁,当多个线程同时修改,出现线程安全问题的可能性较小;
这里需要注意 String,String 没有带锁,但是仍然是安全的;因为 String 中的字符数组或者 byte 数组是被 private 修饰的,是无法被获取,无法被修改;
四、内存可见性引起的线程安全问题
1. 线程安全问题及原因
import java.util.Scanner;public class ThreadDemo20 {private static int isQuit = 0;public static void main(String[] args) {Thread t1 = new Thread(() -> {while(isQuit == 0){}System.out.println("hello t1");});Thread t2 = new Thread(() -> {Scanner in = new Scanner(System.in);System.out.println("请输入 isQuit 的值:");isQuit = in.nextInt();});t1.start();t2.start();}
}
上述代码,即使输入了一个不为 0 的数,仍然不会打印 “hello t1”;
原因:
当用户输入完毕后,t1 线程中循环已经循环很多次了;
在 CPU 中,判断 isQuit 是否为 0 分为两个步骤,一是需要将内存中 isQuit 的值,加载到 CPU 寄存器中,另外一个是判断寄存器中的值是否为 0;
判断寄存器中的值是否为 0 是非常快的,但是将内存中 isQuit 的值加载到 CPU 寄存器中是很慢的;
编译器经过多次循环,认为 isQuit 的值不会发生改变,并且将内存中的值加载到寄存器中开销很大,因此编译器会进行优化,经过多次循环后,不再读内存中的值,而是使用寄存器中的值进行比较;
因此即使 t2 改变了 isQuit 的值,t1 也不会读取,因此会死循环;
上述问题就称为内存可见性问题,因为内存不可见,导致发生线程安全问题;
2. 解决方法
解决问题的思路是使 CPU 持续加载 isQuit 的内存,确保 isQuit 的值发生改变时,可以即时读到;
保持内存可见需要用到关键字 volatile,使用 volatile 关键字修饰 isQuit 即可;
private volatile static int isQuit = 0;