上文我们了解了单线程以及线程的一些基本常见方法但是多线程在一些方面会存在安全问题此文我们来为多线程的安全 保驾护航 详情请见下文1. 多线程带来的风险——线程安全1.1 观察线程不安全/** * 使用两个线程让count自增到10w */publicclassThread_lesson03_01{//定义一个变量让其自增10w次privatestaticintcount0;publicstaticvoidmain(String[]args)throwsInterruptedException{Threadthread1newThread(()-{for(inti0;i50000;i){count;}});thread1.start();Threadthread2newThread(()-{for(inti0;i50000;i){count;}});thread2.start();//等待线程结束thread1.join();;thread2.join();//结束后打印计算的count值System.out.println(countcount);}}1.2 线程安全的概念想给出⼀个线程安全的确切定义是复杂的但我们可以这样认为如果多线程环境下代码运行的结果是符合我们预期的即在单线程环境应该的结果则说这个程序是线程安全的1.3 线程不安全的原因1. 线程调度是随机的由于线程的执行顺序无法人为控制抢占式执行是造成线程安全问题的主要原因而且我们解决不了完全是CPU自己调度而且和CPU核数有关2. 修改共享数据多个线程修改同一个变量会出现线程安全问题多个线程修改不同的变量不会出现线程安全问题一个线程修改一个变量也不会出现线程安全问题3. 原子性我们在前面的MySQL学习过程中知道了事务是有原子性的比如对一个事务如果不让他完全执行任务时就对他操作会造成幻读不可读等等不好的效果MySQL中我们通过隔离级别保证事务的原子性原子性即 事务要么全都执行要么全都不执行在线程中我们知道⼀条Java语句不⼀定是原子的也不⼀定只是⼀条指令比如刚才我们看到的count其实是由三步操作组成的从内存把数据读到CPU LOAD进行数据更新 ADD把数据写回到CPU (STORE)由于线程是抢占式执行的此处通过时间线来助于理解线程的原子性下图对线程的原子性助以理解由于执行CPU指令不是原子性的导致这三条指令没有全部执行完成就被CPU调度走了另外的线程加载到的值是一个原始值,当两个线程分别完成自增操作之后把值写回内存时发生了覆盖现象4. 内存可见性可见性指,⼀个线程对共享变量值的修改能够及时地被其他线程看到.Java虚拟机规范中定义了Java内存模型(JMM)如下图所示线程之间的共享变量存在主内存(MainMemory).每⼀个线程都有自己的⼯作内存(WorkingMemory)且线程工作内存之间是隔离的线程对共享变量的修改线程执行相互感知不到当线程要读取⼀个共享变量的时候,会先把变量从主内存拷贝到⼯作内存,再从⼯作内存读取数据.当线程要修改⼀个共享变量的时候,也会先修改⼯作内存中的副本,再同步回主内存.工作内存是JAVA层面对物理层面的关于程序所使用到了寄存器的抽象如果通过某种方式 让线程之间可以相互通信称之为内存可见性5.指令重排序指令重排序概念重排序是编译器、JVM和CPU为了提高执行效率对指令顺序进行调整的现象。它在保证单线程语义不变的前提下减少了读写操作提升了程序运行速度并且保证程序的运行结果是正确。重排序分为编译器优化、CPU重排序和内存系统的“重排序”。虽然带来性能提升但也要注意其可能影响多线程环境中的数据一致性。2. 如何解决线程不安全问题1.线程的调度是随机执行的硬件层面的我们解决不了2.修改共享数据在真实业务场景中很难避免多线程提供效率我们解决不了3.原子性我们可以通过锁实现原子性下文介绍4.内存可见性我们可以让进程之间通过一种通信关系解决内存的不可见性5.指令重排序我们可以程序员直接指定那个先执行能够解决 3、4、5 其中一项我们的线程不安全问题就可以解决3.synchronized关键字3.1 synchronized 的特性synchronized 会起到互斥效果,某个线程执行到某个对象的synchronized中时,其他线程如果也执行到同⼀个对象synchronized就会阻塞等待.进⼊synchronized修饰的代码块,相当于加锁退出synchronized修饰的代码块,相当于解锁synchronized⽤的锁是存在Java对象头⾥的。可以粗略理解成,每个对象在内存中存储的时候,都存有⼀块内存表示当前的锁定状态(类似于厕所 的有人/⽆人).如果当前是⽆人状态,那么就可以使用,使⽤时需要设为有人状态.如果当前是有人状态,那么其他人无法使用,只能排队理解阻塞等待针对每⼀把锁,操作系统内部都维护了⼀个等待队列.当这个锁被某个线程占有的时候,其他线程尝试进⾏加锁,就加不上了,就会阻塞等待,⼀直等到之前的线程解锁之后,由操作系统唤醒⼀个新的线程, 再来获取到这个锁.注意:上⼀个线程解锁之后,下⼀个线程并不是立即就能获取到锁.而是要靠操作系统来唤醒.这也就 是操作系统线程调度的一部分工作.假设有ABC三个线程,线程A先获取到锁,然后B尝试获取锁,然后C再尝试获取锁,此时B和C 都在阻塞队列中排队等待.但是当A释放锁之后,虽然B比C先来的,但是B不⼀定就能获取到锁, 而是和C重新竞争,并不遵守先来后到的规则.3.2 synchronize关键字的魔力我们通过下列代码来探究synchronize的魔力吧3.2.1 代码1不添加synchronize关键字查看计算结果publicclassThread_lesson04_01{publicstaticvoidmain(String[]args)throwsInterruptedException{//初始化累加对象Counter01counter01newCounter01();//创建两个线程对一个变量进时累加Threadthread1newThread(()-{counter01.increase();});Threadthread2newThread(()-{counter01.increase();});// 启动线程thread1.start();thread2.start();//等待线程thread1.join();thread2.join();System.out.println(counter01.count);}}classCounter01{publicintcount0;publicvoidincrease(){for(inti0;i50000;i){count;}}}3.2.2 代码2给increase方法加上synchronize关键字publicclassThread_lesson04_02{publicstaticvoidmain(String[]args)throwsInterruptedException{Counter02counternewCounter02();Threadthread1newThread(()-{counter.increase();});Threadthread2newThread(()-{counter.increase();});thread1.start();thread2.start();thread1.join();thread2.join();System.out.println(countcounter.count);}}classCounter02{publicintcount0;publicsynchronizedvoidincrease(){for(inti0;i50000;i){count;}}}此时thread1线程先获取了锁方法执行完成之后thread2线程再获取锁这样的情况是一个单线程运行状态是把多线程转换为一个单线程从而解决线程安全问题但并不是我们想要的效果3.2.3 代码3给increase方法中的代码块加上synchronize关键字publicclassThread_lesson04_03{publicstaticvoidmain(String[]args)throwsInterruptedException{Counter03counternewCounter03();Threadthread1newThread(()-{counter.increase();});Threadthread2newThread(()-{counter.increase();});thread1.start();thread2.start();thread1.join();thread2.join();System.out.println(countcounter.count);}}classCounter03{publicintcount0;publicvoidincrease(){//在真实业务中在执行锁代码块之前有很多的数据获取或其他的可以并行执行的逻辑//1.从数据库中查询数据 selectAll()//2.对数据进行处理 build()//3.其他的不修改共享变量的方法//......//当执行到修改共享变量的逻辑时再加锁//被锁修饰的代码块用{}包裹其中()中可以是任何对象使用this就是当前调用该方法的对象synchronized(this){for(inti0;i50000;i){count;}}}}虽然当前代码依旧是按串行执行了但是在锁定的代码块前后有其他的方法或代码可以进程执行3.2.4 代码4定义方法increase和方法increase1increase方法用synchronize关键字修饰线程1调用increase方法线程2调用increase1方法publicclassThread_lesson04_04{publicstaticvoidmain(String[]args)throwsInterruptedException{Counter04counternewCounter04();Threadthread1newThread(()-{counter.increase();});Threadthread2newThread(()-{counter.increase1();});thread1.start();thread2.start();thread1.join();thread2.join();System.out.println(countcounter.count);}}classCounter04{publicintcount0;publicsynchronizedvoidincrease(){for(inti0;i50000;i){count;}}publicvoidincrease1(){for(inti0;i50000;i){count;}}}由于increase1方法不加锁在对increase1方法方法执行时increase1方法不需要锁即可执行导致了线程不安全问题具体情况如下总结 关于synchronized1.被 synchronized 修饰的代码会变成串行执行2.synchronized 可以去修饰方法也可以修饰代码块3.被synchronized 修饰的代码并不是一次性在CPU上执行完而是中途可能会被CPU调度走,但是只有当所有的指令执行完成之后才会释放锁4.只给一个线程加锁也会出现线程安全问题了解完了synchronize关键字后我们来谈谈何为锁锁又有哪些知识呢4. 锁我们知道事务的隔离级别是通过锁和MVCC机制保证的那么Java当中锁是用什么来实现的呢锁存放在哪里呢4.1 锁是如何解决线程安全问题的解决线程非原子性通过synchronize给方法加锁解决了原子性问题解决内存不可见性后一个线程永远读到的是上一个线程存放到主内存的值通过这样的方式实现了内存可见性并没有对内存可见性做技术上的处理解决不了重排序问题8.2 锁存放的位置在Java虚拟机中对象在内存中的结构可以划分为4个区域markword: 对象头:锁信息、GC(垃圾回收)次数程序计数器一般为8BYTE类型指计:当时的对象是哪个类一般为4BYTE实例数据:成员变量不定对齐填充:一个对象所的占的内存必须是8byte的整数倍根据实例数据确定5. volatile 关键字上面我们了解到引发线程不安全的问题有几种情况线程的调度是随机的多个线程修改了共享数据原子性内存不可见性指令重排序其中线程的调度是随机的我们解决不了对于共享数据的修改我们使用了synchronize关键字对修改共享数据的代码进行加锁实现了原子性由于原子性让代码串行执行间接的实现了内存的可见性然而在真正意义上并没有对内存的不可见性做修改Java中提供了volatile关键字实现了内存可见性和禁止指令重排序我们现在开始来揭开volatile神秘的面纱吧volatile的作用volatile 修饰的变量, 能够保证 “内存可见性”也可以解决有序性的问题禁止指令重排序5.1 内存可见性代码在写入 volatile 修饰的变量的时候改变线程工作内存中volatile变量副本的值将改变后的副本的值从工作内存刷新到主内存代码在读取 volatile 修饰的变量的时候从主内存中读取volatile变量的最新值到线程的工作内存中从工作内存中读取volatile变量的副本前⾯我们讨论内存可见性时说了, 直接访问工作内存(实际是 CPU 的寄存器或者 CPU 的缓存),速度非常快, 但是可能出现数据不⼀致的情况.加上 volatile , 强制读写内存. 速度是慢了, 但是数据变的更准确了.我们提供一段代码示例来揭开volatile的魅力吧在这个代码中创建两个线程 thread1 和 thread2thread1中包含⼀个循环, 这个循环以 flag ! 0 为循环条件.thread2中从键盘读⼊⼀个非0整数, 并把这个整数赋值给 flag.预期当用户输入 !0 的值的时候, thread1 线程结束.publicclassThread_lesson05_01{staticintflag0;publicstaticvoidmain(String[]args){Threadthread1newThread(()-{System.out.println(thread1线程启动...);while(flag0){//do nothing}System.out.println(thread1线程退出...);});//想要通过线程2对flag的值进行修改让线程1停止Threadthread2newThread(()-{ScannerscannernewScanner(System.in);System.out.println(thread2线程启动...);System.out.println(输⼊⼀个非0整数:);flagscanner.nextInt();});//启动线程thread1.start();thread2.start();}}为何thread2对flag值的修改thread1无法感知到呢thread1 读的是自己的工作内存(寄存器)中的内容.当 thread2 对 flag 变量进行修改, 此时 thread1 感知不到 flag 的变化.图解下面我们试着给flag加上volatile关键字修饰查看效果staticvolatileintflag0;我们发现通过volatile关键字解决了内存不可见问题那么volatile是究竟如何解决的呢我们知道为了解决内存的可见性问题最重要的就是当一个线程修改了另一个线程需要的变量必须让另一个线程感知到在CPU层面在Java层面内存屏障作用是保证指令执行的先后顺序从而保证内存可见性volatile读volatile写5.2 有序性用volatile 修饰过的变量由于前后有内存屏障保证了指令的执行顺序也可以理解为告诉编译器不要进行指令重排以此保证了有序性5.3 volatile不能保证原子性我们通过一段代码来测试volatile是否可以保证线程的原子性publicclassThread_lesson05_02{staticclassCounter01{volatileintcount0;voidincrease(){count;}}publicstaticvoidmain(String[]args)throwsInterruptedException{//初始化自增对象Counter01counter01newCounter01();//创建两个线程对一个变量进时累加Threadthread1newThread(()-{for(inti0;i50000;i){counter01.increase();}});Threadthread2newThread(()-{for(inti0;i50000;i){counter01.increase();}});// 启动线程thread1.start();thread2.start();//等待线程thread1.join();thread2.join();System.out.println(countcounter01.count);}}结论volatile解决不了原子性问题6. wait 和 notify由于线程之间是抢占式执行的,因此线程之间执行的先后顺序难以预知. 但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序所以Java中的Object类给出了wait和notify方法此时我们就有一个疑问了前面我们不是学习过join方法也可以进行等待呀为什么Java还要提供wait方法呢我们可以通过一个案列来理解一家人要吃包子(主线程)妈妈让小明去买包子(子线程)此时join主要等待子线程的结果即一家人等着小明买回来的包子当小明来到包子铺有两种情况包子已经做好小明可以直接买回家老板正在做包子此时小明就需要等待包子做好此时小明买包子这个线程和老板包包子这个线程没有主子线程之分但是要等老板做包子这个线程的结果这是就需要用wait()当老板做好包子之后大喊一声包子出锅了notify()总结join()用于主子线程的等待wait()用于非主子线程的等待wait()可以理解为当前线程等待另一个线程准备资源当资源准备好之后通过notify()通知当前线程6.1 wait()方法wait()/wait(long timeout): 让当前线程进入等待状态wait 做的事情:使当前执行代码的线程进行等待.(把线程放到等待队列中)释放当前的锁满足⼀定条件时被唤醒,重新尝试获取这个锁.wait要搭配synchronized来使用.脱离synchronized使用wait会直接抛出异常.wait 结束等待的条件:其他线程调用该对象的notify方法.wait等待时间超时(wait方法提供⼀个带有timeout参数的版本,来指定等待时间).其他线程调用该等待线程的interrupted方法,导致wait抛出InterruptedException 异常.6.2 notify()方法notify() 唤醒在当前对象上等待的线程.方法notify()也要在同步方法或同步块中调用该方法是用来通知那些可能等待该对象的对象锁的其它线程对其发出通知notify并使它们重新获取该对象的对象锁。如果有多个线程等待则有线程调度器随机挑选出⼀个呈wait状态的线程。(并没有先来后到)在notify()方法后当前线程不会立刻释放该对象锁要等到执行notify()方法的线程将程序执行完也就是退出同步代码块之后才会释放对象锁。6.3 notifyAll方法notifyAll(): 唤醒在当前对象上等待的线程.notify方法只是唤醒某⼀个等待线程.使用notifyAll方法可以⼀次唤醒所有的等待线程.6.4 演示wait() 和 notify()方法的使用importjava.util.concurrent.TimeUnit;/** * 演示wait() 和 notify()方法的使用 * 创建两个线程一个线程调用wait()一个线程调用notify() */publicclassThread_lesson05_03{publicstaticvoidmain(String[]args){//定义一个锁对象ObjectlockernewObject();//创建调用wait()线程Threadt1newThread(()-{synchronized(locker){System.out.println(调用wait()之前...);// 执行线程的逻辑// 如果没有满足线程所需要的数据那么就等待try{locker.wait();}catch(InterruptedExceptione){e.printStackTrace();}System.out.println(wait()唤醒之后..);}});Threadt2newThread(()-{System.out.println(notify()之前...);// 同等待的锁对象进行唤醒synchronized(locker){locker.notify();}System.out.println(notify()之后...);try{TimeUnit.SECONDS.sleep(1);}catch(InterruptedExceptione){e.printStackTrace();}});// 启动线程t1.start();t2.start();}}wati与notify 必须配置synchronized一起使用, 并且使用同一个锁对象注意:当一个线程调用了wait之后就释放掉当前持有的锁等待被其他的线程唤醒当另一个线程调用了notify之后之前调用了wait的线程被唤醒需要重新去竞争锁拿到锁之后会从wait的位置继续执行逻辑使用小结1. wait和notify必须搭配synchronized一起使用2. wait和notify使用的锁对象必须是同一个3. notify执行多少次都没有关系(即使没有线程在wait)6.5 wait和sleep的区别其实理论上wait和sleep完全是没有可比性的因为1. ⼀个是用于线程之间的通信的⼀个是让线程阻塞⼀段时间2. 唯⼀的相同点就是都可以让线程放弃执行一段时间3. wait需要搭配synchronized使用sleepjoin不需要4. wait是Object的方法sleep是Thread的静态方法
阅读完成 · 觉得有帮助?