- JMM是什么
- 八大原子操作
- 可见性/原子性/有序性
- volatile关键字
- synchronized关键字
1. JMM是什么
其实就是第一篇文章里分析的那些过程。Java Memory Model,简称JMM,把它抽象成一种规范,用工作内存与主内存这两个概念。在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。
主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存完成。2. 八大原子操作
我们已经清楚,每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,线程与主内存中的变量操作必须通过工作内存间接完成,主要过程是将变量从主内存拷贝到每个线程各自的工作内存空间,然后对变量进行操作,操作完成后在某个不确定时刻再将变量写回主内存。这里再说具体一点,八大原子操作。
(1)lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态
(2)unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
(3)read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
(4)load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
(5)use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎 (6)assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量
(7)store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
(8)write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中
如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作, 如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作。但Java内 存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。3. 可见性/原子性/有序性
可见性/原子性/有序性是并发编程的三大特性。前面1,2点相当于对第一篇文章做了个复习和补充,接下来分析一下实实在在的代码。
(1)可见性问题
就这么一个类,线程A执行refresh方法,把标志位刷新;线程B执行load方法,用标志位空跑,先运行线程B再运行线程A。根据前面讲的JMM,你觉得结果是什么?
结果就是线程A尽管把标志位刷新了,线程B还是在空跑。这就是缓存不一致带来的可见性问题。
你如果懂了JMM过程,就应该能懂为什么是这个结果。线程A尽管把标志位刷新了,刷新的是自己工作内存中的值,(在某一个时刻会写回主存),但是线程B还是不知道,还用的是自己工作内存中的false值。(2)原子性问题
开10个线程,每个线程把counter加1000,问你counter最后是多少?答案是<=10000,这个不画图解释了,跟上面一样的。(3)有序性问题
这就是说,为了优化,cpu可能会指令重排,但是重排要求不能够改变单线程程序最终结果。
看这个例子,是一个指令重排在多线程环境下可能带来的错误。这篇文章谈到了volatile关键字可以禁止指令重排,保证有序性。volatile还可以保证可见性。但是volatile不能够保证原子性。接下来重点谈下volatile关键字。
4. volatile关键字
volatile关键字就是第一篇文章说的缓存一致性协议的一种实现方式,缓存一致性协议就是volatile关键字的方法论。
(1)保证可见性
volatile关键字保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值立即被其他的线程看到,即修改的值立即更新到主存中,当其他线程需要读取时,它会去内存中读取新值。(synchronized和Lock也可以保证可见性,因为它们可以保证任一时刻只有一个线程能访问共享资源,并在其释放锁之前将修改的变量刷新到内存中。)
那我们再来分析下。在上面的例子中,如果initFlag用volatile修饰,线程A把标志位刷新了,true值立即更新到主存中,线程B工作内存中的值同时失效。那线程B再想读,就只能从主存中去取,于是能正常停止。
(2)保证有序性
先说说概念。举个例子,如果变量i用volatile修饰:volatile int i=0;
i=10; //这叫volatile写
另一个变量=i; //这叫volatile读
不用volatile修饰的变量的读写叫普通读写
代码块1
i=10; //volatile写
代码块2
另一个变量=i; //volatile读
代码块3那么规定,代码块1必须在volatile写执行前执行,不能指令重排(看表最后一列三个No);代码块3必须在volatile读执行后执行,不能指令重排(看表第二行三个No);代码块2分情况讨论下,如果代码块2只是普通读写,可以指令重排,可以变成:
代码块1
代码块2
i=10; //volatile写
另一个变量=i; //volatile读
代码块3或是
代码块1
i=10; //volatile写
另一个变量=i; //volatile读
代码块2
代码块3但如果代码块2是volatile读/写就不能重排。
这只是结论,如果你还对原理感兴趣,搜索内存屏障多了解下也是好的。5. synchronized关键字
volatile关键字也没有保证原子性。
实际上,所有的并发模式在解决线程安全问题时,采用的方案都是序列化(一个一个来)访问临界资源(多个线程同时访问同一个共享、可变资源的情况, 这个资源我们称之其为临界资源)。即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。synchronized关键字是同步互斥访问方法之一(还有Lock,之后章节会手撕源码)。
怎么使用这些基础就不讲了,直接讲原理。synchronized关键字被编译成字节码后会被翻译成monitorenter和monitorexit两条指令分别在同步块逻辑代码的起始位置与结束位置。
那么这个是干嘛的呢?让每个线程在进入同步代码块或是同步方法时,去这个同步对象的对象头里去找monitor指针,目的是找到monitor对象(每个对象在创建时都会有一个与它对应的monitor对象),看里面的信息,判断可不可以申请到对象的锁。(1)对象头是什么?——了解对象的内存布局
HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头 (Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头:比如 hash码,对象所属的年龄,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象才有)等
实例数据:即创建对象时,对象中成员变量,方法等
对齐填充:对象的大小必须是8字节的整数倍
主要关注一下对象头,上面这个图描述的不详细。
实际上,对象的锁状态不同,对象头会存放什么东西也会动态变化(这里要刷新你们的认知了,一个对象就算被synchronized括起来,某个线程进入的时候,也不是立马变成重量级锁状态,而是存在一个锁的膨胀升级的过程,之后会说)。在32位的HotSpot虚拟机中对象无锁状态下,对象头的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于存储对象分代年龄,2Bits用于存储锁标志位,1Bit固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下表所示。
看重量级锁这一行,也就是说对象如果升级到重量级锁状态,对象头里存放着monitor指针,可以找到monitor对象。
下面这个就是monitor对象里的内容,_owner表示持有它的线程,count表示重入次数。
如果没有线程在占用,可以申请到锁。如果有线程占用,这个线程进同步队列等。总结一下,就是下面这个过程。
(2)锁的膨胀升级过程
前面提过一句,“一个对象就算被synchronized括起来,某个线程进入的时候,也不是立马变成重量级锁状态,而是存在一个锁的膨胀升级的过程”。为什么要这个过程?很简单,避免消耗。
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。假设现在有一同步代码块:synchronized(object){ //代码块 }
下面为锁的升级全过程:
(1)无锁:当还没有任何线程进入的时候object就是无锁状态。
(2)偏向锁:如果线程1申请锁,获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。如果线程2想要访问同步代码块了,会尝试修改一下对象头里面的线程id为自己(想让这个对象偏向自己),可能修改成功(线程1已经释放锁);可能失败(线程1还占有着锁),这时线程2向虚拟机发出申请说,让线程1这次时间片用完了停一停,我要用了。
这里所谓的安全点就是时间片用完了停留的位置,如果线程1还停留在同步代码块里,升级为轻量级锁。如果线程1在同步代码块外,保持偏向锁级别,使它偏向线程2.
(3)轻量级锁:这时线程1,线程2都把对象头复制一份到自己的栈空间里面去,这个空间叫做lockrecord,线程1让对象头里的指针指向自己的lockrecord,然后从安全点继续执行。同时线程2自旋尝试把这个指针指向自己栈里面的lockrecord,一定次数内成功了,获得轻量级锁。没成功,升级成重量级锁。
(4)重量级锁:对象头升级成重量级锁的内容——monitor指针。线程2阻塞,线程1做完有个轻量级锁解锁过程,主要是看对象头的指针指向的还是不是自己的lockrecord,但是现在已经是重量级锁,它就知道有人升级了,在等着申请锁了,于是唤醒线程2
并发编程专题二
最新推荐文章于 2024-08-26 23:57:38 发布
转载:
https://blog.csdn.net/qq_31989841/article/details/99292859