一次学习引发我对于 synchronized 的再理解

1. 背景

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

2. 线程安全问题的例子

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

java 复制代码
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 的自增或自减操作不是一步完成的,而是分成好几步:

  1. 先获取到 count 的值,记做 x
  2. 然后进行自增(x+1)或者自减 (x-1),记为 y
  3. 最后将 y 回写到 count 中

当两个线程同时对 count 进行操作时,有可能发生以下情况:

  1. 线程 A 获取到 count 的值为 x
  2. 线程 B 也获取到 count 的值为 x
  3. 线程 A 执行 x+1
  4. 线程 B 执行 x -1
  5. 线程 A 将 x+1 回写到 count 中
  6. 线程 B 将 x-1 回写到 count 中

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

3. 解决线程安全问题

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

java 复制代码
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++ 在执行的中途不会插入其他操作,也就不会出现线程安全问题了。

4. 能这么简单理解吗?

先问个问题,如果只对 count++ 或者是 count-- 加锁,会出现线程安全问题吗?

显然会?为啥?因为如果加一个就能解决的话为啥要加两个?(ps:哈哈哈哈,整个活)

我们正经分析一下,如果只对 count++ 加锁,两个线程同时运行,线程 A 在执行 count++ 的时候,由于 count-- 没有加锁,线程 B 还是可以执行 count-- ,只要两个线程同时执行,就会出现线程安全问题,也就是互相覆盖的情况。

我当时在疑惑什么呢?我在想, 给 count++ 加上 synchronized 关键字以后 count++ 就具有原子性了,原子性就代表中间不会存在其他操作,所以加上一个是不是也行?

显然我的理解是有问题的,首先加锁并不等同于原子性,为什么这么说?举个例子:

java 复制代码
   synchronized (Test1.class){
       count++;
   }

虽然多个线程执行上面的代码是一个线程一个线程去执行的,是原子性的,但是并不是说 count++ 这个操作就变成原子性的了,只是这段被 synchronized 包裹的代码是原子性的,多个线程不能同时执行这段代码,但是可以同时执行别的代码,就比如说 count++ 和 count-- ,如果只对其中一个加锁,那么他们就可以可以同时执行。

其次,给操作共享资源的代码块加锁,并不等于给资源加锁。对于 count 这个资源,我只给 count++ 加锁并不能阻止其他的线程去同时运行 count--,所以说只给 count ++ 加锁是没有用的,必须要同时给两个操作都加锁,并且锁对象必须是一个。

5. 总结

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

相关推荐
Hellyc30 分钟前
基于模板设计模式开发优惠券推送功能以及对过期优惠卷进行定时清理
java·数据库·设计模式·rocketmq
lifallen33 分钟前
Paimon LSM Tree Compaction 策略
java·大数据·数据结构·数据库·算法·lsm-tree
hdsoft_huge1 小时前
SpringBoot 与 JPA 整合全解析:架构优势、应用场景、集成指南与最佳实践
java·spring boot·架构
Livingbody2 小时前
基于【ERNIE-4.5-VL-28B-A3B】模型的图片内容分析系统
后端
百锦再2 小时前
详细解析 .NET 依赖注入的三种生命周期模式
java·开发语言·.net·di·注入·模式·依赖
程序员的世界你不懂2 小时前
基于Java+Maven+Testng+Selenium+Log4j+Allure+Jenkins搭建一个WebUI自动化框架(2)对框架加入业务逻辑层
java·selenium·maven
你的人类朋友3 小时前
🍃Kubernetes(k8s)核心概念一览
前端·后端·自动化运维
有没有没有重复的名字3 小时前
线程安全的单例模式与读者写者问题
java·开发语言·单例模式
追逐时光者4 小时前
面试第一步,先准备一份简洁、优雅的简历模板!
后端·面试
慕木兮人可4 小时前
Docker部署MySQL镜像
spring boot·后端·mysql·docker·ecs服务器