线程安全介绍
前面我们提到了多线程的概念,由于操作系统对线程的调度是随机的,抢占式执行。因此,在多线程程序中就有可能出现了线程安全问题。
1.线程安全问题
一段代码如果在多线程并发执行的情况下,出现了bug,就称为线程安全问题。反之,如果一段代码在多线程并发执行的情况下,没有出现bug,就是线程安全。
bug就是代码运行后的实际结果与程序员的预期结果不符合。
如以下代码,我们的预期结果是100000,但是,经过我们多次运行之后发现,count的值是五花八门的。于是,该程序运行的结果与我们的预期结果不符合,就说明该程序中有bug,有bug,就说明代码出现了线程安全问题。
public class Demo1 {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++看起来只有一行代码,但是,站在CPU的角度,涉及到3个指令执行的问题,分别为load,add和save。
1. load:把内存中count的值,加载一份到CPU的寄存器中
2. add: 将寄存器中count的值进行加1操作
3. save:最后将修改后的count的值重新存储到内存中
由于count++操作不是原子性(操作指令不只有一条),所以在多并发程序中,由于程序中多个线程的并发执行,可能有线程1刚好执行到load指令(线程1的count++操作没完全执行),就会有线程2开始执行了,线程2可能一口气将这三条指令一口气都执行完了(线程2完成了一次count++操作),这时候内存中的count的值就是1。这时,线程1就又开始运行了,但是由于线程1之前才执行了一个load指令,且之前从内存拿到的值是0,这时候线程1就有开始执行add和save操作(此时线程1才完成了一次count++操作),此时线程1的寄存器中count的值就是1了,然后又将count的值重新存储到了内存中,所以此时内存中count的值还是1.这因此就导致经过了两次count++的操作,然而count的值还是1,不是2。
如下图
上面只是一种情况的分析,在多线程并发执行下,上面运行的情况就会有很多种,因此就导致了线程安全的问题。
2.线程安全问题产生的原因
在介绍线程安全问题的原因之前,我们先了解一个概念:原子性。
原子性:代码的原子性是指一个操作或者一系列操作要么全部执行成功,要么全部不执行,不会执行部分执行的情况。
在多线程并发执行的程序中,一个线程中的一个操作或者一系列操作是一个整体,不会受到其他线程的操作的影响或者打断,这就说明该进程中的操作具有原子性。
原因1.根本原因:操作系统对于线程的调度是随机的,抢占式执行
原因2.多个线程同时修改同一个变量
原因3.修改操作不是原子的
原因4.内存的可见性问题
原因5.指令重排序
注意事项: 一个线程修改一个变量,多个线程不是同时修改同一个变量,多个线程同时修改不同变量,多个线程同时读取同一个变量,以上这些情况是不会出现线程安全问题的。
3.解决线程安全问题
3.1 针对原因1
由于操作系统的底层设定我们程序员是无法干预的,所以我们无法从该角度解决线程安全问题。
3.2 针对原因2
原因2与代码结构相关,我们可以通过适当的调整代码结构来避免这个问题。但是这还是又局限性的,有时候我们的需求就是要同时多个线程同时修改一个变量才能完成。
3.3 针对原因3---原子性
由于一些线程安全问题的产生是由于该代码执行操作的操作指令不是原子性的。
解决方案:我们可以通过锁来解决。用锁将涉及到线程安全问题的代码锁起来。
如上面由于count++导致的线程安全问题也可以用锁来解决。
我们针对于线程1和线程2的count++操作,我们用锁将count++的锁起来。
如上图,线程1和线程2共享一把锁,当线程1先进行count++时,由于锁的原因,线程2必须等线程1完成了count++的操作之后,释放了锁,线程2才能执行count++操作。
在Java中,JVM中也提供了一个关键字来帮助我们实现锁-----syncronized
关键字---syncronized
在面对线程安全问题时,我们的主要解决方案就是通过加锁,通过加锁,我们能够,让一个非原子性的操作变成原子性的操作。
在Java中,JVM中提供了syncronized关键字来实现加锁的操作。
1.语法
syncronized(锁对象){//要进行加锁的操作
}
关于锁对象,Java中的任何类的实例化对象都可以成为锁对象。 锁对象就是我们要用来进行加锁和解锁的锁。
进入syncronized代码块就是枷锁
退出syncronized代码块就是解锁
2.互斥性
互斥性是指当有多个线程的操作都对依靠同一个锁对象进行加锁时,多个线程之间的syncronized就会产生互斥的效果。也就是说,假如线程2执行到syncronized的时候,如果线程1没有释放锁,那么线程2就会阻塞等待,直到线程1将锁释放,线程2才会从阻塞队列中退出,继续执行线程2的操作。
注意:当上一个线程释放锁之后,下一个线程并不是立即获取到该锁的,而是要由操作系统来唤醒下一个线程,这也是操作系统调度线程的一部分工作。
也就是说,假如由1,2,3着几个线程,线程1先获取到锁,接着线程2被操作系统唤醒,线程2就尝试获取锁,然后接着线程3也被操作系统唤醒了,线程3也尝试着获取该锁。但是由于线程1还每释放锁,线程2和线程3就会在阻塞队列中进行等待。当线程1释放锁之后,尽管线程2比线程3被唤醒的早,但是线程2不一定能获取到锁,而是和线程3共同竞争该锁。
3.解决上述案例的线程安全问题
了解了这个,我们就可以解决上述案例产生的线程安全问题了。
我们直到,上述线程安全问题的产生是由于count++操作的指令不是原子产生的,所以这时候,我们可以通过syncronized将count++这个操作锁起来,让其变成原子性的。
public class Demo1 {public static int count=0;public static void main(String[] args) throws InterruptedException {Object locker=new Object();//创建锁对象Thread t1=new Thread(()->{for(int i=0;i<50000;i++){synchronized (locker){count++;}}});Thread t2=new Thread(()->{for(int i=0;i<50000;i++){synchronized (locker){count++;}}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}
通过syncronized对count++操作加锁,也就是将load,add和save这三个指令锁起来了,这样,当线程t1执行count++操作时,这三条指令必须全部执行完,t1才会释放锁,此时t2从才能获取锁,进行count++操作,同样,t2在执行count++操作时,这三条指令全部执行完,t2才释放锁。接着t1才能继续获取该锁......,如此循环下去,直到线程t1和t2的操作全部执行完毕。
因此,通过加锁,在并发执行中,不会出现在线程t1或线程t2在执行count++操作时,load,add,save这三条指令没有全部执行完的情况下,线程t2或t1来进行count++的操作。
注意事项:我们不是使用了syncronized进行加锁就是成功避免了线程安全问题,我们还要考虑要正确得使用锁。
比如,syncronized的 { } 代码块要合适。更重要的是加锁的锁对象必须是同一个锁对象 ,因为多个线程,针对同一个锁对象加锁才会产生互斥。如果多个线程针对不同对象进行加锁,那么多个线程之间是不会产生互斥效果的,这时候可以认为syncronized就失效了。
4.syncronized的可重入性
在Java中,多个syncronized代码块对于同一个线程来说是可重入的,不会将自己锁死的情况。
理解“将自己锁死”
简单来所,就是一个线程中在syncronized代码块中嵌套了syncronized代码块。
也就是一个线程在没有释放锁的情况下,又对同一个锁进行加锁操作。
按照之前对于锁的设定,第二次加锁时,由于所没有被释放,线程就会阻塞等待,直到第一次的锁被释放,线程才能获取该锁。但是由于释放第一个锁也是由该线程执行,第二次加锁也是由该线程执行,这时由于该线程还没有释放锁,第二次就无法获取该锁来进行加锁,结果就是阻塞在第二次加锁那里,就出现了自己将自己锁死的情况。我们将该锁称为“不可重入锁”。
但是,在Java中syncronized是可重入锁,并不会出现上面的问题。
如何实现一个可重入锁?
在可重入所的内部,要有“线程持有者”和“计数器”两个信息。
1.线程在加锁时,我们先判断该锁的线程持有者是不是同一个线程,如果恰好是自己持有该锁,该线程就不会产生阻塞,继续执行下去。
2.如果我们遇到syncronized的 { ,我们就让计数器自增1,解锁的时候遇到 } ,就让计数器自减1,直到计数器减为0,才真正释放该锁。
5.syncronized的其他使用方法
1.syncronized也可以修饰普通成员方法,相当于对this加锁。
2.syncronized也可以修饰被static修饰的成员方法,由于static的存在,就不存在this了,就相当于对类对象加锁。
3.4 针对原因4---内存可见性
可见性是指一个线程对共享变量值的修改,能够及时得被其他线程看到。
如果一个线程共享变量值的修改无法被其他线程看到,也会导致线程安全问题。
如以下例子:
public class Demo4 {public static int flag=0;public static void main(String[] args) {Thread t1=new Thread(()->{while (flag==0){}System.out.println("线程t1结束");});Thread t2=new Thread(()->{Scanner scanner=new Scanner(System.in);System.out.println("请输入flag的值");flag=scanner.nextInt();});t1.start();t2.start();}
}
当我们运行上面代码的时候发现,即使我们输入非0的值,但是此时t1线程中的循环并没有结束,t1线程也在持续运行。我们可以通过jconsole来观察,如下图:
这是什么原因呢? 这就涉及到了Java中编译器会自动帮助优化代码的操作 。
由于程序员的水平参差不齐,所以,在Java中,编译器就会有一个自动优化代码的操作。编译器会保证代码逻辑不变的情况下,自动的帮助程序员优化代码,使代码的运行效率更高。
编译器虽然声称是优化操作,虽然能够保证逻辑不变。但是在某些情况下,尤其是在多线程程序中,编译器会出现判断失误的情况,这就会导致代码在优化前和优化后的逻辑会有些许差异。
如上面的代码
在上面的代码中,线程1有一个循环,线程2需要用户输入flag的值。
上面代码出现线程安全问题的原因在于while(flag==0)这个循环条件的判断操作。虽然flag==0看起来只有一行,但实际上该操作对应上两条CPU指令。分别为load(从内存中读取flag的值),cmp(将从内存中读到的值存到寄存器中)。
问题就是出现在第一条指令,由于load的指令需要从内存中获取变量的值,并且存到寄存器中。此前我们知道,从寄存器中读取数据的速度比从内存中读取数据的速度块了几个数量级的倍数,并且load的时间开销也是cmp时间的几千倍。并且这又是一个循环判断,由于计算机的运行速度非常块且用户不知道什么时候输入flag的新值,在这段时间,循环就进行很多遍,所以此时,编译器发现while循环里面的flag==0该条件的返回结果总是true,所以此时,编译器就会将load(从内存中读取数据的操作)优化成从寄存器中读取数据,由于从寄存器中读取数据的效率比从内存中读取数据的效率高,所以,经过编译器优化后,代码的运行效率就提高了。
但是,这也导致了线程安全问题。当我们在线程2中输入flag的值,并将flag的值存到内存中了,此时,按道理来说,由于flag值得改变,线程t1中的循环就应该结束了。但是由于编译器将线程t1中从内存中读取数据得操作优化成从寄存器中读取数据,这就导致了线程t2修改了flag的值,而线程t1无法感知flag值改变,这就导致线程t1中得循环持续发生。
简单来说,线程t1读取的是自己工作内存中的内容,t2对flag的值进行改变,线程t1无法感知。
如下图
volatile关键字
使用volatile关键字,就可以解决上面的问题。
只要我们用volatile关键字修饰了flag变量,那么就可以强制线程从内存中读取flag,从而保证了内存的可见性。
public class Demo4 {public volatile static int flag=0;public static void main(String[] args) {Thread t1=new Thread(()->{while (flag==0){}System.out.println("线程t1结束");});Thread t2=new Thread(()->{Scanner scanner=new Scanner(System.in);System.out.println("请输入flag的值");flag=scanner.nextInt();});t1.start();t2.start();}
}
3.5 针对原因5---指令重排序
什么是指令重排序?
一段代码的执行逻辑是下面这样子的:
1.去前台取优盘
2.去教室写10分钟作业
3.去前台取快递
如果是在单线程情况下,JVM,CPU指令集会对其进行优化,比如按照1->3->2的方式执行,也是没问题的,少跑一次到前台。这种就叫指令重排序。
编译器对于指令重排序优化的前提是“保程逻辑不发生变化”,这一点在单线程情况下容易判断。但是在多线程环境下就每那么容易了,多线程的代码执行复杂度更高,编译器很难在编译阶段对代码的执行效果进行预测,因此激进的重排序很容易导致优化之后的逻辑于优化之前不等价。
指令重排序的问题也是通过volatile关键字去解决,volatile关键子能确保变量的读取和修改操作不会触发重排序。
后面在讲解单例模式中,会讲解一个指令重排序的案例。