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

文章目录

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

java 复制代码
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中,任何一个对象都有用作成"锁"。

这个对象的类型不重要,重要的是,是否有多个线程尝试针对这同一个对象进行加锁,换言之,是否多个线程同时竞争同一把锁。

那么我们现在就创建一把锁,放到参数中,然后运行看一下结果。

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

java 复制代码
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的

通过我们的画图分析,我们发现第一种的情况是比较好的,相较于 第二种。

第二种还有一种写法,其实在上一篇博客中写过了,我们再拿过来看看。

java 复制代码
/**
 *
 * @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的效果是一样的。

解释完第一个,那我们就看一下第二个吧。

  1. 把锁对象类型进行变化,然后观察一下现象是否发生改变

我们把锁对象都换成t1,看看情况如何。

java 复制代码
/**
 * @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可以修饰方法,我们也可以把上面的代码改成方法的形式。

java 复制代码
/**
 * @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进行修饰。

java 复制代码
/**
 * @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());
    }
}

我们主要是来观察

java 复制代码
	public synchronized static void add(){
	        count++;
	}

java 复制代码
	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 同步块对同一条线程来说是可重入的 ,不会出现自己把自己锁死的问题。

例如我们写个代码来观察一下。

java 复制代码
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);
    }
}

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

java 复制代码
/**
 * @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是如何得知的此处是可重入的。

java 复制代码
/**
 * @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 的 { } 不会被编译成加锁解锁的指令。

综上,可重入锁的实现原理,关键在于让锁对象,内部保存,当前是线程持有的这把锁。后续有线程针对这个锁加锁的时候,对比一下,所持有者的线程和当前加锁的线程是否是同一个。


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

相关推荐
EviaHp21 分钟前
递归构建树菜单节点
java·spring boot·后端·maven·idea
HappyAcmen37 分钟前
关于Java适配器模式的面试题目及其答案
java·面试·适配器模式
love静思冥想38 分钟前
自动化执行 SQL 脚本解决方案
java·数据库·sql·自动化
Miraitowa_cheems1 小时前
【JavaEE】Spring Web MVC
java·spring boot·java-ee
iamlzjoco1 小时前
idea报错Malformed \uxxxx encoding.报错解决
java·ide·intellij-idea
华年源码2 小时前
基于SpringBoot的旅游网站的设计与实现(源码+数据库+文档)
java·毕业设计·源码·springboot·旅游
北京_宏哥2 小时前
《手把手教你》系列技巧篇(十九)-java+ selenium自动化测试-元素定位大法之By css下卷(详细教程) 1.简介
java·selenium·前端框架
程序猿零零漆2 小时前
SpringCloud系列教程:微服务的未来(十)服务调用、注册中心原理、Nacos注册中心
java·spring cloud·微服务
就爱学编程2 小时前
力扣刷题:数组OJ篇(上)
java·算法·leetcode
一条小小yu2 小时前
从零手写缓存框架(二)redis expire 过期原理
java·redis·缓存