Java EE:2.多线程-初阶(第四弹):synchronized 锁+内存可见性
目录
5.synchronized关键字-监视器锁monitor lock
Q2:能不能用synchronized代码块把for循环包起来?
Q6:之前讲线程状态不是还有一个BLOCKED(阻塞的)吗?
提出问题:Java中为啥使用synchronized+代码块 的写法?而不是采用lock+unlock函数的方式来搭配呢?
Q1:如下图,既然第一次加锁是真正加锁的地方,那什么时候是真正解锁的地方??
Q2:站在JVM的视角,看到多个 } 需要执行,JVM如何知道哪个 } 是真正解锁那个??
答疑:但是怎么区分大括号是synchronized的大括号呢??
情况2:两个线程,两把锁,每个线程获取到一把锁之后,尝试获取对方的锁
提出问题:如果在上述代码中,不加sleep,是否还会出现一样的现象??
不使用sleep如何解决上述的内存可见性问题呢?使用volatile!
书接上文: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
虽然其他的参数(主频、核心数……)都干不过其他旗舰,但是在游戏圈子里就是最牛逼的~
更多推荐














































所有评论(0)