乐趣区

关于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

失常来说,因为应用了 synchronized 来进行同步解决,那么第一个线程进入 run 办法之后,会进行锁定。先执行“开始休眠”,而后再执行“完结休眠”,最初开释锁之后,第二个线程才可能进入。

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

synchronized 常识回顾

在剖析起因之前,咱们先来回顾一下 synchronized 关键字的应用。

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

  • 同步一般办法,锁的是以后对象;
  • 同步静态方法,锁的是以后 Class 对象;
  • 同步块,锁的是 () 中的对象;

很显然,下面的场景中,应用的是第三种形式进行锁定解决。

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

代码在编译时,编译器会在同步办法调用前退出一个 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 关键字来说,如果加锁的对象是一个可变的对象,那么当这个变量的援用产生了扭转,不同的线程可能锁定不同的对象,进而都会胜利取得各自的锁。

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

在上图中,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

能够看到,第一个线程齐全执行结束之后,第二个线程才进行执行,达到预期的同步解决指标。

下面锁定以后对象还是有一个小毛病,大家在应用时须要留神:比方该类有其余办法也应用了 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;第二,对于多线程的使用,不仅要全面理解相干的根底知识点,还须要尽可能的进行压测,这样能力让问题当时裸露进去。

博主简介:《SpringBoot 技术底细》技术图书作者,热爱钻研技术,写技术干货文章。

公众号:「程序新视界」,博主的公众号,欢送关注~

技术交换:请分割博主微信号:zhuan2quan

退出移动版