JavaEE-多线程初阶(3)
目录1.线程的状态
1.1 NEW、RUNNABLE、TERMINATED
1.2 TIMED_WAITING
1.3 WAITING
1.4 BLOCKED
2.多线程带来的风险-线程安全(重点)
2.1 观察线程不安全的征象
2.2 分析产生该征象的原因
2.3 产生线程安全问题的原因
2.3.1 抢占式执行(根本)
2.3.2 多个线程同时修改同一个变量
2.3.3 修改操纵,不是原子的
2.3.4 内容可见性问题
2.3.5 指令从排序
2.4 怎样解决线程安全问题
2.4.1 抢占式执行
2.4.2 多个线程同时修改同一个变量
2.4.3 修改操纵,不是原子的
解决方案
加锁:synchronized
synchronized的变种写法
https://i-blog.csdnimg.cn/direct/7f72380d00924e1b91a4fadb021936d6.gif
1.线程的状态
线程的所有状态如下:
• NEW: 安排了⼯作, 还未开始⾏动 • RUNNABLE: 可⼯作的. ⼜可以分成正在⼯作中和即将开始⼯作. • BLOCKED: 这⼏个都表⽰排队等着其他事情 • WAITING: 这⼏个都表⽰排队等着其他事情 • TIMED_WAITING: 这⼏个都表⽰排队等着其他事情 • TERMINATED: ⼯作完成了. 1.1 NEW、RUNNABLE、TERMINATED
对于这三个状态的解释:
NEW:new了Thread对象,还没start
RUNNABLE :停当状态,可以分成以下两种情况(正在工作大概即将开始工作):
(1)线程正在cpu上执行 (2)线程随时可以去cpu上执行 TERMINATED:内核中的线程已经结束了,但是Thread对象还在
https://i-blog.csdnimg.cn/direct/77acf0b196dd49cf9e16b56f2576127e.png
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(()->{
System.out.println("线程t1开始执行...");
try {
Thread.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t1线程结束...");
});
System.out.println(t1.getState());
t1.start();
Thread.sleep(0,500);
System.out.println(t1.getState());
t1.join();
System.out.println(t1.getState());
} 执行结果:
https://i-blog.csdnimg.cn/direct/2e936d7a29094994a7d478a7a87b5d07.png
1.2 TIMED_WAITING
• 指定时间的阻塞
• 线程阻塞(不加入 cpu 调度,不继续执行了)
• 阻塞的时间是有上限的
Thread.sleep(时间);会进入到TIMED_WAITING状态
https://i-blog.csdnimg.cn/direct/496f9b9c7213490c851683295c54b406.png
另外,join(时间)也会进入到TIMED_WAITING状态:
https://i-blog.csdnimg.cn/direct/fcc6aff3d3a44c32998fcee557a3f058.png
https://i-blog.csdnimg.cn/direct/ecd9ec1c0e7543329629f26309a07aea.png
1.3 WAITING
死等,没有超时时间的阻塞等待
https://i-blog.csdnimg.cn/direct/5b8ec0daed6e4b25bce8e7963951c5a9.png
https://i-blog.csdnimg.cn/direct/ee032f4994324722b7da145d901c7af4.png
1.4 BLOCKED
也是一种阻塞,比较特别,是由于锁导致的阻塞。
https://i-blog.csdnimg.cn/direct/e4d8dbb0becb4447b0a1582890f6d751.png
https://i-blog.csdnimg.cn/direct/31d435d1b8824bc8b3f281f036fc719d.png
2.多线程带来的风险-线程安全(重点)
2.1 观察线程不安全的征象
案例:
创建一个静态变量count=0;
在main方法里创建两个线程t1和t2
两个线程内分别循环五万次count++操纵
末了打印出count的值:
public class Demo10 {
public static int count=0;
public static void main(String[] args) throws InterruptedException {
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();
t1.join();
t2.join();
System.out.println(count);
}
} 按照原理来说,count进行了总共十万次count++操纵,打印出来的值应该是十万,实际上却并非如此,执行代码:
https://i-blog.csdnimg.cn/direct/09766a7af0c348f7b751fae1b8ccbe47.png
这样的代码很明显是有bug的,实际执行效果与预期效果不符合就叫做bug。
这样的问题,是多线程并发执行引发的问题。
假如调换代码的执行程序,把t1线程和t2线程的并行改成串行(一个执行完了再执行另一个)
结果又酿成正确的了:
https://i-blog.csdnimg.cn/direct/bdce6f7745fe4088a52158fc3c6291b1.png
执行结果:
https://i-blog.csdnimg.cn/direct/00981babd1434b5485c763e5327c0a28.png
很明显,当前的bug是由于多线程的并发执行代码引起的bug
这样的bug,就称为“线程安全问题”,大概叫做“线程不安全”
反之,假如一个代码在多线程并发执行的环境下也不会出现雷同上述的bug
这样的代码就称为“线程安全”
2.2 分析产生该征象的原因
站在 CPU 的角度来看count++,这个操纵看起来是一行代码,实际上对应到三个CPU指令:
1.load,把内存中的值(count变量)读取到寄存器中
2.add,把指定的寄存器中的值进行+1操纵(结果还是在这个寄存器中)
3.save,把寄存器中的值,写回到内存中
CPU执行这三条指令的过程中,随时有可能触发线程的调度切换(如下所示):
指令1 指令2 指令3 线程切走
指令1 指令2 线程切走 指令3
指令1 线程切走 指令2 指令3
指令1 线程切走 指令2 线程切走 指令3
但是由于操纵体系的调度是随机的,不可预期的
执行任何一个指令的过程中都有可能触发上述的“线程切换”的操纵
也就是说,随机调度(大概叫抢占式执行)就是线程安全问题的罪魁罪魁。
t1线程和t2线程的执行过程可能会出现如下情况(只举了个别例子,可能还有别的情况):
对于执行结果:打 √ 的为正确结果,× 为错误结果。
https://i-blog.csdnimg.cn/direct/03d88417adf943a88884229bc812d968.png
对于其中某种正确情况的具体执行过程如下:
https://i-blog.csdnimg.cn/direct/58616a8778d849f4bc11cf94c81a7086.png
对于其中某种错误情况的具体执行过程如下:
https://i-blog.csdnimg.cn/direct/8dc71c66303c419595b2efd54aaa8a5e.png
可以看到:
t1线程和t2线程分别进行了一次count++操纵,理论上count的值应为2,实际上由于随机调度,t1的寄存器和t2的寄存器都读取了内存的初始值0,最终执行完两次count++后count的值为1
由于线程执行的过程中有可能(大概率)会发生上述的错误情况,因此代码的执行结果总是<=100000
假如降低单个线程的循环次数,会发现能运行出正确的结果:
https://i-blog.csdnimg.cn/direct/f152f6ea22cc41688455a124640e61f3.png
运行结果:
https://i-blog.csdnimg.cn/direct/5b63a60fa7584bd380a7a346e4be7284.png
出现这种征象的原因:
毕竟上,线程安全问题仍旧存在,只不过概率变低了。
单个线程执行的count++次数会影响到这个线程的运行时间,即运行50次比运行50000次要快得多
因为运行的太快了,很可能在t2.start()执行之前,t1线程就执行完了
等到后续t2线程的执行,就酿成了串行执行
2.3 产生线程安全问题的原因
2.3.1 抢占式执行(根本)
抢占式执行:操纵体系对于线程的调度是随机的
抢占式执行计谋
最初诞生于多使命操纵体系的时间,是非常重大的发明
后代的操纵体系,都是一脉相承
2.3.2 多个线程同时修改同一个变量
https://i-blog.csdnimg.cn/direct/e84908e09d7143c6b12d22c345615e12.png
假如是一个线程,修改一个变量---没问题
假如是多个线程,不是同时修改同一个变量---没问题
假如是多个线程,修改不同变量---没问题(不会出现中间结果相互覆盖的情况)
假如是多个线程,读取同一个变量---没问题(该变量不可修改)
2.3.3 修改操纵,不是原子的
原子:
假如一个操纵只是对应到一个cpu指令,那么就可以认为它是原子的
cpu就不会出现“一条指令执行到一半”这样的情况
反之,假如一个操纵对应到多个cpu指令,那么它就不是原子的,例如:
++
--
+=
-=
........
2.3.4 内容可见性问题
后续再讨论
2.3.5 指令从排序
后续再讨论
2.4 怎样解决线程安全问题
2.4.1 抢占式执行
抢占式执行是操纵体系的底层设定,程序员无法左右。
2.4.2 多个线程同时修改同一个变量
和代码的结构直接相关
可以调整代码结构,规避一些线程不安全的代码
但是这样的方案不够通用
Java中有个东西:
String 就是接纳了“不可变”特性,确保线程安全
2.4.3 修改操纵,不是原子的
解决方案:
Java中解决线程安全问题,最重要的方案就是:加锁
通过加锁操纵,让不是原子的操纵,打包成一个原子的操纵
2.4.3.1 锁的概念
计算机中的锁,和生存中的锁,是同样的概念:互斥/排他
把锁“锁上”称为“加锁”
把锁“解开”称为“解锁”
一旦把锁加上了,其他人要想加锁,就得阻塞等待
对于上述例子的count++操纵,它并非是原子的,此时就可以使用锁,把刚才不是原子的count++
包裹起来,在count++之前,先加锁,然后进行count++,计算完毕之后,再解锁
(此时在执行三步走的过程中,其他线程就没法“插队”了)
加锁操纵,不是把线程锁死到cpu上,禁止这个线程被调度走
而是禁止其他线程重新加这个锁,避免其他线程的操纵在当前线程执行的过程中“插队”
2.4.3.2加锁:synchronized
加锁 / 解锁 自己是操纵体系提供的api
很多编程语言都对这样的api进行了封装
大多数的封装风格,都是接纳两个函数:
加锁 lock();
//执行一些要保护起来的逻辑
解锁 unlock();
Java中使用 synchronized 这样的关键字,搭配代码块来实现雷同的效果:
synchronized(){ //进入代码块,相称于加锁
//执行一些要保护起来的逻辑
}出了代码块,相称于解锁
使用synchronized对上述案例进行加锁:
https://i-blog.csdnimg.cn/direct/663a8e26e0954c48b8c4df1b12bff357.png
可以看到括号内需要填入参数,应该填入什么呢?
填写的是,用来加锁的对象。要 加锁 / 解锁,前提是得先有一个锁
在Java中任何一个对象都可以用作“锁”
这个对象的类型是什么不重要
重要的是,是否有多个线程针对这同一个对象加锁(竞争同一把锁)
https://i-blog.csdnimg.cn/direct/4e58af4b702c4aa0901086b687661ea4.png
再次执行代码,线程安全问题得到了解决:
https://i-blog.csdnimg.cn/direct/cd708458c06f41378e0501b343f58d40.png
【注意】
两个线程,针对同一个对象加锁,才会产生互斥效果
(一个线程加上了锁,另一个线程就得阻塞等待,等到第一个线程开释锁,才有时机)
假如是不同的锁对象,此时不会有互斥效果,线程安全问题并没有得到解决:
https://i-blog.csdnimg.cn/direct/396e87539c714f05a7e3fd9dc4367ccd.png
代码执行结果,仍旧存在线程安全问题:
https://i-blog.csdnimg.cn/direct/9e1e5d6ae4f64f9c94beba9863114459.png
2.4.3.3 synchronized的变种写法
在方法内加锁:
https://i-blog.csdnimg.cn/direct/4d687d603daa4f63a5964c56cf03654c.png
https://i-blog.csdnimg.cn/direct/c0fd2e96597a48639bc21067365b31f7.png
这种写法跟之前的写法产生的效果是一样的,执行代码:
https://i-blog.csdnimg.cn/direct/fa7793ed86e04e068be18e9e43b9e7c9.png
修饰方法的写法还能继续变形:使用synchronized修饰方法,就相称于是针对this进行加锁
https://i-blog.csdnimg.cn/direct/3c68afb4a35444a1a3cad3db1da207cc.png
这种写法跟上面的效果也是一样的。
StringBuffer、Vector这些类里面有些方法就是带有synchronized的:
https://i-blog.csdnimg.cn/direct/b3cb6df6e1974bbebb0d75f21f25aa9c.png
因此,StringBuffer是线程安全的,而StringBuilder线程不安全。
而方法之中,还有一个特别情况,static修饰的方法不存在this
此时,synchronized修饰static方法相称于针对类对象加锁。
2.4.3.4 可重入
当我们使用锁写代码,一旦方法调用的条理比较深,搞不好就会出现这种情况(大概雷同的):
https://i-blog.csdnimg.cn/direct/3f002e272d934ab0915617ee823ee833.png
分析上面这段代码:
1.第一次进行加锁(第一层),可以或许成功(锁没有人使用)
2.第二次进行加锁(第二层),此时的locker已经是被占用的状态,第二次加锁就会触发阻塞等待
要想排除阻塞,就必须往下执行
要想往下执行,就需要等到第一次的锁被开释
这样的问题,就被称为“死锁”(dead lock)
为了解决上述问题,Java的synchronized就引入了可重入的概念
当某个线程针对一个锁,加锁成功之后
后续该线程再次针对这个锁进行加锁
不会触发阻塞,而是直接往下走
因为当前这把锁就是被这个线程持有
但是假如是其他线程尝试加锁,就会正常阻塞
也就是说,当我们执行上述代码时,并不会触发死锁,而是正常执行:
https://i-blog.csdnimg.cn/direct/892bbfbd16da49b5a5812cd61f066ee3.png
可重入锁的实现原理,关键在于让锁对象内部保存,当前是哪个线程持有这把锁
后续有线程针对这个锁加锁的时间,对比一下,持有锁的线程是否和当前要加锁的线程是同一个
当有多层加锁时,最外层的加锁和解锁才是有效的,内部锁直接放行
站在JVM的角度,看到多个 } 需要执行,JVM怎样知道哪个 } 是真正需要解锁的谁人:
先引入一个变量,计数器(0)
每次触发 { 的时间,把计数器++
每次触发 } 的时间,把计数器--
当计数器 --到0的时间,就是真正需要解锁的时间
https://i-blog.csdnimg.cn/direct/7df53ac7e51144e9891810e352932546.png
2.4.3.5 监视器锁
监视器锁 minitor lock
JVM中接纳的一个术语
使用锁的过程中抛出的一些异常,可能会看到 监视器锁 这样的报错信息
完
假如哪里有疑问的话欢迎来评论区指出和讨论,假如觉得文章有价值的话就请给我点个关注还有免费的收藏和赞吧,谢谢各人
https://i-blog.csdnimg.cn/direct/37c334262cb9443ebdc686b6a9a38566.gif
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页:
[1]