目录
线程安全
概念
用一段代码感受线程安全
线程安全问题的原因
修改上述代码,使其线程安全
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循环也是可以并发执行的
一个任务中,一部分可以并发,一部分串行,任然比所有代码都串行的执行速度快
synchronized
synchronized使用方法
1.修饰方法
(1)修饰普通方法 修饰普通方法,锁对象就是this
(2)修饰静态方法 修饰静态方法,锁对象就是类方法(刚刚代码的Counter)
2.修饰代码块 修饰代码块,手动指定锁对象
锁对象的规则
如果俩个线程针对同一个线程同一个对象加锁,就会出现锁竞争/锁冲突
一个线程先获取到锁(先到先得),另一个阻塞等待
等待到上一个线程解锁,才能获取锁成功
如果俩个线程针对不同对象加锁,此时不会发生锁竞争/锁冲突
这俩线程都能获取到各自的锁,不会再阻塞等待了
俩个线程,针对同一个对象,一个线程加锁,一个线程不加锁,此时也没有锁竞争
比如一个线程调用add方法,同时另一个线程调用add2方法,此时不会发生阻塞等待
synchronized用法,代码展示
monitor lock
jvm给synchronized起了个名字,因此代码中有时候会出异常会有这个说法
sychronized的特性
(1)互斥
synchronized会起到互斥的效果,某个线程执行到某个对象的 synchronized时,其它线程如果也执行到同一个对象 sychronized就会阻塞等待
进入synchronized修饰的代码块,相当于加锁
退出synchronized修饰的代码块,相当于解锁
(2)可重入
一个线程针对同一个对象,连续加俩次锁,
如果不报错,那么这个锁就是可重入的,如果报错就是不可重入的
java标准库中的线程安全类
如果多个线程操作同一个集合类,就需要考虑线程安全的问题
死锁
死锁的常见原因
(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();
}
}
执行结果:
volatile
现在有俩个线程,线程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不能修饰局部变量
内存可见性出现线程安全问题的原因是:一个线程读取变量 ,另一个线程可能会修改这个变量
局部变量不能再多线程之间,读取和修改
局部变量只能再当前方法内使用,出了方法,变量就没了
方法内部的变量在 栈 这样的空间上,每个线程都有自己的栈空间
即使是同一个方法,在多线程中被调用,这里的局部变量也会处在不同的栈空间中,本质还是不同的变量
wait 和 notify
wait和notify概念
线程最大的问题是,抢占式执行,随机调度
我们写代码不喜欢随机的,因为不确定的东西,可能会出现一些Bug
因此程序员发明了一些方法,来控制线程之间的顺序,虽然线程在系统内核里的调度是随机的
但是可以通过一些api让线程主动阻塞,主动放弃cpu(给别的线程让路)
比如,t1和t2俩个线程,希望t1先干活,干的差不多了,再让t2来干,
就可以让t2先wait(阻塞,主动放弃cpu),
等t1做的差不多的时候,再通过notify 通知t2,把t2唤醒,让t2来干活
wait和notify都是object类中的方法,也就是说任意对象都有wait和notify方法
wait进行阻塞
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 之后");
}
}
运行结果
为什么会有这个异常?
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 和 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就开始通知了