万字图解工作面试必备,Java线程安全问题和解决方案

上一篇介绍了《​​Java多线程的作用​​》,使用场景和创建方式等基础,本篇主要介绍:,文章涵盖广而全,对工作和面试都有很大帮助,值得收藏认真阅读,不错的话记得点赞,关注支持哦!,一旦调用start方法,线程处于runnable状态【可运行状态】。也就是可能正在运行也可能没有运行,这取决于操作系统给线程提供运行的时间。(Java的规范说明没有将它作为一个单独状态。一个正在运行中的线程仍然处于可运行状态。),一旦一个线程开始运行,它不必始终保持运行。运行中的线程被中断,目的是为了让其他线程获得运行机会。线程调度的细节依赖于操作系统提供的服务。抢占式调度系统给每一个可运行线程一个时间片来执行任务。当时间片用完,操作系统剥夺该线程的运行权,并给另一个线程运行机会。当选择下一个线程时,操作系统考虑线程的优先级。,现在所有的桌面以及服务器操作系统都是用抢占式调度。但是,像手机这样的小型设备可能使用协作式调度,在这样的设备中,一个线程只有调用yield方法,或者被阻塞或等待时,线程才失去控制权。,在具有多个处理器的机器上,每一个处理器运行一个线程,可以有多个线程并行运行。当然,如果线程的数目多于处理器的数目,调度器依然采用时间片机制。,记住,在任何给定时刻,一个可运行的线程可能正在运行也可能没有运行(这就是为什么将这个状态称为可运行而不是运行),Java的多线程可以充分利用CPU资源提高计算速度和处理后台任务等,而且线程之间的运行机制是抢占式,或者说是随机的,这就会导致多个线程对共享数据操作时可能出现错误的结果,对于多线程并发时会使程序出现bug的代码称作线程不安全的代码,这就是线程安全问题,比如公司研发了一款手机,提供两个售货渠道卖10部手机,每一个线程就是一个售货渠道,当然最多只能卖出10部不能超卖,打印结果:,万字图解工作面试必备,Java线程安全问题和解决方案,运行结果发现出现了0号手机,卖出了11部,明显是有问题的。你也可以试着运行,每次的运行结果不一样,而且出现这种BUG也是随机的,你可能运行十几二十次都不会出现这问题,要明确一个前提是只有得到CPU的时间片线程才会被执行,而且CPU不保障一次将线程执行完,也就是说,CPU会在线程之间切换执行,上述例子出现超卖的原因也在这里,万字图解工作面试必备,Java线程安全问题和解决方案,上边我们在操作共享变量stocks时使用了stocks–这样的语法,自减操作也有大学问,stocks–:会对变量进行-1操作,–在变量之后,所以是后–,意思是如果变量参与了运算,则先完成运算再进行-1操作,比如上述例子与字符串进行相加运算,所以stocks变量会先于字符串完成拼接输出数据之后再对变量进行 -1 操作,而且程序运行时需要交给CPU执行,系统在执行运算时会将代码转换为指令集进行运算,在指令集方面,stocks–这样的一个自减操作会被分成三个指令操作:,情况1:线程之间指令集无交叉,运行结果与预期相同,线程1从内存加载值,运算之后再将值存进内存,线程2获取值,发现值为0,while判断不成立,万字图解工作面试必备,Java线程安全问题和解决方案,情况2:线程之间指令集存在交叉,结果可能存在问题,指令交叉计算后得知没有及时刷新进内存,导致另外的线程获取到的是旧值,就会存在少减情况,万字图解工作面试必备,Java线程安全问题和解决方案,情况3:指令完全交叉,现象与情况2一样,出现库存少减现象,万字图解工作面试必备,Java线程安全问题和解决方案,根据上边的几种情况分析,发现线程运行时没有出现指令交叉结果是预期的,如果出现指令交叉就会存在库存少减现象,是因为自减操作不是原子的是可以再分割的,线程之间独立,线程内计算的值并没有直接刷新进内存,导致别的线程并不会得到最新的数据,多线程并发执行时很可能出现指令交叉,导致线程安全问题,出现错误结果。,解决上述线程不安全问题,我们常用的方法就是加锁,在Java多线程中,当两个或以上线程对同一数据进行操作时,就会产生【竞争条件】的现象,这种现象产生的根本原因是因为多个线程在对同一个数据进行操作,此时对该数据的操作是非“原子化”的,可能前一个线程对数据的操作还没有结束,后一个线程又开始对同样的数据开始进行操作,这就可能会造成数据结果的变化未知。,为了解决由于【抢占式执行】导致的线程安全问题,我们可以对共享数据进行加锁,可以理解为给多线程操作的共享数据设置一个操作权限,谁拿到这个锁,谁就有权利操作共享数据,当一个线程拿到共享数据的锁后,就会把共享数据锁起来,其他线程如果也要操作这个共享数据,需要等待已经获取到锁的线程执行完之后释放锁,其他拿个线程得到这个锁,谁就可以操作共享数据。,举个例子:有一家饭店的包间非常不错,很多人都想在包间中就餐。当包间被顾客预定之后就相当于被上了锁,其他顾客必须等待上一个顾客享用完服务之后才可再预定使用,预定到的就会再次对包间上锁,其他顾客无法享用这个包间。这样就不会乱糟糟的了是吧,不然就跟没有秩序一样,谁都可以进包间里边就会发生冲突。这里的顾客就是一个一个的线程,这里的包间就是共享数据,预定包间成功就相当于加的锁,万字图解工作面试必备,Java线程安全问题和解决方案,当你使用完之后,释放锁,其他线程竞争锁,当一个线程抢到锁之后,就会进入套房享用服务,万字图解工作面试必备,Java线程安全问题和解决方案,Java中最常见的是使用 synchronized 加锁。synchronized 是互斥锁,有互斥效果,即同一时刻只能有一个线程操作共享数据,某个线程执行到 synchronized 中时, 其他线程如果也执行这块代码,就会阻塞等待。线程进入 synchronized 修饰的代码块, 相当于 加锁,退出 synchronized 修饰的代码块, 相当于 解锁,方式1:使用synchronized关键字修饰方法,这样会使方法所在的对象加上一把锁,实现类:,测试类:,上边的代码可以解决线程安全问题,但是因为while条件中直接判断的共享资源,所以将while直接锁进嘞小房间,所以所有的手机都会被同一个线程售出,比如:线程1获取到锁资源后上锁,进入while循环,沉迷其中不可自拔,一口气消费完才释放锁。我们可以通过以下代码优化,实现线程交替运行:,定义 flag 变量标记是否还有库存,while循环判断库存标记,这样可以保障当线程1判断while之后挂起还没有调用售卖方法时仍然可能丢失CPU执行权,切换到其他线程执行。,运行结果:在运行结果截图中,发现写了一个sleep方法,这是为了让线程进入超时等待可以释放CPU执行权,来达到切换线程的目的,实际开发中是不会使用sleep方法的,所以上边贴出的代码中并没有sleep方法调用,万字图解工作面试必备,Java线程安全问题和解决方案,为可看出效果,也可以将库存调为10万台,有充分的资源支撑线程切换,可以看出下图同样线程1和线程2之间切换,并且没有出现超卖现象,万字图解工作面试必备,Java线程安全问题和解决方案,方式2:使用synchronized关键字对代码段进行加锁,但是需要显式指定加锁的对象。,运行结果:,万字图解工作面试必备,Java线程安全问题和解决方案,方式3:使用synchronized关键字修饰静态方法,相当于对当前类的类对象进行加锁,常见的用法差不多就是这些,对于线程加锁(线程拿锁),如果两个线程同时拿一个对象的锁,就会产生锁竞争,两个线程同时拿两个不同对象的锁不会产生锁竞争。 对于synchronized这个关键字,它的英文意思是同步,但是同步在计算机中是存在多种意思的,比如在多线程中,这里同步的意思是“互斥”;而在IO或网络编程中同步则是另一个意思,三种方式锁对象区别:,synchronized 的工作过程:,综上,synchronized关键字加锁有如下性质:互斥性,刷新内存性,可重入性。,所谓可重入,即一个线程已经获得了某个锁,当这个线程要再次获取这个锁时,依然可以获取成功,不会发生死锁的情况。synchronized就是一个可重入锁。,一般而言,可重入的函数一定是线程安全的,反之则不一定成立。在不加锁的前提下,如果一个函数用到了全局或静态变量,那么它不是线程安全的,也不是可重入的。如果我们加以改进,对全局变量的访问加锁,此时它是线程安全的但不是可重入的,因为通常的加锁方式是针对不同线程的访问(如Java的synchronized),当同一个线程多次访问就会出现问题。只有当函数满足可重入的四条条件时,才是可重入的,从设计上讲,当一个线程请求一个由其他线程持有的对象锁时,该线程会阻塞。当线程请求自己持有的对象锁时,如果该线程是重入锁,请求就会成功,否则阻塞。,我们回来看synchronized,synchronized拥有强制原子性的内部锁机制,是一个可重入锁。因此,在一个线程使用synchronized方法时调用该对象另一个synchronized方法,即一个线程得到一个对象锁后再次请求该对象锁,是永远可以拿到锁的。,在Java内部,同一个线程调用自己类中其他synchronized方法/块时不会阻碍该线程的执行,同一个线程对同一个对象锁是可重入的,同一个线程可以获取同一把锁多次,也就是可以多次重入。原因是Java中线程获得对象锁的操作是以线程为单位的,而不是以调用为单位的。,每个锁关联一个线程持有者和一个计数器。当计数器为0时表示该锁没有被任何线程持有,那么任何线程都都可能获得该锁而调用相应方法。当一个线程请求成功后,JVM会记下持有锁的线程,并将计数器计为1。此时其他线程请求该锁,则必须等待。而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增。当线程退出一个synchronized方法/块时,计数器会递减,如果计数器为0则释放该锁。,当下成1获取到锁对象之后就会将共享资源锁起来【lock】,当线程1处理完之后释放锁【unlock】,其他线程来竞争这把锁,谁得到锁谁就将资源锁住【lock】,依次释放和获得锁,没有获取到锁的线程就会进入阻塞状态,万字图解工作面试必备,Java线程安全问题和解决方案,加锁后线程就是串行执行,与单线程其实没有很大的区别,那多线程是不是没有用了呢?但是对方法加锁后,线程运行该方法才会加锁,运行完该方法就会自动解锁,况且大部分操作并发执行是不会造成线程安全的,只有少部分的修改操作才会有可能导致线程安全问题,因此整体上多线程运行效率还是比单线程高得多。,从JDK5.0开始,Java提供了更强大的线程同步机制——通过显示定义同步锁对象来实现同步。同步锁使用Lock对象充当。该锁对象在Java的JUC包中,java.util.concurrent.locks.Lock接口是控制多个线程对共享资源进行访问的工具。锁提供了对共享资源的独占访问。每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象,ReentrantLock类实现了Lock,ta拥有与synchronized 相同的并发性和内存语义,在实现线程安全的控制中,比较常用的是ReentrantLock,从名字上可以看出该所对象是可重入锁,可以显式加锁,释放锁,使用 ReentrantLock 的时候,建议把 Lock 和 方法体 放在 try{} 代码块中,然后释放锁 unlock() 放在 finally{} 代码块中保证锁释放成功~,如果线程发生异常意外终止,锁没有释放成功,别的线程也获取不到锁,就会出现死锁,也就是谁都拿不到锁,谁都运行不了程序,以下是Lock接口的源码,简单翻译如下:,lock方法:,运行结果:,万字图解工作面试必备,Java线程安全问题和解决方案,tryLock方法:,tryLock就是尝试获取锁,如果所被别的线程获取,则直接放弃获取,不阻塞,好比追一个小姐姐,人家有对象了,直接放弃,而lock则是等着【分手接盘】,而tryLock(long time, TimeUnit unit),则是锁被别的线程拿到,会等待指定时间,如果还没获取到就放弃,好比给小姐姐一段时间分手,如果没分就拉到,分了就接盘,运行结果:,万字图解工作面试必备,Java线程安全问题和解决方案,下方代码是从JDK源码中摘录出来的,对部分代码做了注释,可以细品一下,ReentrantLock在创建对象时可以选择是否为公平锁,默认为非公平锁。面试时Java中的锁分类也是高频问点!在下边也为大家介绍到:,在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU,synchronized:Java是用字节码指令来控制程序(这里不包括热点代码编译成机器码)。在字节指令中,存在有synchronized所包含的代码块,那么会形成2段流程的执行,如下代码:,通过 javap -c LockTest.class 指令获取该类的class字节码数据如下:,万字图解工作面试必备,Java线程安全问题和解决方案,如上就是这段代码段字节码指令,我们可以清晰段看到,其实synchronized映射成字节码指令就是增加来两个指令:monitorenter和monitorexit。当一条线程进行执行的遇到monitorenter指令的时候,它会去尝试获得锁,如果获得锁那么锁计数+1(因为它是一个可重入锁,所以需要用这个锁计数判断锁的情况),如果没有获得锁,那么阻塞。当它遇到monitorexit的时候,锁计数器-1,当计数器为0,那么就释放锁。,有的朋友看到这里就疑惑了,为什么有2个monitorexit呀?马上回答这个问题:synchronized锁释放有两种机制,一种就是执行完释放;另外一种就是发送异常,虚拟机释放。图中第二个monitorexit就是发生异常时执行的流程。而且,从图中我们也可以看到在第18行,有一个goto指令,也就是说如果正常运行结束会跳转到26行执行。,Lock:Lock实现和synchronized不一样,后者是一种悲观锁,它胆子很小,它很怕有人和它抢吃的,所以它每次吃东西前都把自己关起来。而Lock呢底层其实是CAS乐观锁的体现,它无所谓,别人抢了它吃的,它重新去拿吃的就好啦,所以它很乐观。具体底层怎么实现,如果面试问起,你就说底层主要靠volatile和CAS操作实现的。,尽可能去使用synchronized而不要去使用LOCK,jdk1.6~jdk1.7中对 synchronized 进行优化:,1、线程自旋和适应性自旋,Java线程其实是映射在内核之上,线程的挂起和恢复会极大的影响开销。并且jdk官方人员发现,很多线程在等待锁的时候,在很短的一段时间就获得了锁,所以它们在线程等待的时候,并不需要把线程挂起,而是让他无目的的循环,一般设置10次。这样就避免了线程切换的开销,极大的提升了性能。 而适应性自旋,是赋予了自旋一种学习能力,它并不固定自旋10次一下。它可以根据它前面线程的自旋情况,从而调整它的自旋,甚至是不经过自旋而直接挂起,2、锁消除【Lock Elimination】,锁消除就是把不必要的同步在编译阶段进行移除,惊讶!我自己写的代码我会不知道这里要不要加锁?需要你教我做事?我加了锁就是表示这边会有同步呀? 并不是这样,这里所说的锁消除并不一定指代是你写的代码的锁消除,而是根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁,我打一个比方: 在jdk1.5以前,我们的String字符串拼接操作其实底层是StringBuffer实现,而在jdk1.5之后,是用StringBuilder来拼接。我们考虑前面的情况,比如如下代码:,底层实现会变成这样:,StringBuffer是一个线程安全的类,也就是说两个append方法都会同步,通过指针逃逸分析(就是变量不会外泄),我们发现在这段代码并不存在线程安全问题,这个时候就会把这个同步锁消除,3、锁粗化,在用synchronized的时候,我们都讲究为了避免大开销,尽量同步代码块要小。Hotspot 确实进行了锁粗化优化,可以有效合并几个相邻同步块,从而降低锁开销。能够把下面的代码,转换为:,Hotspot 能否对循环进行这种优化?例如,把,转换为,理论上,没有什么能阻止我们这样做,甚至可以把这种优化看作只针对锁的优化,像 loop unswitching 一样。然而,缺点是可能把锁优化后变得过粗,线程在执行循环时会占据所有的锁,小贴士:Loop unswitching 是一种编译器优化技术。通过复制循环主体,在 if 和 else 语句中放一份循环体代码,实现将条件句的内部循环移到循环外部,进而提高循环的并行性。由于处理器可以快速运算矢量,因此执行速度得到提升。,4、轻量级锁和偏向锁,轻量级锁和偏向锁在上边锁分类中已经解释,不再复述,JDK将 synchronized 升级成了这两种特性的锁,这里对Java多线程的运行机制,线程安全问题产生的原因和解决方案,锁分类,并对 synchronized 和 Lock的底层原理进行分析。多线程是一门比较深的学问,不同的场景使用方法都不同,但是本质几乎一样,如果您对本文有什么疑问或者问题欢迎在评论区指出。,Java的多线程仅仅是开始远没有结束,比如多线程的8锁问题,JUC中的原子类,volatile关键字,ThreadLocal,分布式锁,线程通信,JDK中各个线程安全类如何实现线程安全的等等都会陆续更新出来,欢迎持续关注!,文章出自:​石添的编程哲学​,如有转载本文请联系【石添的编程哲学】今日头条号。

文章版权声明

 1 原创文章作者:cmcc,如若转载,请注明出处: https://www.52hwl.com/22282.html

 2 温馨提示:软件侵权请联系469472785#qq.com(三天内删除相关链接)资源失效请留言反馈

 3 下载提示:如遇蓝奏云无法访问,请修改lanzous(把s修改成x)

 免责声明:本站为个人博客,所有软件信息均来自网络 修改版软件,加群广告提示为修改者自留,非本站信息,注意鉴别

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2023年3月5日 上午12:00
下一篇 2023年3月7日 下午10:34