多线程的线程安全
多线程的线程安全
@[toc]
线程安全是多线程的重点和难点,一定要好好理解
线程安全 : 在多线程各种随机的调度顺序下,代码都没有bug,都能符合 预期的方式执行
什么是bug : 不符合需求就算是bug
举一个实例来验证线程安全
class Counter{
public int count = 0;
public void increase(){
count++;
}
}
public class demo14 {
public static Counter counter = new Counter();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
//阻塞main,先执行 t1 t2线程,等他们执行完了,再执行main
t1.join();
t2.join();
System.out.println("counter="+ counter.count);
}
}
预期的是,有两个线程各累加5000次,静态的count应该会变成10000,可是结果却不到10000次,并且count还是随机的
在进行count++的时候,底层会在CPU上执行3条指令(不是原子性)
-
把内存的数据读取到CPU寄存器上 load
-
把CPU的寄存器上的值+1 add
-
把寄存器中的值,写到内存中 save
串行: 机器执行完一条指命后,才取出下一条指令来执行的一种工作方式。
极端的两种情况:
如果两个线程之间的调度全是串行执行,结果就是10000
如果两个线程全是其他的情况,没有一次串行执行,结果就是5000
所以最终情况就是5000 - 10000
所以以上的随机值就是一种线程不安全
线程不安全的原因:
- 多线程之间抢占式执行(多线程不安全的根本原因)
任何一种调度都是有可能 的
多个线程修改同一个变量
执行修改的操作不是原子的(上述的count++就涉及到了3个CPU指令LOAD ADD SAVE)
内存可见性
指令重排序 (4 5 两点主要是JVM优化代码的时候出现的bug)
…(具体还得看代码实现)
要想解决线程安全问题,最常见的方法就是将多个操作通过特殊手段变成一个原子操作
在上面的例子中,可以在count++ 之前进行加锁,在count++之后进行解锁,在加锁与解锁之间进行修改count,此时别的进程修改不了count,别的线程出于阻塞状态(BLOCKED状态)
在java中,进行加锁,要使用synchronized 关键字
加上锁之后就使别的线程变成了阻塞状态,由"并发"变成了 串行,运行效率确实降低,但是保证了多线程的安全
一定要知道: 加上锁不一定就能保证线程安全,==正确的加锁是通过加锁,让并发修改同一个变量–>串行修改同一个变量==
要是只给一个线程加锁,另一个不加锁,其实是没用的,只给一个线程加锁不会涉及到"锁竞争",也就不会有阻塞等待,也就不会并发执行–>串行执行(追根究底就是还是会抢占式执行)
synchronized锁对象的理解
关键字synchronized 不仅能修饰方法,还能修饰代码块
synchronized 后面括号填的是锁对象, 也就是针对该对象进行加锁,谁要是调用increase方法,谁就是this–锁对象
锁对象不止可以是this,还可以是任何的对象
上面的synchronized锁方法其实默认的锁对象就是this
写多线程代码的时候,最关心的是,两个线程锁的是否是同一个对象,只要锁同一个对象就会存在锁竞争
注意: 此处打印的是counter的count,所以要想达成10000,就要在counter上形成锁竞争,counter与counter2 里面的counter是不一样的
class Counter{
public int count = 0;
public void increase(){
synchronized (this){
count++;
}
}
}
public class demo14 {
public static Counter counter = new Counter();
public static Counter counter2 = new Counter();//再次设立一个新的对象
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
counter2.increase();
}
});
t1.start();
t2.start();
//阻塞main,先执行 t1 t2线程,等他们执行完了,再执行main
t1.join();
t2.join();
System.out.println("counter="+ counter.count);
}
}
其实这种写法与之前的synchronized后直接加this,counter调用的写法在线程安全角度是一样的
注意: 此处打印的是counter的count,所以要想达成10000,就要在counter上形成锁竞争,counter与counter2 里面的counter是不一样的
总结就是一句话: 写多线程代码的时候,最关心的是,两个线程锁的是否是同一个对象,只要锁同一个对象就会存在锁竞争
死锁问题
对于一个线程,连续加锁两次,就会形成死锁
第二次加锁会阻塞等待,直到第一把锁解开,才能加第二把锁
第一把锁要想解开,要求第二把锁加锁成功
所以就这样子相互纠缠住了,就形成了死锁
但是,死锁问题有时候是很难避免的,要是加锁函数1嵌套函数2 ,函数2嵌套函数3,函数3嵌套加锁函数4,这样子就会很难发现死锁问题
可重入锁
要是不会产生死锁的话,这样的所就叫做"可重入锁"
synchronized就是可重入的
可重入锁的底层实现是很简单的
只要让锁记录好时哪个线程持有的这把锁
加锁: t 线程尝试对this来加锁,锁就会记录是 t 线程持有了它
第二次锁就会发现,还是t线程,此时就会直接通过,不会再次加锁
解锁: 在锁里增加一个计数器,每次加锁就++,每次解锁就–,如果计数器为0,此时才真加锁,当计数器为0,此时才真解锁
总结:
可重入锁的实现要点:
- 让锁里持有线程对象,记录哪个线程加了锁
- 维护一个计数器,用来衡量什么时候真加锁,什么时候真解锁,什么时候直接通过
就算加锁代码出现异常,也还是会解锁,还是不会死锁—不得不说synchronized关键字是一个十分优秀的设计
所以上面的代码是不会引起死锁的
复习一下final :
final修饰一个变量: 禁止修改
final 修饰类: 禁止进程
final 修饰方法: 禁止重写
内存可见性
线程不安全的其中一个原因就是内存可见性
要是程序需要频繁地读取数据,比较数据速度远快于LOAD的速度,此时编译器就会开始优化, 要是频繁地执行LOAD 并且 LOAD的结果还是一样的,编译器就会只执行一次LOAD,之后就不会重新读取内存了
import java.util.Scanner;
public class Demo16 {
public static class Counter{
public int count = 0;
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(()->{
while (counter.count == 0){
//具体操作
}
System.out.println("t1进程运行结束");
});
t1.start();
Thread t2 = new Thread(()->{
System.out.println("请输入一个整数:");
Scanner scanner = new Scanner(System.in);
counter.count = scanner.nextInt();//修改count
});
t2.start();
}
}
运行以上的代码就会发现,while循环会一直执行,永远不会停止循环
原因: 内存中的count已经修改成了输入的1 ,但是刚才的修改并不会影响t1 的读内存操作,因为t1 的读内存已经被编译器优化成了不再循环读内存,只是读一次就好了,t1 还以为count还是0
也就是说, t2 把内存改了,但是t1没有没看见,这就是内存可见性问题
内存可见性是编译器优化惹的祸, 编译器在单线程情况下,对于代码的优化,逻辑是不会变的,但是编译器在多线程的情况下,很有可能会发生误判
要想解决内存可见性问题,就不要让编译器进行优化,由我们自己进行操作,此时就 要使用关键字volatile[ˈvɒlətaɪl]
volatile关键字
使用volatile"可变的"来修饰一个变量,这样子编译器就不会进行优化,也就是说,每次编译器都会去读取变量的值,
import java.util.Scanner;
public class Demo16 {
public static class Counter{
volatile public int count = 0;//加上volatile
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(()->{
while (counter.count == 0){
}
System.out.println("t1进程运行结束");
});
t1.start();
Thread t2 = new Thread(()->{
System.out.println("请输入一个整数:");
Scanner scanner = new Scanner(System.in);
counter.count = scanner.nextInt();
});
t2.start();
}
}
此时编译器每次都会去读取内存中的数据
volatile能解决内存可见性问题,也就是一个线程读,一个线程修改的情况,它并不能保证原子性的问题
要是两个线程修改同一个变量还得是synchronized来保证原子性
public class Demo17 {
static class Counter{
volatile int count = 0;//只能解决内存可见性
public void increase(){
count ++;
}
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(()->{
for(int i = 0; i<5000; i++){
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = "+ counter.count);
}
}
//两个线程修改同一个变量,还是要 synchronized来保证原子性
要是谈到volatile就一定要知道JMM(Java Memory Model) Java内存模型
- 通俗地讲:
volatile禁止了编译器优化,避免了直接读取从CPU寄存器缓存的数据,而是每次都会重新读内存
Java语言为了更加通用,尽可能 避免硬件的差异,就起了一些术语
CPU寄存器–>工作内存(work memory) 内存—> 主内存(main m emory )
- 站在JMM角度看volatile:
正常程序执行的过程中,会把主内存的数据先加载到工作内存中,再进行计算处理,要是编译器进行优化,就不会每次都去主内存中读取,而是直接去工作内存读取, 这样就会导致内存可见性问题
volatile 起到的效果就是 ,保证每次读取内存都是真的才能够主内存中读取
注意: 工作内存不是真的内存,它只是CPU的寄存器,这是术语而已
wait 和 notify
多线程中总是会出现抢占式执行,可以使用wait 和 notify 来控制线程的执行顺序\
线程1调用了wait,线程1 就会阻塞,直到别的线程调用notify之后,线程1 才会继续执行
package Threading;
public class Demo18 {
public static void main(String[] args) {
Object object = new Object();
System.out.println("wait之前");
try {
object.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("wait之后");
}
}
这样写的结果,会报错
不合法的锁状态异常
wait内部会进行一下三个操作:
- 释放当前的锁
- 进行等待
- 当有别的线程调用 notify 时,就会被唤醒,然后重新获取锁
所以要想要释放当前的锁的前提就是要先加上锁
package Threading;
public class Demo18 {
public static void main(String[] args) {
Object object = new Object();
synchronized(object){ //先加上锁,wait之后object就会解锁,其他的线程会获取到锁
System.out.println("wait之前");
try {
object.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("wait之后");
}
}
}
要想使用wait 和 notify 就一定要先加上锁
举一个例子来理解wait与notify执行的具体顺序
package Threading;
public class Demo19 {
public static void main(String[] args) {
Object object = new Object();//创建 一个对象
Thread t1 = new Thread(()->{
while (true) {
synchronized (object) {
System.out.println("wait之前");
try {
object.wait();//前面加上锁
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("wait之后");
}
}
});
t1.start();
Thread t2 = new Thread(()->{
while (true){
synchronized (object){
System.out.println("notify之前");
object.notify();//前面已经加上锁
System.out.println("notify之后");
}
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t2.start();
}
}
就像是上面写的,t1先调用了wait, t2后调用notify, 此时notify就会唤醒wait
但是,要是t2先执行了 notify , t1 后执行wait , 或者干脆就不调用wait , 其实也没有什么事,只是不符合上面的规定罢了,什么都不会发生
notifyAll : 唤醒所有被wait的线程,
wait 与 notify 总结
wait notify是 用来控制多线程直接的执行先后顺序的
- wait 和 notify 都要先进行上锁(synchronized)
- 必须是同一个对象调用wait 和 notify
- 锁对象也要和 调用wait / notify 的对象一致
- 就算没有wait , 直接notify 也是没有副作用的
wait 与 sleep 的区别
首先要知道,wait 和 sleep 都是 让线程进入阻塞等待的状态
-
两个方法所属类不一样, sleep是thread 类的方法, wait是Object类的方法
-
sleep是 通过时间来控制何时唤醒线程, wait 是 其他的线程通过notify 唤醒线程的(但是wait还有一个重载版本 , 参数可以传入时间, 表示等待的最大时间)
-
有无释放锁: 在调用wait之前, 必须要保证已经请求到锁, 调用之后会释放掉已经获得的锁,唤醒之会重新请求锁, sleep就不涉及到锁
练习
有三个线程,线程名称分别为:a,b,c。
每个线程打印自己的名称。
需要让他们同时启动,并按 c,b,a的顺序打印
public static void main(String[] args) {
Thread tc = new Thread(()->{
System.out.println("c");
});
Thread tb = new Thread(()->{
try {
tc.join();//等待tc线程结束
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("b");
});
Thread ta = new Thread(()->{
try {
tb.join();//等待tb线程结束
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("a");
});
ta.start();
tb.start();
tc.start();
}
总结来说,就是线程c先开始, b等c结束再开始, a等b结束再开始
进阶版
有三个线程,分别只能打印A,B和C
要求按顺序打印ABC,打印10次
输出示例:
ABC
ABC
ABC
ABC
ABC
ABC
ABC
ABC
ABC
ABC
public static boolean isThreadA = true;
public static boolean isThreadB = false;
public static boolean isThreadC = false;
public static void main(String[] args) {
final Test test = new Test();//其实创建一个Object对象也是一样的
Thread t1 = new Thread(()->{
for (int i = 0; i < 10; i++) {
synchronized (test){
while (!isThreadA){
try {
test.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.print("A");
isThreadA = false;
isThreadB = true;//交给线程2
isThreadC = false;
test.notifyAll();//唤醒
}
//以上的代码必须要synchronized里面,保证原子性
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 10; i++) {
synchronized (test){
while (!isThreadB){
try {
test.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.print("B");
isThreadA = false;
isThreadB = false;
isThreadC = true;
test.notifyAll();
}
}
});
Thread t3 = new Thread(()->{
for (int i = 0; i < 10; i++) {
synchronized (test){
while (!isThreadC){
try {
test.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.print("C");
isThreadA = true;
isThreadB = false;
isThreadC = false;
test.notifyAll();
System.out.println();
}
}
});
t1.start();
t2.start();
t3.start();
}
这道题就不好向上面一样使用join了,使用的是标识位 + 加锁 + wait notify 控制顺序
- 点赞
- 收藏
- 关注作者
评论(0)