首先了解一下JMM中定义的内存操作:
一个线程操作数据时候都是从主内存(堆内存)读取到自己工作内存(线程私有的数据区域)中再进行操作。对于硬件内存来说,并没有工作内存和主内存的区分,这都是java内存模型划分出来的,它只是一种抽象的概念,是一组规则,并不是实际存在的。Java内存模型中定义了八种同步操作:
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内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
同步规则:
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存 中
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或者assign)的变量。即就是对一个变量实施use和store操作之前,必须先自行assign和load 操作。
- 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一线程重复 执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和 unlock必须成对出现。
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变 量之前需要重新执行load或assign操作初始化变量的值。
- 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去 unlock一个被其他线程锁定的变量。
对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)
Synchronized:
synchronized是jvm内置的同步锁,它是隐式锁,不需要我们自己手动释放锁。
每一个java对象中都有一个内部对象Monitor。synchronized就是通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁性能较低(jdk1.6之后进行了优化)。
当我们在代码中使用了synchronized之后,可以在字节码文件看到MONITORENTER和MONITOREXIT。Idea中安装了ByteCode Viewer插件就可以查看字节码,选中编译完的class文件
java虚拟机中ObjectMonitor的定义:(虚拟机C++代码片段)
加锁的过程:
Monitor.Enter和Monitor.Exit就是作用在JMM中定义的内存操作中的lock和unlock上面。然后从上面的同步规则中可以知道一个变量在同一时刻只允许一条线程对其进行lock操作,lock操作的时候会清空工作内存,重新去主内存load最新的数据。Unlock操作则会执行store和write操作将工作内存中的数据写回主内存。这也就是为什么我们用了Synchronized关键字之后就能够实现线程安全。
Java对象内存结构:
对象在内存中存储的结构由三部分组成:对象头,主要是一些标记信息MarkWord,比如hashcode,锁状态这些;实例数据,就是真实的数据;对齐填充,要求对象大小8字节的整数倍,如果不是就填充补齐。
MarkWord:锁状态标记就在这里面,以32位jvm为例,64位也是这些东西,只是占的大小不一样
无锁状态:前25位记录的是hashcode,后四位是对象分代年龄,然后是否是偏向锁标记
偏向锁状态:前23位是偏向的线程ID
轻量级锁:前30位指向线程栈中锁记录的指针
重量级锁:前30位指向重量级锁Monitor的指针
JVM内置锁优化升级
JDK1.6版本之后对synchronized的实现进行了各种优化,自旋锁、偏向锁和轻量级锁
并默认开启偏向锁
开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking
偏向锁
偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需 再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从 而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效 果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激 烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相 同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
轻量级锁
倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种 称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量 级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同 步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应 的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。这个时候也就是上面的Monitor.Enter和Monitor.Exit。锁的升级过程是不可逆的。
自旋锁
虚拟机为了避免线程真实地在操作系统层面挂起,会进行一项称为自旋锁的优化手段。它是一个过渡,每一次升级之前先进行自旋,比如通过一定的自旋之后发现还是偏向锁锁的场景那么就不进行锁的升级。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实 现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对 比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程 可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为 自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作 系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。
整个过程如下图