一次学习引发我对于 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)是同一个。

相关推荐
AskHarries12 分钟前
OpenClaw 如何查看历史对话和响应时间?
后端
n8n14 分钟前
Spring Boot 3.2+ 新特性、Spring AI 核心架构与 API Key 安全管理技术文档
后端
古法安卓14 分钟前
Android-epoll原理详解
android·java·android studio
fireworks9918 分钟前
接口鉴权(401与403)
java·后端
wear工程师22 分钟前
线程池里的异常去哪了?execute 和 submit 不是一回事
java
n8n23 分钟前
Java 21 虚拟线程(Virtual Threads)
后端
孔明click3330 分钟前
别人绕过我的网关直接调用资源服务怎么办?使用 Sa-Token 解决:网关转发鉴权、RPC调用鉴权
java·sa-token·springboot·权限·权限认证
吴声子夜歌33 分钟前
Java面试——设计模式(一)
java·设计模式·面试
Csvn42 分钟前
🐍 Day 5: Python 函数详解 — 参数、作用域与一等公民
人工智能·后端
半亩码田42 分钟前
C#转Python第3.6篇:Python 的 @property 比 C# 的 get/set 更灵活
java·python·c#