目录

 5.synchronized关键字-监视器锁monitor lock

5.1synchronized的特性和写法

1)特性1:互斥

写法1:修饰代码块-明确指定锁哪个对象

答疑:

Q1:作为锁的对象只能作为锁吗?可以正常使用吗?

Q2:能不能用synchronized代码块把for循环包起来?

Q3:Q2中的执行性能,第二个比第一个更差吗?

Q4:对于锁来说任意的对象都行嘛?有什么适合做锁的对象?

Q5:给currentThread加锁行吗?

Q6:之前讲线程状态不是还有一个BLOCKED(阻塞的)吗?

提出问题:Java中为啥使用synchronized+代码块 的写法?而不是采用lock+unlock函数的方式来搭配呢?

其他写法

写法2:直接修饰普通方法

写法3:修饰静态方法

阶段性小结

2)特性2:可重入

答疑:那Java就不会产生死锁了??

提出问题:

Q1:如下图,既然第一次加锁是真正加锁的地方,那什么时候是真正解锁的地方??

Q2:站在JVM的视角,看到多个 } 需要执行,JVM如何知道哪个 } 是真正解锁那个??

答疑:但是怎么区分大括号是synchronized的大括号呢??

死锁的其他情况

情况2:两个线程,两把锁,每个线程获取到一把锁之后,尝试获取对方的锁

提出问题:如果在上述代码中,不加sleep,是否还会出现一样的现象??

情况3:N个线程,M把锁

5.2synchronized使用示例小结

1)修饰代码块:明确指定锁哪个对象

锁任意对象

锁当前对象

2)直接修饰普通方法

3)修饰静态方法

5.3如何避免代码中出现死锁呢?

死锁构成的四个必要条件(重要)

1.锁是互斥的(锁的基本性质)

2.锁是不可剥夺的(锁的基本特性)

3.请求和保持

4.循环等待

死锁的小结

5.4Java标准库中的线程安全类

6.内存可见性

内存可见性出现的原因:编译器优化

解决内存可见性问题

不使用sleep如何解决上述的内存可见性问题呢?使用volatile!

volatile不保证原子性

答疑:volatile只能修饰变量吗?

Java内存模型(JMM)

答疑

Q1:为啥说法不同??

Q2:那为啥不直接用register表示??


书接上文:Java EE:2.多线程-初阶(第三弹)~

 5.synchronized关键字-监视器锁monitor lock

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

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

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

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

synchronized的发音&拼写,非常重要!!!

在计算机中,同步这个术语,本身也有多种含义,此处“同步”指的是“互斥”(后续谈到IO,还会说,同步IO以及异步的IO)

闲聊:

经常有同学,synchronized不会读/不会写,面试的时候,告诉面试官,你不会读这个词或者读错了面试官听不懂……那基本上这次面试就凉凉了~

有同学会说:反问一下面试官你会念吗?

如果你雀食不想要这个offer,可以这么问~

但凡是你想要这个offer,还是要 舔一舔~

因为你这么一问,面试官给你贴个标签:你是个刺头儿,就算技术很好,招聘进来也是非常难管理的类型~

面试中,专门有一个反问环节:你有啥问题要问我不??

这个非常关键,笑里藏刀,一个问题问不好,反而把前面的优势送没了~~

很多东西不能问!!!

比如技术类问题、问长得这么帅,咋保养的、是否加班……

同学A:我还要哪些方面有待提高?

勉强可以~~不是最优解~

同学B:咱们团队项目前景如何?

这个太利己了,其实是在问自己以后的前景(其实也无可厚非)

反问环节,只能问两个问题~~

1.问团队的业务

问咱们团队具体是干啥的~~

让面试官非常好发挥,给他机会,让他吹一波~~

他吹完之后,他会很爽

在他吹的过程中,你表现出非常浓厚的兴趣(不一定是真的,考验演技了~),面试官会认为你意向强烈~~

2.问咱们团队,对于新人的培养是怎样的机制

需要我这边提前学习那些相关的知识技能~~

一方面能体现出你的兴趣

好比你在追妹子的时候,你和妹子都相互喜欢,但是妹子还有些犹豫,你开始给妹子画饼,畅想以后在一起的幸福生活,让她安心~~

另一方面也体现,你非常积极的希望技术成长~

5.1synchronized的特性和写法

1)特性1:互斥

我们拿出上节课的代码,稍加增改进行演示synchronized在代码中的作用👇

package thread;

/**
 * Created with IntelliJ IDEA.
 * Description:
 * User: CoderYanger
 * Date: 2026-05-18
 * Time: 18:00
 */
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++;
            }
            System.out.println("t1 结束");
        });
        Thread t2=new Thread(()->{
            for(int i=0;i<50000;i++){
                count++;
            }
            System.out.println("t2 结束");
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        //一个线程自增5w次,两个线程,总共自增10w次,预期结果,count=10_0000
        System.out.println(count);
    }
}
写法1:修饰代码块-明确指定锁哪个对象

只需把count用锁包起来👇

那么我们会发现()处会报错,那么()里应该填写啥呢?

填写的是,用来加锁的对象

因为要加锁、要解锁,前提是有一个锁

在Java中,任何一个对象,都可以用作“锁”

这个对象的类型是啥?不重要

重要的是,是否有多个线程尝试针对这同一个对象加锁(是否在竞争同一个锁)

完整代码👇

package thread;

/**
 * Created with IntelliJ IDEA.
 * Description:
 * User: CoderYanger
 * Date: 2026-05-18
 * Time: 18:00
 */
public class Demo15 {
    private static int count=0;
    public static void main(String[] args) throws InterruptedException {
        Object locker=new Object();
        //String s=new String();//用s当锁对象也可以
        Thread t1=new Thread(()->{
            for(int i=0;i<50000;i++){
                synchronized (locker){
                    count++;
                }
            }
            System.out.println("t1 结束");
        });
        Thread t2=new Thread(()->{
            for(int i=0;i<50000;i++){
                synchronized (locker){
                    count++;
                }
            }
            System.out.println("t2 结束");
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        //一个线程自增5w次,两个线程,总共自增10w次,预期结果,count=10_0000
        System.out.println(count);
    }
}

但如果是不同的锁对象,此时不会有互斥效果,那么线程安全问题还是存在的👇

因此解决线程安全问题,不是你写了synchronized就可以,而是要正确的使用锁~~

1)synchronized{ }代码块要合适

2)synchronized{ }指定的锁对象也得合适

如果把锁对象想象成妹子,线程就是追妹子的小哥

如果咱俩追“同一个妹子”,如果我先追上了(加锁),你就得阻塞等待

等到我俩分手(解锁),你才有机会~

如果是追的“不同妹子”,咱俩的进度,各自不影响~

两个线程竞争同一把锁,才会产生阻塞等待,两个线程分别尝试获取两把不同的锁,不会产生竞争

答疑:
Q1:作为锁的对象只能作为锁吗?可以正常使用吗?

把对象作为锁对象,不影响对象的其他使用的

但是一般来说,编程的原则,一个对象只有一个用途,才是比较好的

Q2:能不能用synchronized代码块把for循环包起来?

也是可以的👇(部分代码)

        Thread t1=new Thread(()->{
            synchronized (locker){
                for(int i=0;i<50000;i++){
                    count++;
                }
            }
            System.out.println("t1 结束");
        });
        Thread t2=new Thread(()->{
            synchronized (locker){
                for(int i=0;i<50000;i++){
                    count++;
                }
            }
            System.out.println("t2 结束");
        });

 

可以发现结果也是一样的,但是这两种结果,在程序执行过程的角度来看,还是差异很大的~

像之前的操作中:这俩线程并发执行的过程中,相当于只有count++这个操作,会涉及到互斥~

for循环里的条件判断(i<50000)和 i++操作这俩操作不涉及到互斥~(相当于对count的++操作是串行执行的,但是 i<50000和 i++是并发的~)

但是这次的操作中:意味着,整个for循环,i<50000,i++,count++都是“互斥”的方式进行的~~(相当于整体上全是串行执行的~)

Q3:Q2中的执行性能,第二个比第一个更差吗?

肯定的,所以我们首选第一个

Q4:对于锁来说任意的对象都行嘛?有什么适合做锁的对象?

没有硬性要求~

在Java中是任意对象都可以(不过一般不会这么做,如果只是作为锁对象,一般专门创建个对象出来)

但是在其他语言中,一般是有专门的类/类型,提供锁对象

Q5:给currentThread加锁行吗?

可以,但是需要确保,两个线程是针对同一个对象加锁,才有互斥

俩线程分别使用currentThread,此时得到的是不同对象,没有互斥~

下面写代码给大家演示👇

package thread;

/**
 * Created with IntelliJ IDEA.
 * Description:
 * User: CoderYanger
 * Date: 2026-05-20
 * Time: 13:38
 */
public class Demo17 {
    private static int count=0;

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

发现不能解决线程不安全的问题,原因如下👇

如果你雀食想这么写来解决线程安全问题的话,可以这么写👇

但是我们一般不会真的这么写,因为代码看起来太别扭了~~

Q6:之前讲线程状态不是还有一个BLOCKED(阻塞的)吗?

后面讲到死锁的时候再说~

提出问题:Java中为啥使用synchronized+代码块 的写法?而不是采用lock+unlock函数的方式来搭配呢?

其实在Java中,也有lock/unlock风格的锁,一般很少使用

//写成lock unlock的写法:
Lock locker=new Locker();
locker.lock();//加锁
//执行其他逻辑……
locker.unlock();//解锁

像C++,Python都是这么写的~~

但是这种写法,容易把unlock给遗漏了(这样的话你上了锁不解开,别人就用不了了,术语典型的“占着茅坑不拉💩”)

有的同学会说:只要我写了lock,就会立即加上unlock~~

这种说法,纯纯的“大猪蹄子行为”~

就好比:你给妹子保证,我这辈子只爱你一个,永远不会变心~~????

就算你非常细心,能够保证每个条件都加unlock,但是你不能保证,你们组新来的实习生,也能做到这一点(各位同学们很可能就是这个实习生)

而且代码中间抛出一个异常,也可能使unlock执行不到~~

Java采取的synchronized就能确保,只要出了 } 就一定能释放锁,无论因为return还是因为异常,无论里面调用了哪些其他代码,都是可以确保unlock操作执行到的

闲聊:

设计Java的大佬,为咱们操碎了心~~

尽可能想办法让咱们写的代码不要出错~~

此时,别的语言看到了Java这里的设定,觉得雀食挺香的~~也会参考过去~~

C++、Python、Go……也是针对释放锁有一些对应的措施

C++提供了lock_guard机制

Python提供了with语句(上下文管理器)

Go提供defer关键字

这些本质上都是和synchronized一样的,都是出了代码块就能自动释放

其他写法

写法2:直接修饰普通方法

下面的代码其实跟上面的代码本质上不变,只是把变量操作用类封装了一下👇

package thread;

/**
 * Created with IntelliJ IDEA.
 * Description:
 * User: CoderYanger
 * Date: 2026-05-20
 * Time: 14:57
 */
class Counter{
    private int count=0;
    public void add(){
        count++;
    }
    public int get(){
        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<50000;i++){
                counter.add();
            }
        });
        Thread t2=new Thread(()->{
            for(int i=0;i<50000;i++){
                counter.add();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count="+counter.get());
    }
}

很明显,默认情况下还是线程不安全👇

此时我们就可以进行加锁操作👇

这个是我们上述讲过的操作

我们也可以对其进行变形👇

我们发现输出结果都是一样的,使用synchronized修饰方法就相当于针对this进行加锁~

闲聊:

其实像StringBuffer、Vector这些对象,方法上就是带有synchronized(针对this加锁)👇

而StringBuiler没有👇

写法3:修饰静态方法

synchronized修饰方法还有一个特殊情况,static修饰的方法:不存在this

此时synchronized修饰static方法,相当于针对类对象加锁👇

    public synchronized static void func(){
        synchronized (Counter.class){

        }
    }

其中的Counter.class就是类对象(Java SE的反射章节讲过的,反射API拿到的信息,都是从类对象中拿的)

阶段性小结

1.线程安全的原因

1)[根本]随机调度,抢占式执行

2)多个线程同时修改同一个变量

3)修改操作不是原子的

4)内存可见性(后续再说)

5)指令重排序(后续再说)

2.解决线程安全问题

使用“加锁”操作(体现“互斥”的性质),用到synchronized关键字

synchronized(锁对象){//自动加锁
    //一些要保证线程安全的代码
}//自动解锁

针对锁对象:多个线程针对同一个对象加锁,才会产生互斥(锁冲突/锁竞争)

其他写法:

写法2:synchronized修饰普通方法,相当于给 this 加锁

写法3:synchronized修饰静态方法,相当于给类对象加锁

闲聊:

synchronized也叫监视器锁monitor lock,为啥这么叫???

不知道,无从考证,这是JVM中采用的一个术语,比如使用锁的过程中抛出一些异常,可能会看到  监视器锁  这样的报错信息

2)特性2:可重入

先给大家讲解一下什么是“死锁”(dead lock)

上述这样的问题,就称为“死锁”

①第一次进行加锁的时候,能够成功(锁没有人使用)

②第二次进行加锁,此时意味着,锁对象是已经被占用的状态,第二次加锁,就会触发阻塞等待

诸如此类的,有些死锁现象并不明显,这也是我们工作中容易出现问题的地方👇

package thread;

/**
 * Created with IntelliJ IDEA.
 * Description:
 * User: CoderYanger
 * Date: 2026-05-20
 * Time: 17:29
 */
class Counter2{
    private int count=0;
    synchronized void add(){
        synchronized (this){
            count++;
        }
    }
    public int get(){
        return count;
    }
}
public class Demo19 {
    public static void main(String[] args) throws InterruptedException {
        Counter2 counter=new Counter2();
        Thread t1=new Thread(()->{
            for(int i=0;i<50000;i++){
                synchronized (counter){
                    counter.add();
                }
            }
        });
        t1.start();
        t1.join();
        System.out.println("count="+counter.get());
    }
}

死锁是一个非常严重的Bug,使代码执行到这一块之后,就卡住了

为了解决上述问题,Java的synchronized就引入了可重入的概念

但是当我们真正去执行上述代码,会发现并没有出现死锁👇

原因就是synchronized内置的可重入的概念解决了这个问题

而C++那边的锁(std::mutex,不具有可重入特性),雀食就会死锁~~

我们加四层锁,会发现依然能够正常运行👇

这个过程中:当某个线程针对一个锁,加锁成功之后,后续该线程再次针对这个锁进行加锁时,不会触发阻塞,而是直接往下走

因为当前这把锁就是被这个线程持有~~

但是,如果是其他线程尝试加锁,就会正常阻塞

比如说:咱追妹子的时候

咱跟妹子说:大宝贝我爱你,么么哒~~(申请进行加锁)

如果妹子这边加锁成功,此时,她也会进行类似的回应~~

我们从此之后就建立好关系了~~

关系建立好之后,后续,如果我再把上面的话,多说几次:

大宝贝我爱你,么么哒~~(申请进行加锁)

大宝贝我爱你,么么哒~~(申请进行加锁)

大宝贝我爱你,么么哒~~(申请进行加锁)

大宝贝我爱你,么么哒~~(申请进行加锁)

就会发现,没什么用了,此时的”申请加锁“已经变成了“日常情趣”

这里是不会产生任何的阻塞状态的~~

但是如果这个过程中,隔壁老王,也对妹子说类似的话,此时,他就得阻塞等待~~

答疑:那Java就不会产生死锁了??

Java中当然会产生死锁,只不过死锁有多种情况

可重入锁只是针对:一个线程,一把锁,连续加锁多次的情况~~

死锁的其他情况,我们后续再说

可重入锁的实现原理:关键在于让锁对象,内部保存,当前是哪个线程持有的这把锁

后续有线程针对这个锁加锁的时候,对比一下,锁持有者的线程是否和当前加锁的线程是同一个

提出问题:
Q1:如下图,既然第一次加锁是真正加锁的地方,那什么时候是真正解锁的地方??

当然是最后一个 } 

最外层:真正加锁

最外层:真正解锁

Q2:站在JVM的视角,看到多个 } 需要执行,JVM如何知道哪个 } 是真正解锁那个??

括号匹配??不需要,那样的话还得搞个栈啥的,直接记个数就行了

①先引入一个变量,计数器(初始化为0)

②每次触发 { 的时候,把计数器++

③每次触发 } 的时候,把计数器- - 

④当计数器 - - 为0的时候,就是真正需要解锁的时候~~

闲聊:以后面试官问你:如何自己实现一个可重入锁??

①再锁内部记录当前是哪个线程持有的锁,后续每次加锁,都进行判定

②通过计数器,记录当前加锁的次数,从而确定何时真正进行解锁

答疑:但是怎么区分大括号是synchronized的大括号呢??

{ } 只是Java代码角度理解的,JVM看到的是:字节码

.java=>.class这个过程中,编译器已经处理了

Java代码中看到的是 { } 

字节码中,对应的是不同的指令

“ { ” :涉及到加锁指令

“ } ” :涉及到解锁指令

if、while它们的 {  }  不会被编译成  加锁 解锁指令~

闲聊:

.java=>.class,然后JVM执行.class,这个过程涉及到编译原理(如何实现一个“编译器”)

当你把编译原理学明白了之后,就能够“自创编程语言”了,说出去还是挺有面儿的~~

推荐的书籍:

①龙书

②虎书

③鲸书

死锁的其他情况

上述我们介绍的死锁的一种情况:一个线程,一把锁,连续加锁两次

情况2:两个线程,两把锁,每个线程获取到一把锁之后,尝试获取对方的锁

举个例子:吃饺子~

东北人吃饺子,蘸酱油~

陕西人吃饺子,蘸醋(更通用的吃法)

现在我们的做法,是同时蘸酱油和醋~~

我拿起酱油,妹子拿起醋

我:你把醋给我,我用完了,全都给你

妹子:凭啥?你先把酱油给我,我用完了,都给你

如果我们俩互不相让,就会构成死锁~~

再比如说:钥匙锁车里了,车钥匙锁家里了~

下面用代码演示一下👇(这也是一个经典的面试题:让你手写一个出现死锁的代码)

C++方向,代码就好写,直接加锁两次就行了

Java方向,就得写下述代码,两个线程两把锁,精准控制好加锁的顺序

package thread;

/**
 * Created with IntelliJ IDEA.
 * Description:
 * User: CoderYanger
 * Date: 2026-05-20
 * Time: 18:39
 */
public class Demo20 {
    public static void main(String[] args) throws InterruptedException {
        Object locker1=new Object();
        Object locker2=new Object();
        //这个过程必须是嵌套的关系
        //必须是,拿到第一把锁,再拿第二把锁(不能释放第一把锁)
        Thread t1=new Thread(()->{
            synchronized (locker1){
                //我拿起酱油
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                //我尝试拿起醋
                synchronized (locker2){
                    System.out.println("t1 线程两个锁都获取到");
                }
            }
        });
        Thread t2=new Thread(()->{
            synchronized (locker2){
                //妹子拿起醋
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                //妹子尝试拿起酱油
                synchronized (locker1){
                    System.out.println("t2 线程两个锁都获取到");
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }
}

我们发现,确实出现了“死锁”的情况👇

接下来我们借助jconsole.exe来看一下相关的状态👇

(之前说的BLOCKED的线程状态在这里结合死锁讲解~~~)

提出问题:如果在上述代码中,不加sleep,是否还会出现一样的现象??

不一定!!

这就要看具体的调度顺序了~

上述代码加上sleep,是为了确保:

t1拿到locker1

t2拿到locker2

等待1s

t1尝试拿locker2

t2尝试拿locker1

如果不加sleep,很可能t1一口气儿就把locker1和locker2都拿到了,这个时候t2还没开动呢~~

自然无法构成死锁~~

那死锁的概率有多大呢??

这个很难讲~和当前电脑的运行环境有关,还要看你的当前机器上运行的任务多不多,系统调度的频次是怎样的……

闲聊:

一定不要挂科,最终一定得通过的,否则没有学位证

后续无论你拿到多牛逼的offer,都无法入职!!

你在入职公司的时候,需要携带学位证+毕业证的

HR一般入职现场的时候,在学信网上进行验证

没有双证,意味着你这边违约,offer取消

情况3:N个线程,M把锁

一个经典的模型:哲学家就餐问题

在古代,文艺复兴时期的哲学家,当时欧洲卫生条件非常差,所以哲学家之间不会介意彼此用过的筷子

5个哲学家,随机的触发:吃面条和思考人生

①吃面条:拿起筷子

②思考人生:放下筷子,思考

5个哲学家=5个线程

5根筷子=5把锁

每个线程只需要拿到其中的两根筷子即可~~

大部分情况下,上述模型,可以很好的运转,但是在一些极端情况下会造成死锁

同一时刻,大家都想吃面条,同时拿起左手的筷子

由于哲学家是非常执拗的人,每个人都不会放下手中的筷子,而是等~~

此时,任何一个线程都无法拿起右手的筷子,任何一个哲学家都吃不到面条~~

闲聊:

咱们以后的工作,大部分是做服务器开发的,需要同时给很多个用户提供服务,假设上述场景,出现死锁的概率是1%%,可能感觉算不了什么,但是在咱们国家,再小的问题,×13亿都是大问题~~

5.2synchronized使用示例小结

synchronized本质上要修改指定对象的“对象头”,从使用角度来看,synchronized也势必要搭配一个具体的对象来使用

1)修饰代码块:明确指定锁哪个对象

锁任意对象
public class SynchronizedDemo {
    private Object locker = new Object();

    public void method() {
        synchronized (locker) {

        }
    }
}
锁当前对象
public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            
        }
    }
}

2)直接修饰普通方法

public class SynchronizedDemo {
    public synchronized void method() {
        
    }
}

3)修饰静态方法

public class SynchronizedDemo {
    public synchronized static void method() {
        
    }
}

5.3如何避免代码中出现死锁呢?

首先要知道死锁是怎样构成的,我们针对构成原因逐一攻克即可~~

死锁构成的四个必要条件(重要)

1.锁是互斥的(锁的基本性质)

一个线程拿到锁之后,另一个线程再尝试获取锁,必须要阻塞等待

2.锁是不可剥夺的(锁的基本特性)

线程1拿到锁,线程2也尝试获取这个锁,线程2必须阻塞等待,而不是线程2直接把锁抢过来~

注意:至少Java的synchronized是遵守这两点的~~

除非是你自己实现一个锁,解决特定的问题,可以打破这两点,至少各种语言内置的锁/主流的锁的实现,都会遵守这两点~~

3.请求和保持

一个线程拿到锁1之后,不释放锁1的前提下去获取锁2

还是上面哲学家吃面条就餐的例子,如果先放下左手的筷子,再拿右手的筷子,就不会构成死锁

这就需要代码中加锁的时候,不要去“嵌套”👇

我们发现死锁现象就消失了👇

但其实这种做法,是不够通用的,因为有些情况下,我们确实需要先拿到多个锁,在进行某个操作的,所以这个嵌套问题,很难避免

4.循环等待

多个线程,多把锁之间的等待过程,构成了“循环”:

A等待B,B也等待A

A等待B,B等待C,C等待A

解决方式:约定好加锁的顺序,就可以破除循环等待了

还是拿上面哲学家就餐的例子👇

哲学家就餐-解决死锁问题

在上述代码中,我们也可以用类似的做法,都是先获取编号小的👇

我们发现,死锁的问题也解决了👇

破坏掉上述的3或者4任何一个条件,都能够打破死锁~~

死锁的小结

死锁的知识是面试中重要的考点,也是工作中,写多线程代码的重要注意事项

1.构成死锁的场景

a)一个线程一把锁=>可重入锁

b)两个线程两把锁=>代码如何编写

c)N个线程M把锁=>哲学家就餐问题

2.死锁的四个必要条件

a)互斥

b)不可剥夺

c)请求和保持

d)循环等待

3.如何避免死锁

打破必要条件的c)和d)

打破c):把嵌套的锁改成并列的锁

打破d):对加锁的顺序做出约定

5.4Java标准库中的线程安全类

1.Java标准库中很多都是线程不安全额,这些类可能会涉及到多线程修改共享数据,又没有任何加锁措施,比如👇

①ArrayList

②LinkedList

③HashMap

④TreeMap

⑤HashSet

⑥TreeSet

⑦StringBuilder

2.但是还有一些是线程安全的,使用了一些锁机制来限制

①Vector(不推荐使用)

②HashTable(不推荐使用)

③StringBuffer(不推荐使用)(最直观的就是我算法部分的博客,一旦使用了StringBuffer,运行速度降低了老大一截子~~😭)

这仨兄弟,虽然有synchronized,但是不推荐使用

因为加锁这个事情,不是没有代价的

一旦代码中使用了锁,意味着代码可能会因为锁的竞争,产生阻塞=>程序的执行效率大打折扣(线程阻塞=>从CPU上调度走,啥时候能调度回来继续执行???不好说了~~沧海桑田)

因此一定要思考清楚,这个地方是否确实需要锁~~不需要的时候不要乱加

④ConcurrentHashMap:相对于HashTable来说,属于高度优化的版本(后续详细分析)
3.还有的虽然没有加锁,但是不涉及“修改”,仍然是线程安全的

String

6.内存可见性

造成线程安全问题的原因之一

下面用代码来演示一下👇

package thread;

import java.util.Scanner;

/**
 * Created with IntelliJ IDEA.
 * Description:
 * User: CoderYanger
 * Date: 2026-05-20
 * Time: 22:28
 */
public class Demo21 {
    private static int flag=0;
    //实现一个线程进行读取,另一个线程进行修改
    public static void main(String[] args) {
        Thread t1=new Thread(()->{
            while (flag==0){

            }
            System.out.println("t1线程结束");
        });
        Thread t2=new Thread(()->{
            //针对 flag 进行修改
            Scanner sc=new Scanner(System.in);
            System.out.println("请输入 flag 的值:");
            flag=sc.nextInt();
        });
        t1.start();
        t2.start();
    }
}

我们发现,虽然输入了非0的值,但是此时 t1 线程循环并没有结束,t1 线程仍然持续执行~

下面通过jconsole.exe来查看一下线程状态,发现线程确实持续进行着循环👇

很明显,这也是Bug,也是线程安全问题:

一个线程读取,一个线程修改,修改线程修改的值,并没有被读线程读到,这就是“内存可见性问题”(我改了,你看不见)

内存可见性出现的原因:编译器优化

对于咱们写的代码,javac把.java=>.class=>jvm

而程序员的水平,参差不齐,并不能保证所有代码执行效率都高

因此研究JDK的大佬们,就希望通过让编译器&JVM对程序员写的代码,自动的进行优化

本来写的代码是进行xxxxxx,编译器/JVM会在你原有逻辑不变的前提下,对你的代码进行调整,使程序效率更高

闲聊:这个优化后的效果还是很明显的,尤其是在一些大的服务器上

启动好了要在硬盘上加载100多个G的数据,CPU和IO都很密集,如果不开启优化,启动时间在1h以上,但是服务器在开启优化的情况下,启动时间就能达到10min左右~~

编译器,虽然声称优化操作,是能够保证逻辑不变,尤其是在多线程的程序中,编译器的判断可能出现失误,可能导致编译器的优化,优化后的逻辑和优化前的逻辑出现细节上的偏差~

我们现在分析下面的代码在干什么?

while(flag==0){//短时间内,这个循环,就会循环很多次
    load//读内存操作

    cmp//纯CPU寄存器操作
    //因此load的时间开销可能是cmp的几千倍
}
//执行过程中,JVM就能感知到:
//load反复执行的结果,好像都是一样的

JVM开始犯嘀咕:我执行这么多次读flag的操作,发现值始终都是0,既然都是一样的结果,既然还要反复执行这么多次,何必呢~~??

于是就把  读取内存  的操作,优化成读取寄存器  这样的操作

(把内存的值读到寄存器了,后续再load不再重新读内存,直接从寄存器里来取)

于是,等到很多秒之后,用户真正输入新的值,开始真正修改flag的时候,此时 t1 线程,就感知不到了(编译器优化,使得 t1 线程的读操作,不是真正读内存)

解决内存可见性问题

如果我们稍稍微调上述代码,在while循环里加个sleep(1)👇

我们发现,程序就能正常执行了👇

为啥休眠1ms,代码就能正确了呢?

相当于转移矛盾~~

本来这个循环能转的飞起,1s能跑几千万次、上亿次……

但是加了sleep(1)之后,循环次数大幅度降低了

当引入了sleep之后,sleep消耗的时间相比于上面的load flag的操作,就高了不知道多少了

假设本身读取flag的时间是1ns的话

如果把读内存操作优化成寄存器,能从1ns优化到0.xxx ns,优化50%以上

如果引入sleep,sleep直接占用1ms

此时优不优化flag无足轻重~~

比如说,有一天你丢了100块钱

如果你的全部身家就是500块,丢100,影响非常大

如果你的全部身家是几百万,丢100,丢就丢了,相当于核心矛盾转移了~

闲聊:

编译器的优化,本身是一个比较复杂的工程,具体怎么优化,咱们作为普通程序员很难感知到~~

但是针对内存可见性问题,也不能全指望通过sleep来解决~

因为使用sleep会大大影响到程序的效率

不使用sleep如何解决上述的内存可见性问题呢?使用volatile!

JDK的大佬们,知道上述的可见性问题,但是在编译器优化的角度又难以进行调整

于是就在语法中,引入了 volatile 关键字,通过这个关键字来修饰某个变量,此时编译器对这个变量的读取操作,就不会被优化成读寄存器

下面演示一下使用volatile的效果👇

package thread;

import java.util.Scanner;

/**
 * Created with IntelliJ IDEA.
 * Description:
 * User: CoderYanger
 * Date: 2026-05-20
 * Time: 22:28
 */
public class Demo21 {
    private volatile static int flag=0;//加上volatile修饰
    //实现一个线程进行读取,另一个线程进行修改
    public static void main(String[] args) {
        Thread t1=new Thread(()->{
            while (flag==0){

            }
            System.out.println("t1线程结束");
        });
        Thread t2=new Thread(()->{
            //针对 flag 进行修改
            Scanner sc=new Scanner(System.in);
            System.out.println("请输入 flag 的值:");
            flag=sc.nextInt();
        });
        t1.start();
        t2.start();
    }
}

我们发现现在程序就可以正常结束了👇

说明我们进行下述操作后

private volatile static int flag=0;//加上volatile修饰

flag就变成了一个“易失的”、“易变的”变量,这样的变量的读取操作,就不会被编译器进行优化了

t2修改了,t1就能及时看到了

volatile不保证原子性

volatile是解决内存可见性问题,不是解决原子性问题

拿之前的count++的例子来演示一下👇

package thread;

/**
 * Created with IntelliJ IDEA.
 * Description:
 * User: CoderYanger
 * Date: 2026-05-20
 * Time: 23:34
 */
public class Demo22 {
    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();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count="+count);
    }
}

我们发现其中count前是否加volatile,线程不安全问题仍然存在

private static int count=0;

加上volatile后👇

private volatile static int count=0;

原因:

这个过程不是可见性问题,是原子性问题,count在修改的过程中,被其他线程串插进来进行了中间结果的覆盖,而volatile是针对一个读一个写的场景涉及的可见性问题,这点需要进行区分

答疑:volatile只能修饰变量吗?

Yes!

闲聊:其实谈到volatile,在网上看相关资料,会发现与之相关的话题叫JMM(Java Memory Model,Java内存模型)

Java内存模型(JMM)

Java内存模型:出自Java官方文档的术语

每个线程,有一个自己的“工作内存”,同时这些线程共享同一个“主内存”

当一个线程循环进行上述读取变量操作的时候,就会把主内存中的数据,拷贝到该线程的工作内存中

后续另一个线程修改,也是先修改自己的工作内存,拷贝到主内存里

由于第一个线程仍然在读自己的工作内存,因此感知不到主内存的变化

咱们前面讲的是,把读内存的操作,优化成读寄存器操作(其实与上述表达是同一个意思~)

其实上述的“工作内存”,其实不是咱们说的“内存”,就是指的CPU的寄存器~~

答疑

Q1:为啥说法不同??

其实是翻译的不同~

工作内存:work memory

主内存:main memory

main memory才是咱们真正所说的内存~~

memory这个单词,只是表示“存储”的意思,这个词也能翻译成内存,也能翻译成“存储”

因此work memory应该翻译成“存储空间”

Q2:那为啥不直接用register表示??

Java文档上没有明确说“寄存器”,而是使用更抽象的 work memory 表示,是为了能够兼容不同的硬件设备

因为Java最大的特点是“跨平台”,Java程序员不需要关心硬件(CPU)的差别的~

而不同的CPU,用来缓存上述内存数据的区域可能是不同的~~

我们此时打开任务管理器,在CPU界面会看到以下几个东西👇

这就是缓存~

寄存器虽然快,但是空间太小,存不了多少东西呀~

于是开发CPU的大佬就在CPU上,另外建设了一些存储空间,称为“缓存”(存的越少,速度越快,比如上述的L1速度最快,L3速度最慢,但最慢也比内存快)

闲聊:

最早的CPU没有缓存,后来有了L1缓存,后来又有了L1+L2缓存,现在是L1+L2+L3缓存,未来会不会有L4??不好说~

内存数据缓存到CPU里,具体是在寄存器上的L1?L2?L3??不知道~

但是对于Java代码来说,没区别~~

为了防止老是写register+L1+L2+L3这么麻烦(后续再变还得改),就使用了抽象词work memory表示整个寄存器+缓存

闲聊:

CPU的三级缓存结构,对于程序运行的影响是非常大的~~

现在游戏圈子里,最牛逼的CPU是啥?

7800×3d:游戏圈的王牌~~

这个CPU最牛逼之处:有业界最大L3缓存的CPU

虽然其他的参数(主频、核心数……)都干不过其他旗舰,但是在游戏圈子里就是最牛逼的~

更多推荐