对象在内存中的存储布局
其中,markword是8个字节,记录GC和锁的相关信息;pointer是类型指针(指向class类型),含义是它是哪个类的实例,未压缩是8个字节,压缩后是4个字节(默认压缩),两者合起来是对象头(图片不对);如果该对象的字节数不是8的倍数,需要用空字节补齐至8字节的倍数,因为64位的操作系统一次读64位也就是8个字节的效率最高;实例数据中指向普通对象的指针默认也是开启压缩的,占4个字节。
原子操作
java层面
循环CAS
CAS操作需要输入两个值,一个是旧值(期望的值),一个是新值,在操作期间先比较旧值有没有变化,没有变化才会修改成新值。
CAS的问题:
- ABA问题:使用版本号来解决,每次更新变量都去将版本号加1
- 循环时间长会浪费CPU资源
- 只能保证一个共享变量的原子操作
加锁
锁机制保证了只有获取到锁的线程才能操作锁定的内存区域。
CPU底层
处理器保证了基本的内存操作的原子性,如:从内存中读取或者写入一个字节是原子的,意思是当一个处理器访问一个字节时其它处理器不能访问该内存地址。同时处理器提供了总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。
锁总线
当一个处理器在总线上输出Lock#信号时,其它处理器的请求就将被阻塞住,该处理器就可以独占的使用内存。
锁缓存
缓存在处理器中的缓存在Lock操作期间被锁定,则在其执行锁操作写回到内存时,不声言Lock#信号而是直接修改内存,并允许缓存一致性协议来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上的处理器缓存的内存数据区域,当其他处理器写回被锁定的缓存行时会使缓存行失效。
synchronized
作用
- 多个线程互斥访问同步代码段
- 解决共享对象的线程可见性问题
- 禁止指令重排序
使用
1.代码块:
synchronized(锁) {
需要被同步的代码
}
2.加在静态方法上:此时 当前类.class充当锁
3.加在普通方法上:此时当前对象this充当锁
实现
在对象头的markword中记录有关锁的信息:
过程:
- 在java代码中使用synchronized
- 在字节码文件中会翻译成monitorenter、monitorexit来进入和退出同步代码块,或者将同步方法标识为ACC_SYNCHRONIZED
- 在执行过程中锁自动升级
- 最终会变成汇编代码中的lock comxchg指令
锁升级
JDK6中,锁一共有4中状态:无锁态、偏向锁、轻量级锁(自旋锁)、重量级锁,会随着竞争情况逐渐升级,但是不能降级。
偏向锁
研究表明,大多数情况下,锁不仅不存在竞争,而且总是由同一线程多次获得。
当一个线程访问同步快并获取锁时,会在对象头中和栈帧中的锁记录中存储线程ID,以后该线程在进入和退出同步块时只需简单地测试一下对象头的markword中是否存储着指向当前线程的偏向锁。如果测试成功,则表示该线程已经获取到了锁。如果失败,则看一下markword的偏向锁标志位是否为1:如果不是,则使用CAS上锁;如果是,则会产生竞争,当前线程尝试CAS获取锁。
偏向锁的撤销:采用等到竞争出现时才释放锁的机制,即当其它线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销必须等到全局安全点,即没有正在执行的字节码时。首先暂停持有偏向锁的线程,然后检查它是否存活:如果不处于活动状态则直接将对象头设置为无锁状态;否则撤销偏向锁并升级为轻量级锁,然后唤醒原持有锁的线程。
轻量级锁
执行同步代码块之前,JVM会在当前线程的栈帧中创建用于存储锁记录的空间,并将markword里的信息复制到锁记录中。然后线程使用CAS尝试将对象头中的markword替换为指向栈帧中的锁记录的指针,如果成功则加锁成功,否则继续自旋。解锁时也是使用CAS操作将锁记录替换回markword当中。
重量级锁
因为自旋的过程会消耗CPU,所以为了防止过度浪费CPU资源,当一个线程自旋的次数达到一定数量或自旋的线程数达到一定数量,就会升级到重量级锁,这时需要从用户态进入到内核态,向操作系统申请锁资源(Mutex)。
监视器:
重量级锁的指针指向一个Monitor对象,synchronized的实现也是基于Monitor的。任何对象都有一个monitor与之关联,当且一个monitor被持有后,monitor将处于锁定状态。monitor由ObjectMonitor来实现,C++编写的。ObjectMonitor拥有两个队列,分别是EntryList(等待获取monitor的线程队列)和WaitSet(等待在该monitor上的线程集合)。
执行monitorenter时,当前线程尝试获取monitor的持有权,如果其它线程占有了monitor则当前线程进入EntryList等待获取锁;执行monitorexit时释放占有的monitor。
当方法调用时,会去检查是否设置了ACC_SYNCHRONIZED标识,如果是则需要先持有monitor再执行方法,最后释放掉monitor。
对比
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁消耗小 | 如果存在竞争则会有锁撤销的额外消耗 | 只有一个线程访问同步代码块 |
轻量级锁 | 线程不会阻塞,响应快 | 消耗CPU资源 | 追求响应时间,同步代码块执行快 |
重量级锁 | 不会消耗CPU资源 | 线程阻塞,响应时间慢 | 线程数较多或同步代码块执行慢 |
锁消除
如果一个加锁的方法只可能会在一个线程中执行,比如说在一个方法中有一个局部变量StringBuffer并调用它的append方法(局部变量,栈私有),则JVM会自动消除锁。
锁粗化
如果有连续的一连串的操作都是对同一个对象加锁,则JVM会将加锁的范围粗化到这一连串的操作的外部,使得这一连串的操作只需要加一次锁即可。
volatile
作用
- 禁止指令重排序:volatile变量上的操作不与其它内存操作一起重排序(CPU是流水线式的工作,指令可能会重排序,乱序执行)。
- 线程可见性:volatile变量的写操作会写回到内存当中并使所有缓存了该变量的缓存行失效,从而确保了所有的线程看到的这个变量的值是一样的。
实现机制
禁止指令重排序:通过使用内存屏障来实现
JVM层面:在volatile写操作之前加StoreStore屏障,之后加StoreLoad屏障;在volatile读操作之前加LoadLoad屏障,之后加LoadStore屏障。
CPU层面:使用lock指令或者对应于各种屏障的指令。
线程可见性:对volatile修饰的变量的写操作转化为汇编语言的时候会产生一条Lock指令,该指令的作用如下:
- 将当前处理器的缓存行的数据写回到系统内存。该指令会锁定这块内存区域的缓存并写回到内存,并使用缓存一致性机制来确保修改的原子性,阻止同时修改由两个以上的处理器缓存的内存数据区域(即其它处理器不能打断当前处理器对该内存区域的修改),此操作被称为**“缓存锁定”**(有的处理器也会锁总线)。
- 这个写回内存的操作会使其它CPU里缓存了该内存地址的数据无效。每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,如果过期就将该缓存行设置成无效状态。
注:Intel处理器使用缓存一致性机制(MESI,修改、独占、共享、无效)来维护内部缓存和其它处理起缓存的一致性。
特点
- 相较于 synchronized 是一种较为轻量级的同步策略
- volatile 不具备“互斥性”(synchronized是互斥锁)
- volatile 不能保证变量的“原子性”
如果你想了解更多我对编程和人生的思考,请关注公众号:青云学斋