使用了synchronized,竟然还有线程安全问题!

简介: 使用了synchronized,竟然还有线程安全问题!

实战中受过的伤,才能领悟的更透彻,二师兄带你分析实战案例。


线程安全问题一直是系统亘古不变的痛点。这不,最近在项目中发了一个错误使用线程同步的案例。表面上看已经使用了同步机制,一切岁月静好,但实际上线程同步却毫无作用。


关于线程安全的问题,基本上就是在挖坑与填坑之间博弈,这也是为什么面试中线程安全必不可少的原因。下面,就来给大家分析一下这个案例。


有隐患的代码

先看一个脱敏的代码实例。代码要处理的业务逻辑很简单,就是多线程访问一个单例对象的成员变量,对其进行自增处理。


SyncTest类实现了Runnable接口,run方法中处理业务逻辑。在run方法中通过synchronized来保证线程安全问题,在main方法中创建一个SyncTest类的对象,两个线程同时操作这一个对象。


public class SyncTest implements Runnable {
    private Integer count = 0;
    @Override
    public void run() {
        synchronized (count) {
            System.out.println(new Date() + " 开始休眠" + Thread.currentThread().getName());
            count++;
            try {
                Thread.sleep(10000);
                System.out.println(new Date() + " 结束休眠" + Thread.currentThread().getName());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        SyncTest test = new SyncTest();
        new Thread(test).start();
        Thread.sleep(100);
        new Thread(test).start();
    }
}

在上述代码中,两个线程访问SyncTest的同一个对象,并对该对象的count属性进行自增操作。由于是多线程,那就要保证count++的线程安全。


代码中使用了synchronized来锁定代码块,进行同步处理。为了演示效果,在处理完业务逻辑对线程进行睡眠。


理想的状况是第一个线程执行完毕,然后第二个线程才能进入并执行。


表面上看,一切都很完美,下面我们来执行一下程序看看结果。


执行验证

执行main方法打印结果如下:


Fri Jul 23 22:10:34 CST 2021 开始休眠Thread-0

Fri Jul 23 22:10:34 CST 2021 开始休眠Thread-1

Fri Jul 23 22:10:44 CST 2021 结束休眠Thread-0

Fri Jul 23 22:10:45 CST 2021 结束休眠Thread-1

1

2

3

4

正常来说,由于使用了synchronized来进行同步处理,那么第一个线程进入run方法之后,会进行锁定。先执行“开始休眠”,然后再执行“结束休眠”,最后释放锁之后,第二个线程才能够进入。


但分析上面的日志,会发现两个线程同时进入了“开始休眠”状态,也就是说锁并未起效,线程安全依旧存在问题。下面我们就针对synchronized失效原因进行逐步分析。


synchronized知识回顾

在分析原因之前,我们先来回顾一下synchronized关键字的使用。


synchronized关键字解决并发问题时通常有三种使用方式:


同步普通方法,锁的是当前对象;

同步静态方法,锁的是当前Class对象;

同步块,锁的是()中的对象;

很显然,上面的场景中,使用的是第三种方式进行锁定处理。


synchronized实现同步的过程是:JVM通过进入、退出对象监视器(Monitor)来实现对方法、同步块的同步的。


image.png代码在编译时,编译器会在同步方法调用前加入一个monitor.enter指令,在退出方法和异常处插入monitor.exit的指令。其本质就是对一个对象监视器(Monitor)进行获取,而这个获取过程具有排他性从而达到了同一时刻只能一个线程访问的目的。


原因分析

经过上面基础知识的铺垫,我们就来排查分析一下上述代码的问题。其实,对于这个问题,IDE已经能够给出提示了。


如果你使用的IDE带有代码检查的插件,synchronized (count)的count上会有如下提示:


Synchronization on a non-final field ‘xxx’

Inspection info: Reports synchronized statements where the lock expression is a reference to a non-final field. Such statements are unlikely to have useful semantics, as different threads may be locking on different objects even when operating on the same object.


很多人可能会忽视掉这个提示,但它已经明确指出此处代码有线程安全问题。提示的核心是“同步处理应用在了非final修饰的变量上”。


对于synchronized关键字来说,如果加锁的对象是一个可变的对象,那么当这个变量的引用发生了改变,不同的线程可能锁定不同的对象,进而都会成功获得各自的锁。


用一个图来回顾一下上述过程:


image.pngimage.png

image.png在上图中,Thread0在①处进行了锁定,但锁定的对象是Integer(0);Thread1中②处也进行锁定,但此时count已经进行自增,导致Thread1锁定的是对象Integer(1);也就是说,两个线程锁定的对象不是同一个,也就无法保证线程安全了。


解决方案

既然找到了问题的原因,我们就可以有针对性的进行解决,这里用的count属性很显然不可能用final进行修饰,不然就无法进行自增处理。这里我们采用对象锁的方式来进行处理,也就锁对象为当前this或者说是当前类的实例对象。修改之后的代码如下:


public class SyncTest implements Runnable {
    private Integer count = 0;
    @Override
    public void run() {
        synchronized (this) {
            System.out.println(new Date() + " 开始休眠" + Thread.currentThread().getName());
            count++;
            try {
                Thread.sleep(10000);
                System.out.println(new Date() + " 结束休眠" + Thread.currentThread().getName());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    // ...
}

在上述代码中锁定了当前对象,而当前对象在这个示例中是同一个SyncTest的对象。


再次执行main方法,打印日志如下:


Fri Jul 23 23:13:55 CST 2021 开始休眠Thread-0

Fri Jul 23 23:14:05 CST 2021 结束休眠Thread-0

Fri Jul 23 23:14:05 CST 2021 开始休眠Thread-1

Fri Jul 23 23:14:15 CST 2021 结束休眠Thread-1

1

2

3

4

可以看到,第一个线程完全执行完毕之后,第二个线程才进行执行,达到预期的同步处理目标。


上面锁定当前对象还是有一个小缺点,大家在使用时需要注意:比如该类有其他方法也使用了synchronized (this),那么由于两个方法锁定的都是当前对象,其他方法也会进行阻塞。所以通常情况下,建议每个方法锁定各自定义的对象。


比如,单独定义一个private的变量,然后进行锁定:


public class SyncTest implements Runnable {
    private Integer count = 0;
    private final Object locker = new Object();
    @Override
    public void run() {
        synchronized (locker) {
            System.out.println(new Date() + " 开始休眠" + Thread.currentThread().getName());
            count++;
            try {
                Thread.sleep(10000);
                System.out.println(new Date() + " 结束休眠" + Thread.currentThread().getName());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

synchronized使用小常识

在使用synchronized时,我们首先要搞清楚它锁定的是哪个对象,这能帮助我们设计更安全的多线程程式。


在使用和设计锁时,我们还要了解一下知识点:


对象建议定义为private的,然后通过getter方法访问。而不是定义为public/protected,否则外界能够绕过同步方法的控制而直接取得对象并改变它。这也是JavaBean的标准实现方式之一。

当锁定对象为数组或ArrayList等类型时,getter方法获得的对象仍可以被改变,这时就需要将get方法也加上synchronized同步,并且只返回这个private对象的clone()。这样,调用端得到的就是对象副本的引用了。

无论synchronized关键字加在方法上还是对象上,取得的锁都是对象,而不是把一段代码或函数当作锁。同步方法很可能还会被其他线程的对象访问;

每个对象只有一个锁(lock)和之相关联;

实现同步是要很大的系统开销作为代价的,甚至可能造成死锁,所以尽量避免无谓的同步控制;

小结

通过本文的实践案例主要为大家输出两个关键点:第一,不要忽视IDE对代码的提示信息,某些提示真的很有用,如果深挖还能发现很多性能问题或代码bug;第二,对于多线程的运用,不仅要全面了解相关的基础知识点,还需要尽可能的进行压测,这样才能让问题事先暴露出来。



目录
相关文章
|
5月前
|
监控 Java 调度
SpringBoot中@Scheduled和Quartz的区别是什么?分布式定时任务框架选型实战
本文对比分析了SpringBoot中的`@Scheduled`与Quartz定时任务框架。`@Scheduled`轻量易用,适合单机简单场景,但存在多实例重复执行、无持久化等缺陷;Quartz功能强大,支持分布式调度、任务持久化、动态调整和失败重试,适用于复杂企业级需求。文章通过特性对比、代码示例及常见问题解答,帮助开发者理解两者差异,合理选择方案。记住口诀:单机简单用注解,多节点上Quartz;若是任务要可靠,持久化配置不能少。
512 4
|
安全 Java 关系型数据库
单例模式下引发的线程安全问题
单例模式确保类在进程中仅有一个实例,适用于如数据库连接等场景。分为饿汉式与懒汉式:饿汉式在类加载时创建实例,简单但可能浪费资源;懒汉式延迟创建实例,需注意线程安全问题,常采用双重检查锁定(Double-Checked Locking)模式,并使用 `volatile` 关键字避免指令重排序导致的问题。
189 2
单例模式下引发的线程安全问题
|
10月前
|
监控 Docker 容器
在Docker容器中运行打包好的应用程序
在Docker容器中运行打包好的应用程序
|
11月前
|
存储 前端开发 JavaScript
|
Cloud Native 持续交付 Docker
云原生技术入门与实践:Docker容器化部署示例
【9月更文挑战第25天】在数字化转型的浪潮下,云原生技术成为推动企业创新的重要力量。本文旨在通过浅显易懂的语言,为初学者揭示云原生技术的核心概念及其应用价值。我们将以Docker容器为例,逐步引导读者了解如何将应用程序容器化,并在云端高效运行。这不仅是对技术趋势的跟随,更是对资源利用和开发效率提升的探索。
173 4
|
Android开发
Android 自定义View 测量控件宽高、自定义viewgroup测量
Android 自定义View 测量控件宽高、自定义viewgroup测量
543 0
|
调度 开发者 UED
Kotlin 中的协程是什么?
【8月更文挑战第31天】
1255 0
|
12月前
|
存储 NoSQL MongoDB
mongodb的数据库表怎么创建
在此过程中,理解并掌握这些基本操作,是深入探索MongoDB魅力,乃至构建高效数据解决方案的关键所在。通过实践,您将更加深刻地体会到这种随需应变的数据管理模式带来的便利与效率提升。
436 0
|
XML Java 开发工具
在Android中使用ProgressBar显示进度
在Android中使用ProgressBar显示进度
1538 2
|
存储 缓存 NoSQL
基于SpringBoot+Redis解决缓存与数据库一致性、缓存穿透、缓存雪崩、缓存击穿问题
这篇文章讨论了在使用SpringBoot和Redis时如何解决缓存与数据库一致性问题、缓存穿透、缓存雪崩和缓存击穿问题,并提供了相应的解决策略和示例代码。
341 0