多线程编程中,最难的地方,也是最重要的一个地方,还是一个最容易出错的地方,更是一个特别爱考的地方,就是线程安全问题。
万恶之源,罪魁祸首,多线程的抢占式执行,带来的随机性.
如果没有多线程,此时程序代码执行顺序就是固定的.(只有一条路)﹒代码顺序固定,程序的结果就是固定的.[单线程的情况下,只需要理清楚这一条路即可)
如果有了多线程,此时抢占式执行下,代码执行的顺序,会出现更多的变数。代码执行顺序的可能性就从一种情况变成了无数种情况。
所以就需要保证这无数种线程调度顺序的情况下,代码的执行结果都是正确的。
只要有一种情况下,代码结果不正确,就都视为是有bug,线程不安全。
目录
线程安全
原因
synchronized
synchronized使用方法
1.修饰方法
2.修饰代码块
3.可重入
4.其他的锁
5.Java标准库中的线程安全类
死锁
死锁的三种典型情况
1.一个线程一把锁
2.两个线程两把锁
3.多个线程多把锁
死锁的四个必要条件
1.互斥使用
2.不可抢占
3.请求和保持
4.循环等待
如何避免死锁
内存可见性
编辑
volatile
wait notify
线程安全
class Counter{
public int count = 0;
public void add(){
count++;
}
}
public class demo1 {
public static void main(String[] args) {
Counter counter = new Counter();
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) {
throw new RuntimeException(e);
}
System.out.println("coount=" + counter.count);
}
}
count=59005
进程已结束,退出代码0
coount=75148
进程已结束,退出代码0
count=67437
进程已结束,退出代码0
我们先来看到这样一个代码:
两个线程各自自增5w次,一共自增10w次,预期结果count是10w,但是实际结果并不是10w,而且每一次都不一样,这个就称为bug。
为什么会出现这样的情况?
count++;
对于count++这个操作本质上要分为三步:
1.把内存中的值,读取到CPU的寄存器中去 load
2.把CPU寄存器里的数值进行+1运算 add
3.把得到的结果写到内存中去 save
如果是两个线程并发的执行count++,此时就相当于两组load,add,save进行执行,此时不同的线程调度顺序就可能会产生一些结果上的差异~
但是那么多种情况,只有这种情况才是我们所需的正确的情况(t1 t2可以交换)
下面这种情况就是一个不正确的,类似于事务中的读到了一个脏数据。t1读到的是一个t2还没来得及提交的脏数据,于是就出现了脏读问题~
此处讲的多线程,和前面的并发事务,本质上都是“并发编程”问题,并发处理事务,底层也是基于多线程这样的方式来实现的 。
一个线程是完成一个任务,要做一些工作,你这个工作是可以分解成一个一个的小步骤的,每一个小步骤就是一个指令。由于线程的抢占式执行,导致当前执行到任意一个指令的时候,线程都可能被调度走CPU让别的线程来执行。
当前这个代码,是否可能结果正好是10w呢?是有可能的,只是概率非常小,假设两个线程的每次调度顺序都是先t1再t2或者先t2再t1,那么还是有可能的~
同时也有可能最后的结果小于5w,可能t1先加载,t2连续执行三次,最后的结果count只加1。
原因
到底是什么样的情况会出现线程安全问题?
1.[根本原因] 抢占式执行,随机调度
2.代码结构:多个线程同时修改一个变量(注意,这里说的是修改,也就是写)
一个线程修改一个变量,没事
多个线程读取一个变量,没事
多个线程修改多个不同的变量,也没事
3.原子性:如果修改操作是原子的,那么不会有事
但是如果是非原子的,出现问题的概率就非常高了
count++可以拆分成 load add save 三个操作
我们需要通过操作把这个非原子的操作变成原子的:加锁
4.内存可见性问题
5.指令重排序(本质上是编译器优化出bug了)
以上分析出的是五个典型的原因,不是全部
一个代码究竟是线程安全还是不安全,都得具体问题 具体分析
如果一个代码踩中了上面的原因,也可能线程安全
如果一个代码没踩中上面的原因,也可能线程不安全.......结合原因,结合需求,具体问题具体分析.
最终抓住的原则:多线程运行代码,不出bug,就是安全的!!!
如何从原子性入手,来解决线程安全问题呢?
synchronized
这是一个关键字,表示加锁
加了synchronized之后,进入方法就会加锁,出了方法就会解锁
如果两个线程同时尝试加锁,此时一个能获取成功,另一个只能阻塞等待(BLOCKED),一直阻塞到刚才的线程释放锁(解锁),当前线程才能加锁成功。
引出之前介绍的线程的几种状态之一:BLOCKED 等待另一个线程解锁的状态
加锁之后,代码执行速度一定是大打折扣的,但是仍然是比单线程要快。
刚刚的例子中,加锁只是针对了count++加锁了,但是除了count++之外,还有for循环的代码,for循环是可以并行的,只是count++串行了。一个任务中,一部分并发,一部分串行,仍然是比所有的代码串行要快~
synchronized使用方法
1.修饰方法
1)修饰普通方法 修饰普通方法,锁对象就是this
2)修饰静态方法 修饰静态方法,锁对象就是类对象(Counter.class)
2.修饰代码块
修饰代码块,显示\手动指定锁对象
所以加锁是要明确执行对哪个对象加锁的
如果两个线程针对同一个对象加锁,会产生阻塞等待(锁竞争、锁冲突)
如果两个线程针对同一个对象进行加锁,就会出现锁竞争/锁冲突,一个线程能够获取到锁(先到先得)另一个线程阻塞等待,等待到上一个线程解锁,它才能获取锁成功~~否则就不会
如果两个线程针对不同对象加锁,此时不会发生锁竞争/锁冲突.这俩线程都能获取到各自的锁.不会有阻塞等待了.
还是两个线程,一个线程加锁,一个线程不加锁这个时候是否有锁竞争呢??没有的!!!
eg1:
public synchronized void add(){
count++;
}这里直接把synchronized修饰到方法上了,此时相当于针对this加锁
eg2:
eg3:
public void add(){
synchronized(this){
count++;
}
}
进入代码块就解锁
出了代码块就解锁
这里的this可以指定任意你想指定的对象(不一定非要是this)
3.可重入
synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题
一个线程针对同一个对象,连续加锁两次,是否会有问题~~如果没问题,就叫可重入的。如果有问题,就叫不可重入的。
synchronized public void add(){
synchronized(this){
count++;
}
}
在这个代码块中,锁对象是this,只要有线程调用add,进入add方法的时候,就会先加锁(能够加锁成功),紧接着又遇到了代码块,再次尝试加锁。
站在this(锁对象)的视角,它认为自己已经被线程占用了,这里的第二次加锁要不要阻塞等待呢?
这里的第二个线程和第一个线程,其实是同一个线程
在是相同线程的前提下如果允许第二个锁不用阻塞等待,那么就说这个锁是可重入的
反之(第二次加锁会阻塞等待),就说是不可重入的
(就是在锁对象里面记录一下,当前的锁是哪个线程持有的,如果加锁线程和持有线程是同一个,就直接放过,否则就阻塞)
因为Java代码中很容易出现死锁,所以Java就把synchronized设定成可重入的了
4.其他的锁
除了Java的synchronized之外,很多别的语言别的库,加锁解锁往往是两个分开的操作,比如:加锁lock(),解锁unlock(),但是这样分开写容易忘记写unlock
所以synchronized基于代码块的方式,就有效的解决了上述问题
5.Java标准库中的线程安全类
死锁
死锁是一个非常影响程序员幸福感的问题,一但程序出现死锁,就会导致无法执行后续工作,程序就会有严重bug。并且死锁是非常隐蔽的,开发阶段不经意间就会写出死锁代码,不容易测试出来。
死锁的三种典型情况
1.一个线程一把锁
连续加锁两次
如果锁是不可重入锁,就会死锁。
Java中synchronized和ReentrantLock都是可重入锁,C++,Python,操作系统原生的加锁API都是不可重入的,就会在这种情况下出现死锁。
2.两个线程两把锁
t1,t2各自先针对锁A,锁B加锁,再尝试获取对方的锁
(在这段代码中要加入sleep,否则会出现线程执行速度差别较大从而能够获取到对方的锁)
locker1和locker2分别加锁,再申请对方的锁,这样就会进入死锁,结果什么也没有,于是我们可以运用jconsole来看一下线程的情况:
可以很清楚的看到,两个线程都进入了BLOCKED状态,表示获取锁,获取不到的阻塞状态。
针对这样的死锁问题,也是需要借助像jconsole这样的工具来进行定位的。看线程的状态和调用栈,就可以分析出代码是在哪里死锁了。
3.多个线程多把锁
死锁的四个必要条件
1.互斥使用
线程1拿到了锁,线程2就得等着(锁的基本特性)
2.不可抢占
线程1拿到锁之后,必须是线程1主动释放,不能说是线程2就把锁给强行获取到。
3.请求和保持
线程1拿到锁A之后,再次尝试获取锁B,A这把锁没有释放,就仍然是保持的。
4.循环等待
线程1尝试获取到锁A和锁B;线程2尝试获取到锁B和锁A。
线程1在获取B的时候等待线程2释放B;同时线程2在获取A的时候等待线程1释放A。
只有这四个条件同时具备,才出现死锁。
循环等待是这四个条件里唯一一个和代码结构相关的,也是我们可以控制的。
如何避免死锁
避免死锁,突破点就是循环等待
方法:给锁编号,然后指定一个固定的顺序(比如从小到大)来加锁,任意线程加多把锁的时候,都让线程遵守上述顺序,此时循环等待自然破除。
内存可见性
class Mycounter{
int flag = 0;
}
public class demo1 {
public static void main(String[] args) {
Mycounter mycounter = new Mycounter();
Thread t1 = new Thread(() -> {
while(mycounter.flag == 0){ t1这里要快速重复的读取flag的值
}
System.out.println("循环结束");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入值");
mycounter.flag = scanner.nextInt();
});
}
}
线程2修改了flag的值,理论上线程1应该会打印循环结束,但是实际上并不会。当输入1的时候,这个线程并不会结束循环。
这个问题就叫做:内存可见性问题
这是一个bug,也是一个线程安全问题
while(mycounter.flag == 0)
这里用汇编来理解,就是两步操作:
1.load,把内存中flag的值,读取到寄存器中
2.cmp,把寄存器的值,和0进行比较,根据比较结果再进行下一步的执行
上述是一个循环,这个循环执行速度极快,一秒钟执行百万次以上。
循环执行这么多次,在t2真正修改之前,load得到的结果都是一样的,另一方面,load操作和cmp操作相比,执行速度慢非常非常多~
由于load执行的速度太慢(相比于cmp来说),再加上反复的load到的结果都一样,JVM就做出了一个大胆的决定:不再真正的重复load,判定好像flag的值不会被修改,干脆就只读取一次就好了。
因为CPU针对寄存器的操作,要比内存快很多!于是通过编译器优化,从而导致了这样的结果。
内存可见性问题:
一个线程对一个变量进行读取操作,同时另一个线程针对这个变量进行修改,此时读到的值,不一定是修改过后的值。
volatile
这时候就需要我们手动干预,需要用到的关键字是violatile。
volatile关键字的作用主要有如下两个:
1. 线程的可见性:当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
2. 顺序一致性:禁止指令重排序。同时volatile只能修饰变量,synchronized可以修饰方法,静态方法,代码块。
这就相当于告诉编译器,这个变量是易变的,你要每次都重新读取这个变量的内容。
一个变量在两个线程中,一个读,一个写就需要考虑violatile了。
wait notify
现在有一个场景:t1 t2俩线程,希望t1先干活,干的差不多了,再让t2来干。就可以让t2先wait (阻塞,主动放弃cpu)等t1干的差不多了,再通过notify通知t2,把t2唤醒,让t2接着干。
是不是这个场景和join有点类似,也是让其中一个线程等待另一个线程。但是如果我们想先让t1执行50%,再执行t2,join就做不到了。
这个时候就需要用到wait和notify
当t1执行到50%时,手动让其wait,让其进入WAITING状态,然后等待t2执行完毕再执行t1,仅需要用notify唤醒就行了。
但是报错了
为什么会有这个异常?先来了解一下wait的操作:
1.先释放锁
2.进行阻塞等待
3.收到通知之后,重新尝试获取锁,并且在获取锁后,继续往下执行。
因此wait操作要搭配synchronized来使用
public class demo2 {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
System.out.println("t1 wait之前");
Thread t1 = new Thread(() -> {
synchronized (object){
try {
object.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("t1 wait之后");
});
Thread t2 = new Thread(() -> {
System.out.println("t2 notif之前");
synchronized (object){
object.notify();
}
System.out.println("t2 notif之后");
});
t1.start();
t2.start();
}
}
同时要注意,只有object四次引用的对象是同一个对象,那么这里的结果才是我们想要的。
wait的带有等待时间的版本,看起来就和sleep有点像,其实还是有本质差别的
虽然都是能指定等待时间,虽然也都能被提前唤醒(wait是使用notify唤醒, sleep使用interrupt唤醒)但是这里表示的含义截然不同。
notify唤醒wait,这是不会有任何异常的。(正常的业务逻辑)interrupt唤醒sleep 则是出异常了。(表示一个出问题了的逻辑)