目录
根本原因是在于各个线程是由操作系统随机进行调度,并且各个线程是抢占式执行的。
3.1让synchronized修饰的代码块当中的所有指令变为原子性的
场景3:两个线程,一个调用add()方法来完成count1对象的count值自增。另外一个调用add2()方法,也完成count的自增
(2)可以考虑一些特定的业务场景,使用lock.tryLock(timeout)
一、线程安全问题
1.1什么是线程安全问题?
线程安全问题,指的是在多线程环境当中,线程并发访问某个资源,从而导致的原子性,内存可见性,顺序性问题。以上这三个问题,会导致程序在执行的过程当中,出现了超出预期的结果
1.2线程安全问题产生的原因是什么?
根本原因是在于各个线程是由操作系统随机进行调度,并且各个线程是抢占式执行的。
在多线程的环境当中,存在线程共享的数据。并且:
①当其中多个线程尝试修改共享变量的值,就有可能引发线程安全问题。
②如果读个线程仅仅读但是不修改共享的变量就不会存在线程安全问题。
③如果多个线程串行化,”排好队",挨个修改变量的值就不会引发线程安全问题。
1.修改的操作的原子性问题
如果修改操作是原子的,那也不存在线程安全问题,但是大部分的修改都是非原子性的。
static class Counter1{
int count;
public void add(){
count++;
}
}
如何理解修改操作的原子性呢?
如图所示,此时方法add就是尝试对成员变量"count"进行修改,修改为count+1。这个看似“原子”的操作,站在编译器底层,也就是汇编代码的角度,实际上是非原子的,分为以下三个步骤:
①把count的值从内存当中读取出来;(load)
②执行count++;(add)
③把自增操作之后的count返回到内存当中。(save)
假设count的值为0.
这三个操作当中,如果有一个线程(Thread1)执行到load指令,然后,被操作系统调度离开了CPU。接着,另外一个线程(Thread2)被调度到CPU内核上面执行,连续执行load,add,save指令,此时,save的到内存当中的值为1。
这个时候,Thread1右被重新调度到CPU内核上面执行,由于线程调度是有上下文的,因此,Thread1会继续在刚刚load的值的基础上面进行add,save操作。
那么,此时Thread1save的值仍然为1.与原先期待的两次++操作之后值为2的期待不一样。
图解:
如果把load,add,save这三个操作,变成原子的,不可以在中间细分开。
所谓的原子性问题,就是,当线程进入一段代码块,还没有执行完毕的时候,允许其他线程进入这一段代码块。
2.内存可见性问题:
一个线程针对变量进行修改,可以被其他的线程及时看到。
回看一下官方对于内存可见性的描述:
首先,了解一下(JMM)模型:
在Java虚拟机当中,线程之间共享的变量存在于主内存(main memory)当中。每一个线程都拥有一个自己的"工作内存";
当线程想要读取一个共享的变量的时候,会首先把变量从主内存当中加载到自己的工作内存当中;
然后,先从工作内存当中修改这个变量的值,修改完之后再把修改操作同步到主内存当中。
但是,在多线程的环境下面,一旦有线程修改了共享变量的值,但是,由于编译器优化等等的原因,其他线程读取这个被修改的变量时候,不一定继续读取来自于主内存当中的值,而是仅仅继续读取自己的"工作内存“当中的值。这样,也就造成了读取数据的错误,也就引发了线程安全问题。
对应到操作系统当中,就是这样的场景:
当一个线程尝试修改内存当中变量的值的时候,是按照:
把内存的变量的值读取到寄存器当中,然后首先修改寄存器当中的值,修改完成之后,再修改内存当中的值。但是,在多线程的环境下面,编译器有可能对这一系列指令进行优化,导致其中一个线程修改完内存当中的变量的值之后,其他线程想再次获取这个变量的值的时候,读取到的数据不是来自于内存,而是仅仅读取寄存器当中还没有来得及更新的值,这样,也就引发了线程安全问题。
3.代码顺序性问题:
编译器在保持原有代码逻辑仍然不变的情况下面,对一系列指令进行了重新排序。
如果在单线程的环境下面,是没有任何问题的。但是在多线程的环境下面,仍然执行指令重排序,就有可能被”优化“过之后的代码逻辑发生了改变,从而引发线程安全问题。
二、线程安全问题在代码当中的体现
给出一个业务场景:一个程序当中有三个线程,分别为main线程,线程1,线程2.让线程1调用上面代码块的add方法,让线程2也并发调用上面代码块的add方法。验证运行的结果:代码如下:
public class ThreadHomework2 {
static class Counter1{
int count;
public void add(){
count++;
}
}
public static void main(String[] args) {
System.out.println("....................................."+func());
}
public static int func(){
Counter1 counter1= new Counter1();
long start=System.currentTimeMillis();
Thread thread1=new Thread(new Runnable() {
@Override
public void run() {
for(int i=0;i<500000;i++){
System.out.println("thread1:"+counter1.count);
counter1.add();
}
}
});
thread1.start();
Thread thread2=new Thread(new Runnable() {
@Override
public void run() {
for(int i=0;i<500000;i++){
System.out.println("thread2::::::::"+counter1.count);
counter1.add();
}
}
});
thread2.start();
try {
//此处的join方法是,方便thread1,thread2
//一起调用结束之后,在main线程当中统计时间
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
long end=System.currentTimeMillis();
System.out.println(end-start);
return counter1.count;
}
}
当线程1与线程2并发修改count变量的时候,就会出现"bug“观察运行的结果:
50W+50W,按照期待,应当输出100W。但是此处为什么输出了99W9862,比预期少了呢?这就是两个线程thread1,thread2同时修改变量count的值所造成的影响。图解一下:
以上图示当中:哪个线程最后save,count的值为最终哪个线程save()的值
这就好像两个事物并发执行的道理一样,事物2(thread2)的load操作读取到了 事物1(thread1)还没有保存,即:还没有save()的数据,这也就出现了类似于"脏读”的场景。
回顾一下,在事物当中,是如何解决脏读的问题的?就是给“写”的操作加锁,只有“写”提交之后,才可以“读”,对应的数据。这也是读已提交的事物隔离级别。
三、synchronized关键字的作用
3.1让synchronized修饰的代码块当中的所有指令变为原子性的
那么在代码当中,是如何实现让线程1的三个操作"load,add,save"变成原子呢?那就是加锁:此处,就是使用sychronized修饰add方法;让”load,add,save"这三个操作变为不可再分的原子。
static class Counter1{
int count;
synchronized public void add(){
count++;
}
}
也可以理解为互斥使用,也有的地方称为不可中断:
加上锁->synchronized之后,执行的效果就变成:当两个线程同时调用add方法的时候,其中一个线程假如是(thread1)会竞争到"锁",另外一个线程(thread2)会进入阻塞状态,进入阻塞队列。也就是Thread类的状态当中的BLOCKED状态。等待竞争到锁的线程执行完add方法之后,此时会自动"解锁",也就是"所释放”,Thread2会从阻塞队列当中离开,重新回到就绪队列当中。所谓的互斥使用,就是,两个线程不可以同时拥有一把锁。
所谓的原子性,其实就是,当其中一个线程进入synchronized修饰的代码块当中,要么全部执行完毕,再允许其他的线程进入这一段代码块,要么都不执行。
3.2synchronized保证了内存的可见性:
JMM当中,对于synchronized的内存可见性做了两条规定:
①线程进入同步代码块的时候,将清空这个线程工作内存中共享变量的值,从而使得其他线程如果针对这个变量进行读取操作,一定要去主内存当中读取。
②线程离开synchronized修饰的代码块的时候,将会把这个变量修改之后的状态同步到主内存当中,以此来保证共享变量的可见性。
可以理解为:synchronized可以保证内存的刷新
3.4synchronized保证了有序性
在synchronized代码块当中,每次只允许一个线程进入。可以理解为,在synchronized代码块内部,同一时刻是单线程执行的。因此,即使编译器针对一部分代码进行了优化,但是仍然不影响代码原有的执行逻辑。
如果是多个线程同时进入同步代码块进行修改,此时再发生编译器优化,就有可能导致线程安全问题的发生。
synchronized保证有序性的原理:我们加synchronized后,依然会发生重排序,只不过,我们有同步代码块,可以保证只有一个线程执行同步代码中的代码,因此保证了有序性。需要注意的是,synchronized虽然保证了代码的有序性,但是,不可以阻止编译器进行指令重排序。
四、synchronized的使用
①synchronized修饰成员方法
场景1:两个线程针对同一个对象调用相同的add()方法
可以看到,两个线程thread1,thread2同时并发调用的是同一个对象(counter1)的add方法。这样,就会造成其中一个线程竞争到锁,另外一个线程没有竞争到锁的情况。
假如thread1优先比thread2调度到CPU内核上面执行,那么当thread1执行到add()方法的时候,此时thread1就会对counter1这个对象加锁。
如果没有竞争到锁,那么没有竞争到锁的线程会进入阻塞状态,等待竞争到锁的线程执行完add方法之后,自己再从阻塞状态回到就绪状态。
场景二:如果两个线程分别处理的是两个不同的对象呢?
如下代码:
可以看到,即使没有对add方法采用synchronized关键字修饰,count1最后的值为50W,count2最后的值也为50W。没有出现"bug“。也就是说,如果针对成员方法加锁,那么锁住的是(this),也就是调用这个方法的对象。可以理解为,线程针对对象加锁。此时线程0与线程1,2,3想要同时针对count1对象进行加锁,那么就产生了锁竞争。需要注意的是,如果两个线程,其中一个针对对象加锁,另外一个线程没有针对对象加锁,那么也不会产生锁冲突。产生锁冲突的前提条件一定是两个不同的线程同时竞争一个对象
场景3:两个线程,一个调用add()方法来完成count1对象的count值自增。另外一个调用add2()方法,也完成count的自增
其中add2()为没有加锁的方法
代码实现:
运行结果,也是出现bug的:
此时这种写法,和没有加锁,是一个道理。因为,其中一个线程加了锁,另外一个线程没有加锁,此时就不存在锁竞争的情况;
总结一下以上三个场景:
(1)多个线程同时针对一个对象进行加锁操作,这个时候会出现锁竞争的情况。一旦出现锁竞争,就会出现线程阻塞等待的情况;
(2) 如果两个线程针对不同的对象加锁,不会出现锁竞争的情况;
(3)两个线程,如果一个加锁,另外一个不加锁,那么也不会出现锁竞争的情况。
②修饰静态方法
如果synchronized针对的是一个类的静态代码块,或者静态方法,那么锁住的就是这个方法所在类的类对象:即:类.class。
③修饰代码块
public void add(){
//进入代码块,就"加锁”,离开代码块,就“解锁”
//可以指定任意需要加锁
synchronized (this) {
count++;
}
//下面的代码块没有被加锁
System.out.println("12334444e");
}
含义就是,当线程执行到add()当中加锁的代码块的时候,线程会针对指定的对象加锁,当执行完count++操作之后,自动解锁。在上图的代码当中,count++所在的代码块是被加了锁的,但是下面那句输出没有被加锁。
五、可重入锁,死锁
①什么是可重入锁:
一个线程连续针对同一把锁,连续加锁两次,是否会产生死锁。如果不会阻塞自己,那么就不是可重入锁。如果是,那就会阻塞自己。如果阻塞了自己,那么就相当于产生了”死锁“。
代码:
class Counter{
public int count;
synchronized public void add(){
synchronized (this) {
count++;
}
}
}
分析一下代码的执行流程:
当多个线程并发调用add()方法的时候,其中一个线程(thread1)可以获取到锁,其中,该线程针对调用的对象,count1加锁。此时该线程获得了锁,其余线程进入阻塞状态。被加锁的对象就是(count1)
当进入add()方法内部的时候,遇到了synchronized修饰的代码块。此时线程1继续尝试获取锁。前面我们也提到,加锁是线程针对对象加锁。可是当调用add()方法的时候,已经针对(this,也就是该方法的调用者count1)加锁了,当再次尝试获取锁的时候,是否会阻塞呢?
我们回顾一下什么情况下会出现因为加锁而产生的线程阻塞:当多个线程尝试竞争同一个未被加锁的对象的时候,没有竞争到锁的线程会进入阻塞状态。
换而言之:如果一个对象被加锁了,那么其他线程想继续获取到这个对象的锁,那么其他线程都无法获取到,都会进入阻塞状态。
那么此时,针对count1,也就是下一个synchronized代码块当中的this,不允许其他线程继续对this加锁,但是,如果thread1此时也不允许对synchronized代码块中的代码加锁的话,thread1也会继续进入阻塞状态。这样的话,所有的线程都进入了阻塞状态。
总结一下:如果一个线程针对同一把锁连续两次加锁,在第二次尝试加锁的时候,不会让自身进入阻塞等待的状态,那么称这个锁是可重入锁。根据代码的执行情况,synchronized就是可重入锁。
其中,java当中还有ReentrantLock也是可重入锁
②死锁产生的条件
先举一个形象一点的例子:小明和小红一起饺子,其中小明先拿到了酱油,小明对小红说,你把醋给我,我就把酱油给你。小红一听很不乐意,他反过来说,你如果把酱油先给我,我才会把醋给你。这个时候,两个人就僵持住了,谁也给不出。也就产生了“死锁”。
死锁的代码:
public class ThreadDemo15 {
public static void main(String[] args) {
Object jiangyou=new Object();
Object cu=new Object();
//xiaoMIng为其中一个线程
Thread xiaoMing=new Thread(new Runnable() {
@Override
public void run() {
//让线程小明先获取到"酱油"
synchronized (jiangyou){
//次数加"sleep"是为了让当前线程获取到对应的锁之后先阻塞
//让另外的线程先获取到下面的锁
//如果不加sleep,那么有可能其中一个线程同时锁住了两个对象
System.out.println("线程1获取到了酱油");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//此时,线程小明会等待小红先释放醋,小明进入阻塞状态
synchronized (cu){
System.out.println("小明同时获取到了酱油的醋");
}
}
}
});
xiaoMing.start();
//xiaoHong为另外一个线程
Thread xiaoHong=new Thread(new Runnable() {
@Override
public void run() {
//让线程小红先获取到"醋"
synchronized (cu){
System.out.println("线程2获取到了醋");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//此时,线程小红会等待小明先释放酱油,小红进入阻塞状态
synchronized (jiangyou){
System.out.println("小红同时获取到了醋和酱油");
}
}
}
});
xiaoHong.start();
}
}
A.死锁准确的定义:
死锁指的在是一组线程当中,由于线程竞争共享的资源,因为互相等待,因而导致了“永久”阻塞的现象,如果没有外力终止程序,将永远无法结束进程。
B.分析一下死锁产生的四个必要条件:
(1)互斥使用:
在上面的场景当中,线程小明拿到了锁(酱油);线程小红也拿到了锁(醋)。
这个时候,如果线程小明尝试获取被小红加锁的对象”醋“,那么小明就会获取锁失败。进而进入阻塞的状态。小红如果想获取小明的锁(酱油)也同理。
所以,互斥使用的含义就是:其中一个线程拿到了锁,其他与当前线程竞争锁的线程就必须进入阻塞状态,等待锁的释放。
(2)不可抢占:
线程1拿到锁之后,线程1一定要主动释放锁,其他线程才可以获取到。
(3)请求和保持:
在上述的场景当中,线程小明获取到锁"酱油"之后,当他想再次获取到小红的醋的时候,不会因为去尝试获取小红的醋而丢失了本来属于自己的锁:酱油。、
所以,请求和保持就是:
线程1获取到对应的一把锁之后,如果想尝试再次获取其他的锁,当前拥有的锁仍然是保持的,不会丢失。
(4)循环等待:
如上面的场景,小红和小明都在等待对方先释放锁,但是都没有自行先释放锁。这个就是循环等待。在线程当中,循环等待的场景就是:
两个线程都在等待获取到对应的锁之后,都在等待对方释放锁。这就是循环等待
注意:以上四个条件,缺一不可,是出现死锁的充分必要条件,但是。归根结底,还是因为synchronized是必须要等到获取到锁的线程执行完加锁的代码块之后,其他线程才能继续获取到当前的锁。但是,其他的锁不一定跟synchronized一样。
③如何避免死锁
应当从产生死锁的原因出发:
(1)尽量避免一个线程同时获取多把锁;
(2)可以考虑一些特定的业务场景,使用lock.tryLock(timeout)
限制加锁的时间,来代替synchronized。因为synchronized是一定要等到正在执行同步代码块的线程解锁之后,其他线程才可以获取锁。
(3)针对锁进行"排序",规定所有线程加锁的顺序。
假设一个进程当中有n个线程和m把锁,如果想要加锁的话,可以在代码编写的时候对加锁的顺序进行规定,所有的线程都必须按照从1号锁到2号锁......到m号锁的顺序进行加锁。不可以打乱这个加锁的顺序。
在刚刚的程序当中,如果分别对酱油和醋,这两把锁进行编号,规定酱油位1,醋位2;小明和小红都必须按照1,2的顺序来拿酱油和醋,就不会发生相互阻塞的情况了;
代码实现:
线程1 线程2:
public class ThreadDemo15 {
public static void main(String[] args) {
Object lock1=new Object();
Object lock2=new Object();
//xiaoMIng为其中一个线程
Thread xiaoMing=new Thread(new Runnable() {
@Override
public void run() {
//让线程小明先获取到"酱油"
synchronized (lock1){
//次数加"sleep"是为了让当前线程获取到对应的锁之后先阻塞
//让另外的线程先获取到下面的锁
//如果不加sleep,那么有可能其中一个线程同时锁住了两个对象
System.out.println("线程1获取到了酱油");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//此时,线程小明会等待小红先释放醋,小明进入阻塞状态
synchronized (lock2){
System.out.println("小明同时获取到了酱油的醋");
}
}
}
});
xiaoMing.start();
//xiaoHong为另外一个线程
Thread xiaoHong=new Thread(new Runnable() {
@Override
public void run() {
//让线程小红先获取到"醋"
synchronized (lock1){
System.out.println("线程2获取到了醋");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//此时,线程小红会等待小明先释放酱油,小红进入阻塞状态
synchronized (lock2){
System.out.println("小红同时获取到了醋和酱油");
}
}
}
});
xiaoHong.start();
}
}
六、总结一下,synchronized的特性
①互斥使用:
当多个线程同时竞争一把锁的时候,仅仅只有其中一个线程可以获取到锁,其他线程必须要阻塞等待,直到竞争到锁的线程执行完同步代码块(也就是synchronized修饰的代码块)的时候,才可以进入同步代码块,执行任务。
②可重入:
参考上面的解释。
③刷新内存:
参考3.3