线程安全介绍

发布时间:2026/7/5 21:00:54
线程安全介绍 前面我们提到了多线程的概念由于操作系统对线程的调度是随机的抢占式执行。因此在多线程程序中就有可能出现了线程安全问题。1.线程安全问题一段代码如果在多线程并发执行的情况下出现了bug就称为线程安全问题。反之如果一段代码在多线程并发执行的情况下没有出现bug就是线程安全。bug就是代码运行后的实际结果与程序员的预期结果不符合。如以下代码我们的预期结果是100000但是经过我们多次运行之后发现count的值是五花八门的。于是该程序运行的结果与我们的预期结果不符合就说明该程序中有bug有bug就说明代码出现了线程安全问题。public class Demo1 { public static int count0; public static void main(String[] args) throws InterruptedException { Thread t1new Thread(()-{ for(int i0;i50000;i){ count; } }); Thread t2new Thread(()-{ for(int i0;i50000;i){ count; } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); } }上面代码出现线程安全问题的原因分析主要问题出现在count这一行代码上虽然count看起来只有一行代码但是站在CPU的角度涉及到3个指令执行的问题分别为loadadd和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的操作。注意当上一个线程释放锁之后下一个线程并不是立即获取到该锁的而是要由操作系统来唤醒下一个线程这也是操作系统调度线程的一部分工作。也就是说假如由123着几个线程线程1先获取到锁接着线程2被操作系统唤醒线程2就尝试获取锁然后接着线程3也被操作系统唤醒了线程3也尝试着获取该锁。但是由于线程1还每释放锁线程2和线程3就会在阻塞队列中进行等待。当线程1释放锁之后尽管线程2比线程3被唤醒的早但是线程2不一定能获取到锁而是和线程3共同竞争该锁。3.解决上述案例的线程安全问题了解了这个我们就可以解决上述案例产生的线程安全问题了。我们直到上述线程安全问题的产生是由于count操作的指令不是原子产生的所以这时候我们可以通过syncronized将count这个操作锁起来让其变成原子性的。public class Demo1 { public static int count0; public static void main(String[] args) throws InterruptedException { Object lockernew Object();//创建锁对象 Thread t1new Thread(()-{ for(int i0;i50000;i){ synchronized (locker){ count; } } }); Thread t2new Thread(()-{ for(int i0;i50000;i){ synchronized (locker){ count; } } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); } }通过syncronized对count操作加锁也就是将loadadd和save这三个指令锁起来了这样当线程t1执行count操作时这三条指令必须全部执行完t1才会释放锁此时t2从才能获取锁进行count操作同样t2在执行count操作时这三条指令全部执行完t2才释放锁。接着t1才能继续获取该锁......,如此循环下去直到线程t1和t2的操作全部执行完毕。因此通过加锁在并发执行中不会出现在线程t1或线程t2在执行count操作时loadaddsave这三条指令没有全部执行完的情况下线程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 flag0; public static void main(String[] args) { Thread t1new Thread(()-{ while (flag0){ } System.out.println(线程t1结束); }); Thread t2new Thread(()-{ Scanner scannernew Scanner(System.in); System.out.println(请输入flag的值); flagscanner.nextInt(); }); t1.start(); t2.start(); } }当我们运行上面代码的时候发现即使我们输入非0的值但是此时t1线程中的循环并没有结束t1线程也在持续运行。我们可以通过jconsole来观察如下图这是什么原因呢 这就涉及到了Java中编译器会自动帮助优化代码的操作 。由于程序员的水平参差不齐所以在Java中编译器就会有一个自动优化代码的操作。编译器会保证代码逻辑不变的情况下自动的帮助程序员优化代码使代码的运行效率更高。编译器虽然声称是优化操作虽然能够保证逻辑不变。但是在某些情况下尤其是在多线程程序中编译器会出现判断失误的情况这就会导致代码在优化前和优化后的逻辑会有些许差异。如上面的代码在上面的代码中线程1有一个循环线程2需要用户输入flag的值。上面代码出现线程安全问题的原因在于while(flag0)这个循环条件的判断操作。虽然flag0看起来只有一行但实际上该操作对应上两条CPU指令。分别为load从内存中读取flag的值cmp将从内存中读到的值存到寄存器中。问题就是出现在第一条指令由于load的指令需要从内存中获取变量的值并且存到寄存器中。此前我们知道从寄存器中读取数据的速度比从内存中读取数据的速度块了几个数量级的倍数并且load的时间开销也是cmp时间的几千倍。并且这又是一个循环判断由于计算机的运行速度非常块且用户不知道什么时候输入flag的新值在这段时间循环就进行很多遍所以此时编译器发现while循环里面的flag0该条件的返回结果总是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 flag0; public static void main(String[] args) { Thread t1new Thread(()-{ while (flag0){ } System.out.println(线程t1结束); }); Thread t2new Thread(()-{ Scanner scannernew Scanner(System.in); System.out.println(请输入flag的值); flagscanner.nextInt(); }); t1.start(); t2.start(); } }3.5 针对原因5---指令重排序什么是指令重排序一段代码的执行逻辑是下面这样子的1.去前台取优盘2.去教室写10分钟作业3.去前台取快递如果是在单线程情况下JVMCPU指令集会对其进行优化比如按照1-3-2的方式执行也是没问题的少跑一次到前台。这种就叫指令重排序。编译器对于指令重排序优化的前提是“保程逻辑不发生变化”这一点在单线程情况下容易判断。但是在多线程环境下就每那么容易了多线程的代码执行复杂度更高编译器很难在编译阶段对代码的执行效果进行预测因此激进的重排序很容易导致优化之后的逻辑于优化之前不等价。指令重排序的问题也是通过volatile关键字去解决volatile关键子能确保变量的读取和修改操作不会触发重排序。后面在讲解单例模式中会讲解一个指令重排序的案例。