目录
线程安全
概念
用一段代码感受线程安全
线程安全问题的原因
修改上述代码,使其线程安全
synchronized
synchronized使用方法
锁对象的规则
synchronized用法,代码展示
monitor lock
sychronized的特性
java标准库中的线程安全类
死锁
死锁的常见原因
多个线程多把锁,死锁的必要条件
多个线程多把锁死锁的解决方案
volatile
wait 和 notify
wait和notify概念
wait进行阻塞
wait方法的操作步骤
wait 和 sleep
练习题
多线程是抢占式执行的,执行具有随机性
如果没有多线程,代码的执行顺序是固定的,代码顺序固定,执行的结果也是确定的
有了多线程,由于多线程是抢占式执行的,代码顺序会出现很多变数
就需要保证在无数种线程调度的顺序情况下,代码的执行结果都是正确的
只要有一种情况不正确,代码执行结果不正确,就认为有Bug,线程不安全
这里我们先写一个有线程安全问题的代码,先定义1个Counter类,类里面有count这个成员变量,有一个add方法,可以对count进行自增操作
使用俩个线程,俩个线程分别针对count 来调用5W次add方法
最后输出count的值(预期结果10w)
class Counter{public int count;public void add(){count++;}
}
public class ThreadDemo12 {public static void main(String[] args) {Counter counter = new Counter();//使用俩个线程,俩个线程分别针对count 来调用5W次add方法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();try {t1.join();t2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println(counter.count);}
}
为什么会出现这种bug?
count++操作,本质上分为3步
1.先把内存中的值读到cpu寄存器中 load
2.把cpu寄存器中的值进行 +1 运算 add
3.把寄存器中的值,同步到内存中 save
如果俩个线程并发执行count++,此时就相当于俩组或者多组load add save进行执行
此时不同的调度顺序,可能就会发生结果上的差异
由于线程调度的随机性,调度的顺序充满可能性,有无数种可能
这种情况就会出现线程安全问题了
这和脏读有点类似,其实就是事物 读 未提交,是一样的
相当于t1 读到的是一个 t2还没来得及提交的脏数据
于是就出现了脏读问题
当前这个代码是否不出线程安全问题,结果真好是10w呢?
也是有可能的,比如出现的都是以下俩种情况
这个代码结果一定会大于5w吗?
不一定,假设俩个线程的调度出现,自增俩次,只增1次的效果,或者t1自增1次,t2自增了多次,最终结果还是增1
1.根本原因:
抢占式执行,随机调度
2.代码结构:
多个线程同时修改同一个变量
一个线程,修改一个变量没事
多个线程读取同一个变量没事
多个线程修改多个不同的变量也没事
因此我们可以通过调整代码结构来规避线程安全问题
但是通常代码结构是源于需求的,不一定能改
3.原子性
如果修改操作是原子的,不容易发生线程安全问题
但如果修改操作不是原子的,就很可能发生线程安全问他
什么是原子性?
原子在计算机中表示为,不可拆分的基本单位
count++,可以拆分load add save 三个指令
单个指令就是原子的 例如上面的load
count++是三个指令,所以这个操作不是原子的
4.内存可见性(编译器优化出bug)
如果是一个线程对一个变量进行读操作,另一个线程对这个变量进行修改操作
此时读的值,不一定是修改后的值,读取变量的线程,没有感知到变量的变化
本质:
java程序里,有一个共用的内存,每个线程还有自己的cpu寄存器/缓存(catche)
t1线程进行读取的时候,只读取了cpu寄存器/catche里的值
t2线程进行修改的时候,先修改自己的cpu寄存器/catche里的值,再把cpu寄存器/catche里的值同步到内存中
但由于编译器优化,导致t1 没有重新的从主内存同步数据到cpu寄存器/catche中,读到的结果就是修改之前的结果
5.指令重排序(本质是编译器优化出bug了)
编译器觉得我写的代码不太好,就自作主张把代码调整了
保持逻辑不变的情况下,调整了代码的执行顺序,从而加快程序的执行效率
以上只是五个典型的原因,并不是全部,具体问题具体分析
原则:多线程运行代码,不出bug就是线程安全的
上述代码是一个典型的原子性问题,
我们可以通过 加锁 操作,把不是原子的转换成"原子的"
class Counter{public int count;public synchronized void add(){count++;}
}
public class ThreadDemo12 {public static void main(String[] args) {Counter counter = new Counter();//使用俩个线程,俩个线程分别针对count 来调用5W次add方法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();try {t1.join();t2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println(counter.count);}
}
我们上个代码出现线程安全的原因是
由于多个线程一起调用add方法,进行count++操作,可能就会导致俩个线程同时执行count++操作,t1线程count++操作还没执行完,还没把结果保存到内存中,t2线程也开始执行count++操作,就会出现调用了2次add,count只增1等等情况
现在我们通过对add方法进行加锁,每次有线程调用add方法,就会要对这个Counter对象进行加锁,
假如线程1调用add方法对Counter这个对象进行加锁,同时线程2想要调用add方法,也要对Counter对象进行加锁,由于一个对象只能被一个线程加锁,所以线程2只能阻塞等待,等待线程1把add方法执行完毕,线程1主动释放锁,这时线程2才能对Counter加锁,执行add方法
这样就保证了,add这个方法,每次最多只有一个线程在执行,也就是这里的原子性
简单来说,就是这里给add加锁的目的就是,保证了每次做多只有一个线程去执行add方法
操作系统里面的锁具有"不可剥夺"的特性,一旦一个线程获取到锁,除非主动释放,否则其它线程无法强占
一旦加锁之后,代码的执行速度是大大折扣的
虽然加锁之后,代码的执行速度慢了,但化生比单线程要快
只是在执行add这个方法的时候,串行执行了,除了add方法,for循环也是可以并发执行的
一个任务中,一部分可以并发,一部分串行,任然比所有代码都串行的执行速度快
1.修饰方法
(1)修饰普通方法 修饰普通方法,锁对象就是this
(2)修饰静态方法 修饰静态方法,锁对象就是类方法(刚刚代码的Counter)
2.修饰代码块 修饰代码块,手动指定锁对象
如果俩个线程针对同一个线程同一个对象加锁,就会出现锁竞争/锁冲突
一个线程先获取到锁(先到先得),另一个阻塞等待
等待到上一个线程解锁,才能获取锁成功
如果俩个线程针对不同对象加锁,此时不会发生锁竞争/锁冲突
这俩线程都能获取到各自的锁,不会再阻塞等待了
俩个线程,针对同一个对象,一个线程加锁,一个线程不加锁,此时也没有锁竞争
比如一个线程调用add方法,同时另一个线程调用add2方法,此时不会发生阻塞等待
jvm给synchronized起了个名字,因此代码中有时候会出异常会有这个说法
(1)互斥
synchronized会起到互斥的效果,某个线程执行到某个对象的 synchronized时,其它线程如果也执行到同一个对象 sychronized就会阻塞等待
进入synchronized修饰的代码块,相当于加锁
退出synchronized修饰的代码块,相当于解锁
(2)可重入
一个线程针对同一个对象,连续加俩次锁,
如果不报错,那么这个锁就是可重入的,如果报错就是不可重入的
如果多个线程操作同一个集合类,就需要考虑线程安全的问题
(1)一个线程,一把锁,连续加俩次,如果锁是不可重入的,就会死锁
(2)俩个线程俩把锁,t1和t2各自针对 锁A 和锁 B,再尝试获取到对方的锁
下面用代码来演示
public class ThreadDemo13 {public static void main(String[] args) {Object cu = new Object();Object lajiaojiang = new Object();Thread xiaoming = new Thread(()->{synchronized (cu) {System.out.println("小明把醋拿到了");//保证小明拿到醋,小红拿到辣椒酱try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (lajiaojiang) {System.out.println("小明拿到醋和辣椒酱了");}}});Thread xiaohong = new Thread(()->{synchronized (lajiaojiang) {System.out.println("小红把辣椒酱拿到了");//保证小明拿到醋,小红拿到辣椒酱try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (cu) {System.out.println("小红拿到醋和辣椒酱了");}}});xiaoming.start();xiaohong.start();}
}
执行结果
用jconsole查看线程情况
针对死锁的问题,我可以通过jconsole这样的工具来进行定位
看线程状态和调用栈,就可以分析出代码在哪里死锁了
(3)多个线程多把锁(相当于2的一般情况)
假设出现了一种极端情况,就会死锁,
同一时刻,所有的哲学家,拿起了左手的筷子
所有的哲学家,这时都拿不起右手的筷子(被其它哲学家拿了) ,都要等右边的哲学家把筷子放下
此时就会出现僵住了的情况,发生死锁
1.互斥使用: 线程1拿到了锁,线程2想获取锁,就要阻塞等待
2.不可强占:线程1拿到了锁之后,除非线程1主动释放,不能是线程2强行把锁获取到
3.请求和保持:线程1拿到锁A后,再次尝试获取锁B,A这把锁还是保持的(不会因为去获取锁B,就把锁A给释放了)
4.循环等待 线程1 获取到锁A ,再尝试获取锁B,线程2获取到锁B,在尝试获取锁A
线程1 在获取锁B 的时候,等待线程2 释放B,线程2在获取锁A 的时候,等待线程1释放A
上述前三个条件都是sychronized这把锁的基本特性,因此想要不死锁,只能改变循环等待这个条件
代入到一开始写的代码
假设我们规定,醋的编号为1,辣椒酱的编号为2
按照从小到大的顺序获取锁
这样先获取到的一定是醋,小红获取不到就会阻塞等待
等待小明释放醋,再去获取醋
public class ThreadDemo13 {public static void main(String[] args) {Object cu = new Object();Object lajiaojiang = new Object();Thread xiaoming = new Thread(()->{synchronized (cu) {System.out.println("小明把醋拿到了");//保证小明拿到醋,小红拿到辣椒酱try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (lajiaojiang) {System.out.println("小明拿到醋和辣椒酱了");}}});Thread xiaohong = new Thread(()->{synchronized (cu) {System.out.println("小红把醋拿到了");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (lajiajiang {System.out.println("小红拿到醋和辣椒酱了");}}});xiaoming.start();xiaohong.start();}
}
执行结果:
现在有俩个线程,线程1判断flag是否等于0,如果是0就一直循环
线程2输入一个整数,修改flag
预期:t2把flag改为非0的一个整数,t1的循环也就结束了
代码演示
import java.util.Scanner;class Mycounter{public int flag = 0;
}
public class ThreadDemo14 {public static void main(String[] args) {Mycounter mycounter = new Mycounter();Thread t1 = new Thread(()->{while (mycounter.flag == 0) {//这个循环什么都不做,因此循环执行速度极快}System.out.println("t1线程 循环结束");});Thread t2 = new Thread(()->{Scanner scanner = new Scanner(System.in);System.out.println("请输入一个整数: ");mycounter.flag = scanner.nextInt();});t1.start();t2.start();}
}
运行结果
很明显运行结果和预期结果不符,出Bug了,
这种情况是内存可见性问题
这也是线程不安全的问题,一个线程读,一个线程改
出现这种问题,我们可以手动给这个可能被别的线程修改的变量flag,加上volatile关键字
告诉编译器这个变量是易变的,每次都要重复读取这个变量的内容
运行结果
volatile不能修饰局部变量
内存可见性出现线程安全问题的原因是:一个线程读取变量 ,另一个线程可能会修改这个变量
局部变量不能再多线程之间,读取和修改
局部变量只能再当前方法内使用,出了方法,变量就没了
方法内部的变量在 栈 这样的空间上,每个线程都有自己的栈空间
即使是同一个方法,在多线程中被调用,这里的局部变量也会处在不同的栈空间中,本质还是不同的变量
线程最大的问题是,抢占式执行,随机调度
我们写代码不喜欢随机的,因为不确定的东西,可能会出现一些Bug
因此程序员发明了一些方法,来控制线程之间的顺序,虽然线程在系统内核里的调度是随机的
但是可以通过一些api让线程主动阻塞,主动放弃cpu(给别的线程让路)
比如,t1和t2俩个线程,希望t1先干活,干的差不多了,再让t2来干,
就可以让t2先wait(阻塞,主动放弃cpu),
等t1做的差不多的时候,再通过notify 通知t2,把t2唤醒,让t2来干活
wait和notify都是object类中的方法,也就是说任意对象都有wait和notify方法
wait无参数版本就是 死等
wait带参数版本,指定了最大等待时间
某个线程调用wait方法,就会进入阻塞状态(无论通过哪个对象wait的),此时线程就处于WAITING状态
代码演示
public class ThreadDemo15 {public static void main(String[] args) throws InterruptedException {Object object1 = new Object();System.out.println("wait 之前");object1.wait();System.out.println("wait 之后"); }
}
运行结果
为什么会有这个异常?
1.先释放锁
2.进行阻塞等待
3.收到通知后,重新尝试获取锁,并在获取到锁后,继续向下执行
这里的锁异常,就是因为,object还没有被加锁,就要释放锁,显然会出现锁异常状态
因此wait操作,要搭配 synchronized使用
public class ThreadDemo15 {public static void main(String[] args) throws InterruptedException {Object object1 = new Object();synchronized (object1) {System.out.println("wait 之前");object1.wait();System.out.println("wait 之后");}}
}
写段代码展示notify的作用
现在有线程1调用了wait方法,线程2调用notify,唤醒线程1,让其继续执行
public class ThreadDemo16 {public static void main(String[] args) {Object object = new Object();Thread t1 = new Thread(()->{//这个线程负责进行等待System.out.println("t1 wait 之前");synchronized (object){try {object.wait();} catch (InterruptedException e) {e.printStackTrace();}}System.out.println("t1 wait 之后");});Thread t2 = new Thread(()->{System.out.println("t2 notify 之前");synchronized (object){//notify 务必获取到锁才能进行通知object.notify();}System.out.println("t2 notaify 之后");});t1.start();//sleep保证t1线程先执行,先进行wait阻塞等待try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}t2.start();}
}
notifyAll
如果当前多个线程在等待 object对象
此时有一个线程 object.notify(),此时随机唤醒一个等待的线程(不知道具体哪个)
notifyAll,多个线程wait的时候,notifyAll所有线程都唤醒,这些线程再一起锁竞争
wait 和 sleep都能等待一段时间,都能被提前唤醒
但它们有本质区别
wait这个方法用来等待,就是为了有朝一日能够被唤醒,notify唤醒 wait是不会有任何异常的,这时一个正常的逻辑
sleep这个方法,用来等待,是为了让线程休眠一段时间,不希望能够被唤醒,用interrupted唤醒sleep,会出现异常,这是一个出问题的逻辑
三个线程,分别只能打印A,B,C
写代码来保证三个线程,固定按照A,B,C的顺序来打印
public class ThreadDemo17 {public static void main(String[] args) throws InterruptedException {Object locker1 = new Object();Object locker2 = new Object();Thread t1 = new Thread(()->{System.out.println("A");synchronized (locker1) {locker1.notify();}});Thread t2 = new Thread(()->{synchronized (locker1) {try {locker1.wait();} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println("B");synchronized (locker2) {locker2.notify();}}});Thread t3 = new Thread(()->{synchronized (locker2) {try {locker2.wait();} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println("C");}});t2.start();t3.start();Thread.sleep(1000);t1.start();}
}
先创建locker1 和 locker2用来加锁的对象
再创建三个线程,t1执行打印A,t2打印B,t3打印C
在t3对象中,使用wait进行阻塞等待,加锁的对象为locker2
在t2对象中,也先使用wait进行阻塞等待,加锁对象为locker1,打印完B后,再用notify通知在locker2这个对象上等待的线程,
在t1对象中,先打印A,再用notify通知在locker1对象上等待的线程
再启动这三个线程,注意要保证,t1线程最后启动,否则t2线程还没有进行阻塞等待,t1就开始通知了