当前位置: 首页 > article >正文

5. 多线程(3) --- synchronized

文章目录

  • 前言
  • 1. 如何解决线程安全问题 [回顾]
  • 2. synchronized 关键字
    • 2.1. 示例
    • 2.2.对示例进行变化
    • 2.3 synchronized的其他写法
    • 2.4 synchronized的特性
      • 2.4.1 互斥
      • 2.4.2. 刷新内存
      • 2.4.3. 可重入


前言

前面我们通过在两个线程中共同对count进行加一操作,最后得到的结果和预期不一样,并且还通过画图得到了原因。在这个博客中,我们来解决一下问题—引入synchronized关键字


1. 如何解决线程安全问题 [回顾]

  1. 操作系统对于线程的调度随机的,抢占式的。
    这个是操作系统对于线程的底层设定,我们无法左右。

  2. 多个线程同时修改同一个变量。
    这个和代码的结果直接相关,通过调整代码结构,规避一些线程不安全的代码,但是在有些场景下,必须使用这种方案。
    例如,在超卖 / 超买 的问题中,某个商品,库存100件,不可以创建出101个订单吧。
    这个就是需求的情况下,需求就是需要多线程同时修改一个变量的。

  3. 修改操作,不是原子的
    通过加锁操作,把之前不是原子的count++ 包裹起来,在count++之前,先加锁,然后进行 count++,计算完毕之后,在解锁。
    执行完这三步,其他线程就无法插队了。
    加锁操作,不是把线程锁死到 CPU上,禁止这个线程被调度走,而是禁止让其他线程重新加这个锁,避免其他线程的操作,在当前线程的执行过程中插队。
    在这里插入图片描述


2. synchronized 关键字

2.1. 示例

加锁 / 解锁 本身是操作系统提供的 api,很多编程语言都对于这样的 api 进行了封装,大多数的封装风格,都是采用两个函数 lock() 和 unlock()

  1. lock(); // 加锁
  2. // 执行一些要保护起来的逻辑
  3. unlock(); // 解锁

在Java中,使用 synchronized 这样的关键字,搭配代码块,来实现类似的效果。

synchronized(){ // 进入代码块,就相当于加锁
// 执行一些保护的逻辑
} // 出了代码块,就相当于 解锁

在这里插入图片描述
我在在代码中使用synchronized关键字。


public class Demo16 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (){
                    count++;
                }
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0;i < 50000;i++){
                synchronized (){
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count:"+count);
    }
}

其中synchronized() 括号中的参数应该填什么呢?
填写的是用来加锁的对象,要加锁,要解锁,顾名思义,前提得有一把锁,在Java中,任何一个对象都有用作成"锁"。
这个对象的类型不重要,重要的是,是否有多个线程尝试针对这同一个对象进行加锁,换言之,是否多个线程同时竞争同一把锁。
在这里插入图片描述
那么我们现在就创建一把锁,放到参数中,然后运行看一下结果。


public class Demo16 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (object){
                    count++;
                }
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0;i < 50000;i++){
                synchronized (object){
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count:"+count);
    }
}

在这里插入图片描述
生成了一个object的锁对象,两个线程,使用同一把锁,才会产生互斥的效果。这是因为,一个线程加上了锁,另一个线程就得阻塞等待,等到第一个线程释放锁之后,才有机会拿到锁。
反之,如果采用不同的锁对象,此时不会产生互斥的效果,线程安全就没有得到改变。
在这里插入图片描述
我们可以对上面的进行修改,例如 把 synchronized放到 for循环的外面,或者是把锁对象类型进行变化,然后观察一下现象是否发生改变,我们说干就干!

2.2.对示例进行变化

  1. 把 synchronized放到 for循环的外面,为什么这个操作是可以的呢?
    这是因为t1和t2线程在并发执行过程中,相当于只有 count++ 这个操作,会涉及到互斥,for 循环里的条件判断 (i<50000) 和 i++ 这两个操作不涉及到互斥,所以可以直接把 synchronized放到 for 循环外面。
    在这里插入图片描述
    Demo16
public class Demo16 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000000; i++) {
                synchronized (object){
                    count++;
                }
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0;i < 50000000;i++){
                synchronized (object){
                    count++;
                }
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count:"+count);
    }
}

Demo17

public class Demo17 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread(()->{
            synchronized (locker){
                for (int i = 0; i < 50000000; i++) {
                    count++;
                }
            }
        });
        Thread t2 = new Thread(()->{
            synchronized (locker){
                for (int i = 0; i < 50000000; i++) {
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count:"+count);
    }
}

这两种写法,都可以得到我们想要的结论,我们可以通过画图,来分析一下他们的在底层上的细微差别。
这个是Demo16的
在这里插入图片描述
这个是Demo17的
在这里插入图片描述
通过我们的画图分析,我们发现第一种的情况是比较好的,相较于 第二种。
第二种还有一种写法,其实在上一篇博客中写过了,我们再拿过来看看。

/**
 *
 * @author admin
 * @date 2024/11/25
 * @Description 
 */
public class Demo15 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0 ;i<50000;i++){
                count++;
            }
        });

        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        t1.start();
        t1.join();
        t2.start();
        t2.join();
        System.out.println("count:"+count);
    }
}

这个代码不就是串行执行吗,t1一直在join阻塞等待,直到t1结束以后,t2才开始执行,跟上面synchronized的效果是一样的。
解释完第一个,那我们就看一下第二个吧。
2. 把锁对象类型进行变化,然后观察一下现象是否发生改变
我们把锁对象都换成t1,看看情况如何。

/**
 * @Author: XXHH
 * @CreateTime: 2024-12-05
 */
public class Demo18 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread(()->{
            Thread thread = Thread.currentThread();
            for (int i = 0; i < 500000; i++) {
                synchronized (thread){
                    count++;
                }
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 500000; i++) {
                synchronized (t1){
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count:"+count);
    }
}

在这里插入图片描述
在这里插入图片描述

2.3 synchronized的其他写法

我们之前再讲String的时候,提到过StringBuilder和StringBuffer这两个类的时候,我们讲到了StringBuilder是不安全的,StringBuffer是安全的。我们现在观察一下他们的源码,从哪里可以看到是否安全?
在这里插入图片描述

在这里插入图片描述
根据上面的截图或者是大家看源码,我们可以发现,StringBuffer 的主要方法都有synchronized关键字,而StringBuilder则没有,因此StringBuffer 可以有效保证线程安全。
当然我们看这个源码还有一个用途,我们发现synchronized可以修饰方法,我们也可以把上面的代码改成方法的形式。

/**
 * @Author: XXHH
 * @CreateTime: 2024-12-05
 */
class Counter{
    private int count = 0;
    synchronized public void add(){
        count++;
    }
    public int getCount(){
        return count;
    }
}
public class Demo18 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 500000; i++) {
                counter.add();
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 500000; i++) {
                counter.add();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count:"+counter.getCount());
    }
}

在这里插入图片描述

我们为此直接创建一个Counter类,里面实现一个具有synchronized的add方法,然后观察现象。
在这里插入图片描述
同理我们也可以为静态方法来使用synchronized进行修饰。

/**
 * @Author: XXHH
 * @CreateTime: 2024-12-05
 */
class Counter{
    // private int count = 0;
    public static int count;
    /*synchronized public void add(){
        count++;
    }
    public int getCount(){
        return count;
    }*/
    public synchronized static void add(){
        count++;
    }
    public static int getCount(){
        return Counter.count;
    }
}
public class Demo19 {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 500000; i++) {
                Counter.add();
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 500000; i++) {
                synchronized (Counter.class){
                    Counter.count++;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count:"+Counter.getCount());
    }
}

在这里插入图片描述
我们主要是来观察

	public synchronized static void add(){
	        count++;
	}

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

这俩段代码,通过观察我们发现,效果一样。
在这里插入图片描述

2.4 synchronized的特性

分为下面三个,互斥刷新内存可重入

2.4.1 互斥

前面的所有代码产生的效果,都是来源于互斥,我们可以用一个例子,形象的比喻一下,我们欢迎我们的助教老师 — 滑稽老铁
现在有很多滑稽老铁,都要去上厕所,但是只有一个卫生间,首先 滑稽老铁A 先进入到 卫生间,为了防止他人偷窥,插入了一把锁(synchronized),剩余的滑稽老铁只能阻塞等待,等到滑稽老铁A上完了,锁释放了,其余的滑稽老铁蜂拥而至,谁先抢到,谁就先进去,可以不遵守先来后到,这就是整个过程。

在这里插入图片描述
在这理解一下阻塞等待
针对每一把锁,操作系统内部维护了一个等待队列,当这个锁被某个线程占有的时候,其他线程尝试进行加锁,就加不上了,就会阻塞等待,一直等到之前的线程解锁之后,由操作系统唤醒一个新的线程,再来获取到这个锁。
注意

  • 上一个线程解锁之后,下一个线程并不是立即就能获取到锁,而是要靠操作系统来 “唤醒”,这也就是操作系统调度的一部分工作。
  • 假设有 A B C 个线程,线程A 先获取到锁,然后 B 尝试获取锁,然后 C 再尝试获取锁,此时B和C都在阻塞队列中排队等待,但是当A释放锁之后,虽然B比C先来的,但是B不一定就能获取到锁,而是要和C重新竞争,并不遵守先来后到的规则。

synchronized的底层是使用操作系统的 mutex lock 来实现的。

2.4.2. 刷新内存

synchronized的工作过程:
(1) 获得互斥锁
(2) 从主内存拷贝变量的最新副本到工作的内存
(3) 执行代码
(4) 将更改后的共享变量的值刷新到主内存中。
(5) 释放互斥锁
所以 synchronized 也能保证内存可见性,具体的代码请看下一个博客volatile部分。

2.4.3. 可重入

synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。
例如我们写个代码来观察一下。

public class Demo20 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread(()->{
            for (int i = 0;i<50000;i++){
                synchronized (locker){
                    synchronized (locker) {
                        count++;
                    }
                }
            }
        });
        t1.start();
        t1.join();
        System.out.println("count:"+count);
    }
}

在这里插入图片描述
大家一看到这种代码,毋庸置疑,觉得程序员有毛病,这种问题都会犯。但是如果是这样写呢?

/**
 * @Author: XXHH
 * @CreateTime: 2024-12-05
 */
class Counter2{
    private int count;
    synchronized public void add(){
        count++;
    }
    public int getCount(){
        return count;
    }
}
public class Demo20 {
//    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Counter2 counter2 = new Counter2();
        Object locker = new Object();
        Thread t1 = new Thread(()->{
            for (int i = 0;i<50000;i++){
                /*synchronized (locker){
                    synchronized (locker) {
                        count++;
                    }
                }*/
                synchronized (locker){
                    counter2.add();
                }
            }
        });
        t1.start();
        t1.join();
        System.out.println("count:"+counter2.getCount());
    }
}

在这里插入图片描述
这两段代码块,在不同的位置上,都使用了 synchronized 关键字,这就不容易发现问题。
我们还是看一下用locker和locker2两个锁的情况吧,分析一下

  1. 第一次进行加锁操作,能够成功 (锁没有人使用)
  2. 第二次进行加锁,此时意味着,锁对象已经被占用了,第二次加锁,就会触发阻塞等待

要想解除阻塞,需要往下执行才可以,但是要想往下执行,就需要等到第一次的锁被释放,出现这样的问题,称之为“死锁”。
根据我们的分析,上面的代码会出现严重的bug,但是执行成功,说明JVM对 synchronized 引入了可重入的功能和概念。

我们来分析一下,JVM是如何得知的此处是可重入的。

/**
 * @Author: XXHH
 * @CreateTime: 2024-12-05
 */
class Counter2{
    private int count;
    synchronized public void add(){
        count++;
    }
    public int getCount(){
        return count;
    }
}
public class Demo20 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Counter2 counter2 = new Counter2();
        Object locker = new Object();
        Thread t1 = new Thread(()->{
            for (int i = 0;i<50000;i++){
                synchronized (locker){
                    synchronized (locker) {
                        synchronized (locker) {
                            count++;
                        }
                    }
                }
             }
        });
        t1.start();
        t1.join();
        System.out.println("count:"+count);
    }
}

其实是 JVM 是 先引入一个变量,计数器 0, 每次触发 { 的时候,把计数器 ++,每次触发 } 的时候,把计数器 - -,当计数器- - 到 0 的时候,就是真正需要解锁的时候。
JVM 中如何区分 synchronized 的 大括号呢?
{ } 只是 我们看Java 代码的角度理解的,
JVM 看到的是对应的字节码。
字节码中,对应的是不同的指令 { 涉及到加锁指令,} 对应到解锁指令
当然了 if while 的 { } 不会被编译成加锁解锁的指令。
综上,可重入锁的实现原理,关键在于让锁对象,内部保存,当前是线程持有的这把锁。后续有线程针对这个锁加锁的时候,对比一下,所持有者的线程和当前加锁的线程是否是同一个。
在这里插入图片描述


写一篇我们讲解 死锁问题,我们不见不散!


http://www.kler.cn/a/471654.html

相关文章:

  • 【SQL】掌握SQL查询技巧:数据分组与排序
  • 06-RabbitMQ基础
  • 如何从串 ‘ 中国 +86‘ 中,获取到‘中国’:strip()、split()及正则表达式的使用
  • 【工业场景】用YOLOv8实现工业安全帽识别
  • Kafka如何实现顺序消费?
  • QML学习(八) Quick中的基础组件:Item,Rectangle,MouseArea说明及使用场景和使用方法
  • 力扣经典题目之2283. 判断一个数的数字计数是否等于数位的值
  • [网络安全]DVWA之File Upload—AntSword(蚁剑)攻击姿势及解题详析合集
  • windows蓝屏以及windows补丁回滚
  • Solaris操作系统
  • JavaScript系列(12)-- 高阶函数应用
  • Spring5框架之SpringMVC
  • 记录一下Coding一直不能clone
  • 7_TypeScript Number --[深入浅出 TypeScript 测试]
  • Ae:合成设置 - 3D 渲染器
  • 除了RAII和智能指针,还有哪些资源管理机制?
  • Java设计模式 —— 【行为型模式】命令模式(Command Pattern) 详解
  • doris 2.1 Data Queries Common Table Expression UDF 学习笔记
  • 【LeetCode】4. 去重的效率提升
  • 基于CentOS的Docker + Nginx + Gitee + Jenkins部署总结(进阶)-- 接入钉钉通知功能
  • C# 对象和类型(结构)
  • GOAT‘S AI早鸟报Part9
  • 2019年IMO第2题
  • 深入解析Java 8中的Lambda表达式与函数式接口
  • MATLAB语言的数据结构
  • 【Javascript Day2】