concurrent 笔记 - volatile 与 Synchronized 关键字

一. volatile

volatile的语义: Java内存模型对volatile关键字定义了一些特殊规则. 首先从volatile的语义开始说起, 再得出Java内存模型对volatile设定的几个规则

  1. 禁止volatile代码附近指令重排
  2. 保证volatile修饰的变量的可见性
1. 何为指令重拍?

普通变量只能保证在依赖其他变量的结果进行计算时可以获得正确结果, 但不能保证变量的赋值顺序和代码中的顺序一致. 这也是满足了Java内存模型中线程内部表现为串行的语义. 指令重拍是机器级的优化, CPU往往会把赋值操作的语句和不依赖该变量的计算语句不按照代码顺序执行, 这是提高CPU执行效率的一种手段. 比如:

  • MEM阶段访问的数据不在cache中,需要从外部存储器获取,这个动作需要几十个cycle,如果顺序执行,后面的指令MEM都要等待这个指令操作完成。乱序执行是说,先执行后面不依赖该数据的指令
    a = 0; b = 0; a = a + 1; b = b + 1
    上面4行代码的执行顺序可能变为
    a = 0; a = a + 1; b = 0; b = b + 1
    (避免寄存器对变量a和变量b之间反复切换, 增大内存取值花费的时钟周期)

volatile 如何避免指令重拍?
当变量被volatile修饰后, 转换后的汇编代码会在赋值语句后面加上一个内存屏障, 在CPU指令重拍时, 不能把后面的指令重排序到内存屏障之前

2. 保证volatile修饰的变量的可见性

前面提到, 被 volatile 修饰的变量, 转换成汇编代码后会在复制操作后加上内存屏障, 避免指令重拍. 该内存屏障还有一个作用是: 如果某个变量被多个 CPU 缓存在 cache 中, 当本 CPU 的 cache 写入内存时, 该写入动作也会导致其它 CPU 无效化其 cache, 使得其它 CPU 再次使用到该变量时只能从内存中重新取值. 相当于其它 CPU 总能使用该变量的新值

Java内存模型对volatile关键字的特殊规则
  1. 要求在工作内存中, 每次使用变量前都必须从主内存刷新最新的值, 保证能看见其他线程对该变量修改后的值
  2. 每次修改变量后, 都要立刻同步回主内存, 保证其它线程可以看见自己对该变量的修改
  3. 要求volatile修饰的变量不会被指令重拍优化, 保证执行顺序与代码的书写顺序一致
对long和double类型变量的特殊规则

JVM允许将未被volatile修饰的64位数据类型的读写操作, 划分为两次32位的操作来执行. 如果多个线程共享一个未被声明成volatile的long或double类型的变量, 并同时对他进行读取和修改, 则可能会读到一个既非原值, 也不是其它线程修改后的值的"半个变量". 不过这种"读到半个变量"的情况已经十分罕见, 因为商用虚拟机几乎都会把64位数据的读写操作作为原子操作, 因此在编写代码时一般不需要把long和double变量专门声明为volatile

二. synchronized 关键字

2.1 synchronized 如何实现加锁

首先, 明确 java 对象在内存中分为三个部分: 对象头, 数据, 填充数据
对象头 又分为两部分:

  • Mark Word
  • 类型指针 : 指向该对象的 class 元数据

Mark Word 存储对象的包括:

  • 数据相关: hashCode, gc 分代年龄
  • 锁相关:
    (1) 表示锁状态的锁状态标记在运行期间一直存在
    (2) 其余信息, 会随着锁状态标记 的变化二变化
    偏向锁: 锁偏向的线程 id, 锁偏向时间戳
    轻量级锁: 指向栈中锁记录的指针
    重量级锁: 即监视锁, 指向 ObjectMonitor 对象

synchronized 加锁逻辑
synchronized 是通过 monitor 来实现同步的. monitor 是由 ObjectMonitor 实现的,其主要数据结构如下(位于HotSpot虚拟机源码 ObjectMonitor.hpp 文件,C++实现的)

ObjectMonitor() {
    _header       = NULL;
    // 用来记录该对象被线程获取锁的次数,这也说明了synchronized 是可重入的
    _count        = 0; 
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    // 指向持有 ObjectMonitor 对象的线程
    _owner        = NULL;  
    // 处于 wait 状态的线程,会被加入到_WaitSet,调用了 wait 方法之后会进入这里
    _WaitSet      = NULL;  
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    // 处于等待锁 block 状态的线程,会被加入到该列表
    _EntryList    = NULL ; 
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

其中最有用的三个属性是:

  • _owner 保存了当前持有锁的线程引用
  • _EntryList 中保存目前等待获取锁的线程
  • _WaitSet 保存 wait 的线程

详细的加锁流程为:

  1. 加锁时,即遇到 synchronized 关键字时,线程会先进入 monitor_EntryList 队列阻塞等待。
  2. 如果 monitor 的 _owner 为空,则从队列中移出并赋值于 _owner
  3. 如果在程序里调用了 wait() 方法,则该线程进入 _WaitSet 队列。我们都知道 wait() 方法会释放 monitor 锁,即将 _owner 赋值为 null 并进入 _WaitSet 队列阻塞等待。这时其他在 _EntryList 中的线程就可以获取锁了。
  4. 当程序里其他线程调用了 notify/notifyAll() 方法时,就会唤醒 _WaitSet 中的某个线程,这个线程就会再次尝试获取 monitor 锁。如果成功,则就会成为monitor 的 owner。
  5. 当程序里遇到 Synchronized 关键字的作用范围结束时,就会将 monitor 的 owner 设为 null,退出。

锁状态的升级
上文提到的 ObjectMonitor 是重量级锁, jvm 为了优化锁效率, 提供四种锁状态, 锁状态逐步提升: 无锁状态偏向锁轻量级锁重量级锁. 锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级

  • 偏向锁 (无锁状态)
    在只有一个线程使用了锁的情况下,偏向锁能够保证更高的效率。

具体过程是这样的:当第一个线程第一次访问同步块时,会先检测对象头 Mark Word 中的 锁状态标志位 是否为 01,以此判断此时对象锁是否处于无锁状态或者偏向锁状态。

线程一旦获取了这把锁,就会把自己的线程 ID 写到 MarkWord 中,在其他线程来获取这把锁之前,锁都处于偏向锁状态。

当下一个线程参与到偏向锁竞争时,会先判断 MarkWord 中保存的线程 ID 是否与这个线程 ID 相等,如果不相等,会立即撤销偏向锁,升级为轻量级锁。

  • 轻量级锁 (锁自旋)
    当锁处于轻量级锁的状态时,就不能够再通过简单地对比 锁状态标志位 的值进行判断,每次对锁的获取,都需要通过自旋

当然,自旋也是面向不存在锁竞争的场景,比如一个线程运行完了,另外一个线程去获取这把锁;但如果自旋失败达到一定的次数,锁就会膨胀为重量级锁。

  • 重量级锁 (监视器锁)
    重量级锁,这种情况下,线程会挂起,进入到操作系统内核态,等待操作系统的调度,然后再映射回用户态。系统调用是昂贵的,所以重量级锁的名称由此而来。

如果系统的共享变量竞争非常激烈,锁会迅速膨胀到重量级锁,这些优化就名存实亡。

如果并发非常严重,可以通过参数 -XX:-UseBiasedLocking 禁用偏向锁,理论上会有一些性能提升,但实际上并不确定。

锁的其他优化手段

  • 锁消除

经过逃逸分析之后,如果发现某些对象不可能被其他线程访问到,那么就可以把它们当成栈上数据,栈上数据由于只有本线程可以访问,自然是线程安全的,也就无需加锁,所以会把这样的锁给自动去除掉。

  • 锁粗化

按理来说,同步块的作用范围应该尽可能小,仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。

但是加锁解锁也需要消耗资源,如果存在一系列的连续加锁解锁操作,可能会导致不必要的性能损耗。

锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。

2.2 Synchronized使用方法

(1) 类级别的锁 (所有同步针对该类的所有对象)

  • (1) 同步静态方法

同步静态方法是类级别的锁,一旦任何一个线程进入这个方法,其他所有线程将无法访问这个类的任何同步类锁的方法。

public synchronized static void fun() { }
  • (2) 同步代码块锁类

下面提供了两种同步类的方法,锁住效果和同步静态方法一样,都是类级别的锁,同时只有一个线程能访问带有同步类锁的方法。

private void fun() {
    synchronized (this.getClass()) { }
}

(2) 对象计别的锁 (所有同步只针对同一个对象)

  • (1) 同步普通方法
public synchronized void fun() { }
  • (2) 同步代码块中使用this对象/其它对象作为锁
public void fun() {
    synchronized (this) { }
}

public void fun() {
    synchronized (LOCK) { }
}

(3) synchronized与wait,notify合用
单一的synchronized虽然可以保证线程安全, 但需要配合其它线程方法, 才能表示复杂逻辑的线程交互

  1. obj.wait()
    synchronized(obj){
       while(条件){
           obj.wait();
           // 收到通知后, 继续执行
       }
    }
    
    (1)使用wait()之前, 需要获取对象锁.
    (2)其次, wait()方法要写在while循环中, 并指明跳出循环的条件。因为wait()可能被错误唤醒, 或原先的判断条件可能已经发生改变, 需要再次判断
    (3)最后, wait()方法执行时, 线程会释放得到的obj独占锁, 并进入’等待阻塞’状态, 等待其它线程执行该obj锁的notify()
  2. obj.notify()
    当等待在obj上的线程收到一个obj.notify()时, 就能重新获得obj的锁.值得注意的是以下3点 :
    (1)当线程执行完obj.notify()后, 不会立刻释放锁, 而是等待synchronized代码块中的代码全部执行完毕后再释放锁
    (2)如果有多个线程在方法obj.wait()中, 则只会随机选择一个线程唤醒
    (3)obj.notifyAll()会唤醒所有在执行obj.wait()的线程
  3. Thread.sleep
    sleep方法会让线程休眠, 但不会释放已获得的锁

三. Java内存模型

什么是Java内存模型

Java内存模型(Java Memory Model), 是用来屏蔽各种硬件和操作系统内存访问差异, 实现让Java程序在各种平台下都能达到一致的内存访问效果的模型; 其主要目标是"定义程序中变量的访问规则", 即2个内存访问细节:将变量存储到内存从内存中取出变量

  1. 这里的"变量"
    此处的变量与Java程序中所说的变量有所不同, 它专指实例的字段,静态字段,数组中的元素, 它不包括"局部变量"与"方法参数". 因为后者是线程私有的, 不会被共享也就不会存在竞争的问题

  2. JMM没有做出的限制
    为了获得较好的执行效能, Java内存模型没有限制:

    • 执行引擎使用cpu中特定寄存器或特定缓存来和主内存交互
    • 也没有限制JIT不能调整代码执行顺序这类优化措施
  3. 什么是主内存, 什么是工作内存
    Java内存模型规定所有变量都存储在主内存中, 此外每个线程还有自己的工作内存.
    (1)线程的工作内存中保存了被线程使用的变量的主内存副本拷贝;
    (2)线程对变量的所有操作都是在工作内存中执行的, 线程不能直接读写主内存的变量
    (3)线程间, 变量值得传递需要通过主内存来完成.

    这里的主内存和工作内存是对物理内存,CPU cache, 寄存器的一种抽象, 有别于Java内存区域中的"堆",“栈”,“方法区”. 二者不是同一层次的内存划分, 基本没有关系. 如果要勉强对应起来, 那从变量, 主内存, 工作内存的定义来看:

    • 主内存对应Java堆中对象实例的数据部分 。(对象实例还包括hash码, GC标志, GC年龄, 同步锁等信息)
    • 工作内存对应栈的部分区域

    从更底层上说:

    • 主内存对应物理内存
    • 工作内存往往对应于于寄存器和CPU高速缓存. 因为程序运行时往往访问的是工作内存的变量, 虚拟机会优先把这些变量拷贝到cache或寄存器中
主内存与工作内存的互相操作

Jvm规定了8种操作, 用来实现主内存和工作内存之间相互拷贝的实现细节. 这8种操作都是原子的, 不可再分的. (对于long和double类型的变量可能有例外)

  1. 作用于主内存的操作
    (1)lock: 将主内存中的变量标识为, “已被一条线程独占"的状态
    (2)unlock: 将主内存中处于"lock"状态的变量释放出来, 释放以后该变量才能被其他线程"lock”
    (3)read: 将变量的值, 从主内存传输到工作内存中, 以便后续执行load动作
    (4)load: 把从主内存拷贝过来的变量值, 赋给工作内存的变量副本

  2. 作用于工作内存的操作
    (1)use: 把工作内存中, 某个变量的值传递给执行引擎. 每当jvm遇到一个需要用到变量值的字节码指令时就会去执行该动作
    (2)assign: 把从执行引擎收到的值, 赋给工作内存中的某个变量. 每当jvm遇到一个给变量赋值的字节码指令时就会去执行该动作
    (3)store: 将工作内存中某个变量的值传递到主内存, 以便后续执行write动作
    (4)write: 把从工作内存传递过来的某个值赋给主内存的某个变量

    因此Java内存模型的主要规则是:
    (1)如果要把主内存中某个变量的值拷贝到工作内存, 则顺序执行readload动作;
    (2)如果要把工作内存中某个变量副本的值写回到主内存, 则顺序执行storewrite动作

    Java内存模型, 只要求以上2个操作是顺序执行的, 而不保证是连续执行的. 也就是说. readload之间, storewrite之间可以插入其他指令. 一种多线程下可能会导致歧义的顺序是:

    read a, read b, load b, load a
    
  3. Jvm还规定了, 以上8种操作必须必须满足如下规则
    (1)readload, 以及storewrite, 必须成对出现.
    即不允许某个变量从主内存读取了但工作内存不接受, 或者从主内存发起了写回但主内存不接受写回的情况
    (2)不允许线程丢弃某个变量最近assign后的值
    即不允许某个变量在工作内存中改变以后没有同步回主存
    (3)不允许一个线程把某个未发生任何assign操作的变量同步回主存
    (4)新的变量只能诞生在主存中, 不允许工作内存使用一个未被初始化的变量. 换句话说就是, 要想对一个变量执行use(执行引擎拷贝工作内存的某个值)store(主内存拷贝工作内存的某个值)操作, 必须先在该变量上执行assign(执行引擎拷贝到工作内存)load(主内存拷贝到工作内存)
    (5)同一时刻, 某个变量只允许一个线程对其lock, 但可以被同一个线程多次lock. 多次执行lock后, 只有执行相同次数的unlock, 该变量才会被解锁
    (6)如果要对一个变量lock, 则必须先清空该变量在工作内存的值, 在执行引擎使用该变量前, 需要重新执行loadassign操作在工作内存中初始化该变量
    (7)如果一个变量没有被lock过, 则不允许被unlock; 一个线程不能去unlock一个被其它线程lock的变量
    (8)一个变量在执行unlock之前, 必须先把该变量同步会主内存(即执行store,write操作)

    这8个规则再加上后面的volatile特殊规则, 就完全确定了Java程序中哪些内存访问动作在并发下时安全的

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值