Java 线程安全问题成因及解决方案
目录1 线程不安全产生的根本原因1.1 线程调度具备随机性1.2 多线程同时修改共享数据1.3 并发三大特性被破坏1.3.1 原子性1.3.2 可见性1.3.3 有序性指令重排序2 现阶段学习到的线程安全解决方案2.1 synchronized 同步锁2.1.1 同步代码块2.1.2 同步方法2.2 volatile 关键字2.2.1 volatile作用2.2.2 volatile 局限性无法保证原子性3 总结前言在 Java 并发场景下多个线程同时操作共享可变数据时会很容易产生线程安全问题。我们想要彻底理解并发首先需要弄懂线程不安全的成因理解原子性、可见性、有序性三大特性。1. 线程不安全产生的根本原因1.1 线程调度具备随机性为什么说线程调度是具备随机性,也会产生线程安全问题呢?举一个例子:有一个线程A,在执行过程中,执行到一半被让出CPU,线程B切入修改数据;后续线程A恢复继续执行,此时就会读到一个旧的数据,引起一个错误;总体来说就是: 操作系统对线程采用抢占式调度线程何时被CPU分配时间片、什么时候暂停执行开发者无法控制。由于程序员没法直接控制线程的调度,可能会出现同一个代码,线程执行的顺序都是不一样的,这是并发问题出现的前提; 返回目录1.2 多线程同时修改共享数据其次就是当多个线程对同一个数据进行修改时,如果没用进行处理就会产生线程安全问题:产生条件:存在多线程并发环境至少有两条及以上线程同时运行存在共享可变数据多个线程访问同一个变量成员变量、静态变量等存在写操作线程对共享变量执行修改、赋值操作。举个例子:定一个一个count成员变量静态变量,对同一个静态成员变量进行自增5W次操作publicclassdemo1{staticinta0;publicstaticvoidmain(String[]args)throwsInterruptedException{Threadt1newThread(()-{for(inti0;i50000;i){a;}});Threadt2newThread(()-{for(inti0;i50000;i){a;}});t1.start();t2.start();t1.join();t2.join();System.out.println(a);}}我们知道,正常情况下单线程中a的结果应该是10W,而进入到了多线程中没用进行任何干预就会出现问题;得到的结果与我们想要的结果不符合,这就是线程安全问题;我们调用了很多次,每一次结果都是不一样的,此时就出现了线程安全的问题;我们可以从CPU的角度来具体看看线程安全出现的一个原因,为什么得到的结果不符合预期值;假设:CPU在执行a的时候分为这3大步骤:load,add,saveload:将数据拿到寄存器;add:对a进行操作save:将结果写会内存正常情况下:此时的结果应该为2,但是此时我们写会内存中的的值只有1;两次自增操作只完成一次有效更新也就是丢失更新。此时这种情况就是线程安全问题,得到的结果永远是小于等于10W;这个现象本质是并发三大特性中的原子性遭到破坏接下来我们详细介绍并发三大特性。 返回目录1.3 并发三大特性被破坏1.3.1 原子性简单理解就是:一个操作不可分割要么全部执行成功要么全部执行失败中途不会被其他线程打断。JVM层面:int,boolean等基础类型单次读写天然就具备原子性,但是我们刚才涉及到的i或者i--都被分为取值,运算,赋值三步,在执行过程中,他们中间可能调用了别的线程指令,因此不具备原子性 返回目录1.3.2 可见性什么是可见性?可见性:当一个线程修改了共享变量的值其他线程能立刻感知到最新值不会读取 CPU 缓存的旧数据。例如:publicclassdemo1{staticbooleanflgtrue;publicstaticvoidmain(String[]args){Threadt1newThread(()-{while(flg){;}});t1.start();ScannerscannernewScanner(System.in);intnscanner.nextInt();if(n0){flgfalse;}}}这是一个很经典的一个例子:定义一个静态变量flg进行while循环的判定,通过改变flg的值来停止循环;此时我们输入了一个0,但是循环并没用停止下来,这是为什么呢?-编译器优化当前方面我们能从CPU和JMM两个角度来说:CPU角度:CPU 拥有 L1/L2/L3 高速缓存速度远快于内存CPU在进行读取的时候,会先将数据读到寄存器上,我们进行输入的操作,站在我们的角度来说是一件很快的事情,而站在CPU角度就不一样了,CPU认为你这么长的时间都是一个不变的值,就直接在寄存器中进行读取,而我们通过修改flg的值,想要去停止循环,while循环中读取的值还没有更新到寄存器中,后续循环反复读取flgCPU 优化直接复用缓存内旧值不再访问主内存;JMM角度(Java内存模型)JMM 规定每个线程拥有独立工作内存共享变量存于主内存子线程将主内存flg加载到自己的工作内存循环时一直读取本地工作内存不再去主内存获取。JVM 无强制同步机制子线程不会主动重新拉取主内存最新值可见性丢失JVM 即时编译器发现循环内没有修改flg判定flg永远为 true直接把while(flg)优化为无限死循环while(true)彻底不再读取变量。工作内存:CPU寄存器 返回目录1.3.3 有序性指令重排序)有序性单线程环境下代码书写的执行顺序和 CPU、编译器实际执行顺序保持一致。什么是指令重排序呢?在编译器,CPU,内存硬件为了提升执行效率提升的一种方式.CPU,内存等硬件方面可以提高设备来提升执行效率,但是在编译器的角度来说,硬件是提升不了的;指令重排序编译器、CPU、内存硬件为了提升运行效率在 单线程逻辑无错误 的前提下打乱代码指令的执行顺序单线程无害多线程会出现逻辑错乱。举个生活中很简单的一个例子:一天小萌的妈妈喊小萌去 超时买菜: 要买西红柿,鸡蛋,猪肉,黄瓜如果我我们此时按照菜单上买此时这样买菜就会耗费一定时间在走路上而此时,编译器就相当于在代码得到的结果一样的情况下,进行优化需要走的流程这就可以理解为指令重排序; 返回目录2. 现阶段学习到的线程安全解决方案2.1 synchronized 同步锁作用:synchronized 可一次性同时保障原子性、可见性、有序性解决多线程并发全部三大问题保证被锁代码块同一时间仅能有一个线程执行操作不可被打断synchronized可以将代码块进行一个打包操作;就好比我们去公共卫生间上厕所,厕所门都是带锁的,进去的时候将门锁上,此时外面的人就不能再进来上厕所一个道理;注意:将代码打包而不是将指令一块执行,当前的上锁操作,只是防止其他线程来进行插足,其他线程想要操作没有锁,就会进入堵塞等待情况而将指令打包一块执行的是有关于:临界区此处不过多结束临界区; 返回目录2.1.1 同步代码块以当前代码为例子:publicclassdemo1{staticinta0;publicstaticvoidmain(String[]args)throwsInterruptedException{Threadt1newThread(()-{for(inti0;i50000;i){a;}});Threadt2newThread(()-{for(inti0;i50000;i){a;}});t1.start();t2.start();t1.join();t2.join();System.out.println(a);}}此时两个线程对a进行自增5W次,我们可以对a自增进行加锁操作,也可以对整一个for循环进行加锁;此时我们需要创建出一个对象,来当作锁钥匙,前提,两个锁对象必须是相同的,不然不会起到锁的作用ObjectlocknewObject();Threadt1newThread(()-{for(inti0;i50000;i){synchronized(lock){a;}}});Threadt2newThread(()-{for(inti0;i50000;i){synchronized(lock){a;}}});t1.start();t2.start();t1.join();t2.join();System.out.println(a);我们也可以进行对for循环进行加锁操作;synchronized(lock){for(inti0;i50000;i){a;}}此时针对于for循环加锁一个线程全程只需要进行一次加锁解锁的操作; 返回目录2.1.2 同步方法还是基于上面写法,修改a在方法内部进行自增我们此时可以通过给方法进行加锁;publicclassdemo1{publicsynchronizedstaticvoidadd(){for(inti0;i50000;i){a;}}publicstaticvoidmain(String[]args)throwsInterruptedException{ObjectlocknewObject();Threadt1newThread(()-{demo1.add();});Threadt2newThread(()-{demo1.add();});t1.start();t2.start();t1.join();t2.join();System.out.println(a);}}当然我们也能写成这样的一个代码:publicvoidadd(){synchronized(this){for(inti0;i50000;i){a;}}}此时就需要我们创建一个对象,同一个对象去调用这一个方法,也不会产生线程安全的问题; 返回目录2.2 volatile关键字2.2.1 volatile作用volatile只保障可见性、有序性不保证原子性这是和synchronized最核心的区分。写 volatile 变量线程修改完成后强制将工作内存数据立刻刷入主内存。读 volatile 变量线程直接清空本地缓存强制从主内存加载最新变量。例如刚才我们写的代码:publicclassdemo1{staticbooleanflgtrue;publicstaticvoidmain(String[]args){Threadt1newThread(()-{while(flg){;}});t1.start();ScannerscannernewScanner(System.in);intnscanner.nextInt();if(n0){flgfalse;}}}此时我们知道,这是内存可见性引起的一个问题;想要解决一个问题我们就需要使用volatile这个关键字来解决问题;使用volatile修饰flgpublicclassdemo1{staticvolatilebooleanflgtrue;publicstaticvoidmain(String[]args){Threadt1newThread(()-{while(flg){;}});t1.start();ScannerscannernewScanner(System.in);intnscanner.nextInt();if(n0){flgfalse;}}}此时当我们再输入0的时候,线程中的死循环就会停止下来;此时volatile就相当于告诉编译器这里可能会产生线程可见性的问题,你需要注意一下,此时while循环中的读取就会在内存中进行读取留下一个问题:当我们用同一个对象,在方法内部上一次锁,和在线程中再上一次锁时,编译器是否会报错? 返回目录2.2.2 volatile 局限性无法保证原子性原子性的要求操作必须是不可拆分的单步骤要么全部执行成功要么全部失败中途不能被其他线程插队。常见反例count自增操作读取从主内存获取 count 当前值运算CPU 执行 1 计算回写把新值赋值给 countvolatile 只能保证步骤 1 读取一定拿主存最新值步骤 3 写完立刻刷回主存。但 volatile 无法锁住当前锁中的操作,不能阻止其他线程的插队现象。多线程下线程调度会出现线程交替执行线程 A 读到 count10还没完成 1 回写线程 B 同时也读到 count10两个线程最终都写入 11一次自增丢失总数变少。 返回目录3.总结Java 并发问题本质来自 JMM/CPU分为原子性、可见性、有序性。synchronized 写法分 this 实例锁、class 类锁、自定义代码块锁。volatile 我们需要只管可见、有序不管原子只能用在单次读写场景自增这类操作不能单独用 volatile。原子类开关变量用 volatile复杂同步用 synchronized。 返回目录