当前位置: 首页 > 科技观察

使用Synchronized,依然存在线程安全问题!

时间:2023-03-13 23:59:28 科技观察

本文转载自微信公众号《程序新视界》,作者二师兄丑胖子。转载本文请联系程序新视界公众号。实战中受的伤,才能理解得更透彻。二师兄带你分析实战案例。线程安全问题一直是系统永恒的痛点。这不,最近项目里发了一个错误使用线程同步的案例。表面上看似乎使用了同步机制,一切正常,但实际上线程同步没有任何作用。关于线程安全的问题基本上是挖坑和填坑的博弈,这也是为什么线程安全在面试中必不可少的原因。下面就为大家分析一下这个案例。对于有隐患的代码,先看脱敏代码示例。代码要处理的业务逻辑很简单,就是多线程访问单例对象的成员变量和自增处理。SyncTest类实现了Runnable接口,业务逻辑在run方法中处理。在run方法中使用了synchronized来保证线程安全,在main方法中创建了一个SyncTest类的对象,两个线程同时操作这个对象。publicclassSyncTestimplementsRunnable{privateIntegercount=0;@Overridepublicvoidrun(){synchronized(count){System.out.println(newDate()+"StartSleep"+Thread.currentThread().getName());count++;try{Thread.sleep(10000);System.out.println(newDate()+"结束休眠"+Thread.currentThread().getName());}catch(InterruptedExceptione){e.printStackTrace();}}}publicstaticvoidmain(String[]args)throwsInterruptedException{SyncTesttest=newSyncTest();newThread(test).start();Thread.sleep(100);newThread(test).start();}}上面代码中,两个线程访问同一个SyncTest对象,并对对象的count属性进行自增操作。既然是多线程,就要保证count++的线程安全。代码中使用synchronized来锁定代码块进行同步。为了演示效果,线程在处理完业务逻辑后休眠。理想的情况是第一个线程执行完毕,第二个线程才能进入执行。从表面上看,一切都很完美,让我们执行程序看看结果。执行校验,执行main方法打印结果如下:FriJul2322:10:34CST2021开始休眠Thread-0FriJul2322:10:34CST2021开始休眠Thread-1FriJul2322:10:44CST2021结束休眠Thread-0FriJul2322:10:45CST2021结束休眠Thread-1正常情况下,由于synchronized是用来做同步处理的,所以第一个线程进入run方法后,会被锁住。先执行“startsleep”,再执行“endsleep”,最后在第二个线程进入之前释放锁。但是分析上面的日志,会发现有两个线程同时进入了“开始休眠”状态,也就是说锁还没有生效,线程安全还是存在问题。下面我们将对synchronized失败的原因进行一步一步的分析。同步知识回顾在分析原因之前,我们先回顾一下synchronized关键字的使用。使用synchronized关键字解决并发问题通常有3种方式:同步普通方法,锁定当前对象;同步静态方法,锁定当前Class对象;synchronize块,将对象锁定在();显然,在上述场景中,采用了第三种方式进行加锁处理。synchronized实现同步的过程是:JVM通过进入和退出对象监视器(Monitor)来实现方法和同步块的同步。编译代码时,编译器会在同步方法调用前添加monitor.enter指令,并在exit方法和exception处插入monitor.exit指令。它的本质是获取一个对象监视器(Monitor),这个获取过程是独占的,使得同一时间只能有一个线程访问它。原因分析上面的基础知识有了铺垫之后,我们来排查分析上面代码的问题。其实对于这个问题,IDE已经能够给出提示了。如果你使用的IDE有代码检查插件,synchronized(计数)的计数会有如下提示:Synchronizationonanon-finalfield'xxx'Inspectioninfo:Reportssynchronizedstatementswherethelockexpressionisareference到非最终字段。此类语句不太可能具有有用的语义,因为即使在同一对象上操作时,不同的线程也可能锁定不同的对象。很多人可能会忽略这个提示,但它已经明确指出这里的代码是线程安全的问题。提示的核心是“同步应用于非final修饰的变量”。对于synchronized关键字来说,如果加锁的对象是一个可变对象,那么当这个变量的引用发生变化时,不同的线程可能会加锁不同的对象,然后都会成功获得自己的锁。用一张图回顾一下上面的过程:上图中,Thread0在①处被锁定,但是被锁定的对象是Integer(0);Thread1在②处也被锁定了,但是此时count已经自增,导致Thread1锁定了对象Integer(1);也就是说,两个线程锁定的对象是不一样的,线程安全得不到保证。解决方案既然找到了问题的原因,那么我们就可以有针对性的解决了。这里用到的count属性显然是不能用final修饰的,否则不能自增。这里我们使用对象lock方法来处理,即lock对象为当前this或者当前类的实例对象。修改后的代码如下:publicclassSyncTestimplementsRunnable{privateIntegercount=0;@Overridepublicvoidrun(){synchronized(this){System.out.println(newDate()+"StartSleep"+Thread.currentThread().getName());count++;try{Thread.sleep(10000);System.out.println(newDate()+"结束睡眠"+Thread.currentThread().getName());}catch(InterruptedExceptione){e.printStackTrace();}}}//...}上面代码中锁定了当前对象,本例中当前对象是同一个SyncTest对象。再次执行main方法,打印log如下:FriJul2323:13:55CST2021startstosleepThread-0FriJul2323:14:05CST2021endstosleepThread-0FriJul2323:14:05CST2021startstosleepThread-1FriJul2323:14:15CST2021endstosleepThread-1可以看出,在第一个线程执行完毕后,再执行第二个线程,达到预期的同步处理目的。上面锁定当前对象还有一个小缺点,使用时需要注意:比如这个类的其他方法也使用了synchronized(this),那么既然这两个方法都锁定了当前对象,其他方法也会堵。所以一般情况下,建议每个方法都给自己定义的对象加锁。比如单独定义一个私有变量,然后加锁:publicclassSyncTestimplementsRunnable{privateIntegercount=0;privatefinalObjectlocker=newObject();@Overridepublicvoidrun(){synchronized(locker){System.out.println(newDate()+"StartSleep"+Thread.currentThread().getName());count++;try{Thread.sleep(10000);System.out.println(newDate()+"结束睡眠"+Thread.currentThread().getName());}catch(InterruptedExceptione){e.printStackTrace();}}}}synchronized使用常识在使用synchronized的时候,首先要搞清楚它锁定的是哪个对象,这样可以帮助我们设计更安全的多线程程序。在使用和设计锁的时候,我们还需要了解一些知识点:对象建议定义为private,然后通过getter方法访问。而不是将其定义为public/protected,否则外界可以绕过同步方法的控制,直接获取对象并进行更改。这也是JavaBean的标准实现之一。当锁定对象为数组或ArrayList时,getter方法获取的对象仍然可以改变。这时候就需要在get方法中加入synchronized同步,只返回这个私有对象的clone()。这样,调用者得到的是对象副本的引用。不管是在方法上还是在对象上加上synchronized关键字,得到的锁都是对象,而不是一段代码或者函数作为锁。同步方法很可能被其他线程的对象访问;每个对象只有一个与之关联的锁(lock);同步的实现是以较大的系统开销为代价的,甚至可能造成死锁,所以尽量避免不必要的同步控制;小结通过本文的实际案例,主要为大家输出两个重点:第一,不要忽视IDE对代码的提示信息。有些提示真的很有用。如果你深入挖掘,你会发现很多性能问题或代码错误;其次,对于多线程的应用,不仅要全面了解相关的基础知识点,还需要尽可能的进行压力测试,这样才能提前暴露问题。