背景

我最近在学习 Java 并发编程,正好学习到 synchronized 锁这一块。在学习过程中由于对问题理解不够透彻产生了偏差,经过思考之后终于捋顺了,思考的过程可能有一些参考意义,希望能给大家一些启发。

线程安全问题的例子

话不多说,我们先看一段代码:

public class Test1 {
    static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                   count++;
            }
        });

        Thread t2 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                   count--;
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(count);
    }
}


简单介绍一下代码的逻辑,在主线程中开启两个线程对类的成员变量 count 分别进行自增和自减操作,等待两个线程都执行完毕,最后输出 count 的值。 在不考虑并发的情况下,由于自增和自减的次数相同,最后的输出结果会是 0 。但是实际的执行结果却有以下三种可能 0、正数、负数。没错 0 的情况也是有可能出现的,不过概率很低。 我们简单分析一下,count 的自增或自减操作不是一步完成的,而是分成好几步:

先获取到 count 的值,记做 x 然后进行自增(x+1)或者自减 (x-1),记为 y 最后将 y 回写到 count 中 当两个线程同时对 count 进行操作时,有可能发生以下情况:

线程 A 获取到 count 的值为 x 线程 B 也获取到 count 的值为 x 线程 A 执行 x+1 线程 B 执行 x -1 线程 A 将 x+1 回写到 count 中 线程 B 将 x-1 回写到 count 中 本来正常操作时 A 线程先读取 x 然后操作完 ,将 y 写回到 count 中。此时 B 线程再读取 count 的值 y,操作之后写会 count 中。结果经过上面的操作,线程 A,B 的两次操作,只有最后回写的线程(B)生效了,A的操作相当于作废了。因此对于多个线程同时操作共享资源,很容易出现线程安全问题。

解决线程安全问题

为了解决上面的问题我们需要对共享资源加锁,于是乎就有了下面的代码:

public class Test1 {
    static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
               synchronized (Test1.class){
                   count++;
               }
            }
        });

        Thread t2 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
               synchronized (Test1.class){
                   count--;
               }
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(count);
    }
}


我们利用 synchronized 对 count 的自增或者自减操作进行加锁,这样最后的结果就会和我们预想的一样为 0 了。我大概说一下其中的原理,这里 synchronizd 的作用就是给代码块里面的代码加上一把锁,这样就保证了 count++ 或者是 count-- 是一个完整的操作,也就是具有原子性(在很长的时期,原子都被认为是不可分的最小微观粒子,所以原子性就是整体性的意思)。学到这里的时候,我其实是处于一知半解,但是以为自己懂了的状态,模糊地觉得原子性嘛说明 count – 和 count++ 在执行的中途不会插入其他操作,也就不会出现线程安全问题了。

能这么简单理解吗?

先问个问题,如果只对 count++ 或者是 **count-- 加锁,会出现线程安全问题吗? 显然会?为啥?因为如果加一个就能解决的话为啥要加两个?(ps:哈哈哈哈,整个活) 我们正经分析一下,如果只对 count++ 加锁,两个线程同时运行,线程 A 在执行 count++ 的时候,由于 count-- 没有加锁,线程 B 还是可以执行 count-- **,只要两个线程同时执行,就会出现线程安全问题,也就是互相覆盖的情况。 我当时在疑惑什么呢?我在想, 给 count++ 加上 synchronized 关键字以后 count++ 就具有原子性了,原子性就代表中间不会存在其他操作,所以加上一个是不是也行? 显然我的理解是有问题的,首先加锁并不等同于原子性,为什么这么说?举个例子:

 synchronized (Test1.class){
       count++;
   }

虽然多个线程执行上面的代码是一个线程一个线程去执行的,是原子性的,但是并不是说 count++ 这个操作就变成原子性的了,只是这段被 synchronized 包裹的代码是原子性的,多个线程不能同时执行这段代码,但是可以同时执行别的代码,就比如说 count++ 和 count-- ,如果只对其中一个加锁,那么他们就可以可以同时执行。 其次,给操作共享资源的代码块加锁,并不等于给资源加锁。对于 count 这个资源,我只给 count++ 加锁并不能阻止其他的线程去同时运行 count–,所以说只给 count ++ 加锁是没有用的,必须要同时给两个操作都加锁,并且锁对象必须是一个。

总结

synchronized 实现原子性的原理是通过给同一个对象加锁,在多线程并发执行的情况下,都要先去同一个对象哪里先获取锁,然后才能执行 synchronized 代码块中的代码,由于同时只能有一个线程来获取到锁,所以同一时间只有一个线程执行代码块中的代码,保证了代码块中的代码是原子性的。但是对于共享资源来说,要想共享资源的线程安全,就需要保证所有对于共享资源的操作的原子性,则需要将所有对于共享资源的操作加上同一把锁,也就是如示例中的,在对 count++ 和 count-- 加锁时也要保证锁对象(Test1.class)是同一个。