什么是JAVA的锁, 为什么要用锁
字面意思, 锁就是要锁住一些东西. 不让别人动, 或者不希望除了有钥匙的之外的人了解锁里面的内容.
那么在什么情况下需要用到锁呢?
日常生活里, 需要用到锁的地方就是你的柜子啊, 你的箱子啊, 你的... 发现了么, 都是你的. 当然你也可以给别人的东西上锁, 但是一旦你锁上了, 不考虑道德问题, 那么这个东西暂时就属于你了.
所以, 在我们的程序里, 什么时候需要用锁呢?
线程同时访问某个东西, 但是我们不希望他们一起来, 或者无法承受他们一起来从而可能产生的问题
所以这个时候我们就提到了线程. 现在随着多计算核心的机器的发展, 为了解决某一并行分支对相同资源的同步访问, 操作系统层面提供了一个叫做互斥信号量
的概念, 对应到JAVA里, 就是我们要接触到的锁
说起来JAVA的锁, 大部分人第一反应就是synchronized
. 这是作为JAVA的关键字来修饰方法或者代码块的. 用来标识使用某个对象来同步这个方法或者代码块. 其实在JAVA的Concurrent包下还有一个Lock
, 它也是锁. 接下来, 我们来简要的了解一下Lock
和 synchronized
有什么锁, 分别是什么, 有什么用
前面我们说了, 大部分人的第一反应synchronized
, 那么我们来看下它.
我们知道, synchronized
是JAVA内置的关键字. 在JAVA里所有的非NULL
对象都可以作为一把锁给它用, 这就是我们叫它内置锁的原因?哈哈哈哈.
其实更多的我们在书上看到的是说synchronized
是监视锁, 为什么叫监视锁? 那是因为JVM
内部是通过monitorenter
和monitorexit
这两个字节指令来获取和释放锁的, 它的JAVA层实现类的名字是ObjectMonitor
, 所以我们叫它监视锁
synchronized
使用起来非常的简单, 从前面的介绍我们可以知道, 只需要给这个关键字一个对象作为锁就可以了.
// 修饰静态方法, 监视这个类对象
public static synchronized void foo(){...}
// 修饰方法, 监视这个实例对象, 一般就是我们说的`this`
public synchronized void foo(){...}
/*
* 修饰代码块, 括号里传入的是需要监视的对象
* 当一个类里面存在多个临界区需要同步的话,
* 一个this对象已经无法满足需求了, 可以新new一个对象
* 比如这样
* private Object lock = new Object();
* public void foo(){
* synchronized (lock){...}
* }
*/
public void foo(){
synchronized (this){...}
}
只有多个线程同时去访问这一个监视锁保护的临界区的时候才会发生竞争, 锁的竞争我们后面继续介绍.
synchronized
我们用的话就是上面的几种方式, 那么它都有什么特点呢?
它是可重入
什么叫做可重入? 就是说当一个线程已经持有了一把监视锁的时候, 如果这个线程需要再次获取这个锁的话, 就不需要竞争了, 直接可以拿到.
/*
* 需要 this 对象上面的监视锁,
* 在获取锁之后, 调用 reentrant() 的时候需要再次获取锁
* 但是由于 可重入 性, 这个方法是没有问题的
*/
public synchronized void foo(){ reentrant(); }
public synchronized void reentrant(){...}
它是阻塞的
如果线程A持有了当前对象的监视锁之后, 在释放之前, 会阻塞其他线程对该对象的访问. 这时候如果线程B来试图获取, 失败后就会加入到阻塞队列中等待锁
的释放.
什么时候释放, 阻塞怎么办? 先拿个小本本记下来
它是非公平锁
- 什么叫公平锁?
公平锁就是先来后到. 如果线程A持有某对象的监视锁期间, 有其他线程BCD...来试图获取锁, 在失败之后会加入阻塞队列. 当线程A释放锁之后, 如果BCD...是按照加入队列的先后顺序来排队被JVM
唤醒的话, 就是公平锁. 因为对阻塞队列中所有等待的线程都是公平的. - 什么叫非公平锁?
上面的例子里,JVM
唤醒线程的顺序不按照阻塞队列里面的排队顺序来.
为什么它是非公平锁?
因为他在每次获取锁的时候, 都会自旋
等待一段时间, 当超过超时时间之后, 才进入阻塞队列. 如果在自选的过程里恰好线程A释放了锁, 这个时候自旋线程就可以直接拿到了, 而不需要去排队等候.
公平锁在实际的操作过程里, 是需要记录加入阻塞队列的先后顺序的. 而且即使有线程来的时候刚好线程A释放, 但是由于要实现公平原则, 那么这个线程依然要老老实实的去阻塞队列里排队, 然后JVM
再唤醒队列头的等待线程. 这些都是需要额外消耗性能的.
我们一开始的时候提到, 多计算核心的发展, 在操作系统层面提供了一个互斥信号量
的东西, 它在JVM
里就是我们所说的锁, 由于synchronized
是通过JVM
里的monitorenter
和monitorexit
指令实现的, 而monitor*
这两个指令又比较严重的依赖操作系统的互斥信号量
, 这就存在了一个问题. 使用系统的互斥信号量
需要将线程从用户态切换到内核态, 这种转换肯定会存在性能问题, 所以我们之前都谈synchronized
色变, 上学的时候, 老师警告⚠️我们, synchronized
影响性能.
其实从jdk6
开始, JAVA就针对这个问题进行了优化.首先是对锁的状态进行了区分, 监视锁不再是简单的一把锁, 它还有各种状态. 锁的状态流转, 其实就是对应多个线程对锁的竞争程度, 如果这个锁的竞争度很低, 那么JVM
就不会通过系统互斥信号量
来实现同步, 随着竞争的加剧, 获取锁的代价越来越大, 才会开始以来系统的互斥信号量
那么针对锁的状态区分, JVM
是怎么做的呢?
前面我们知道了监视锁可以加在任何一个非NULL
对象上, 那么对象怎么表示自己当前被监视呢?
现行的HotSpot VM
中, 每个对象在内存中分为三个部分
- 对象头✔️
- 实例数据
- 对齐填充
锁信息就放在对象头中. 但是由于对象头的长度和具体的机器字长相关. 一般来说, 目前分为32位和64位. 其中对象头的长度分别为32bit和64bit, 它最后的2bit
是锁的状态标志位, 用来标记当前对象的状态. [现在32位机器应该已经不多了]
对象所处的状态, 决定了对象头存储的内容
状态 | 标识位 | 储存内容 |
---|---|---|
未锁定 | 01 | 对象哈希码、对象分代年龄 |
轻量级锁 | 00 | 指向锁记录的指针 |
膨胀 | 10 | 执行重量级锁定的指针 |
GC标记 | 11 | 空(不需要记录信息) |
可偏向 | 01 | 偏向线程ID、偏向时间戳、对象分代年龄 |
我们看到表格中, 未锁定和可偏向的标识位都是01
, 那么怎么区分呢? 这里使用了倒数第三位, 标识了是否偏向 0未加锁 1偏向锁
. 对象头里还有很多其余的JAVA核心内容, 比如GC分代年龄
/ 线程ID
/是否被GC标记
等等. 那些复杂的东西, 我们回头再整理.现在我们只需要记得这几个标识位和对应的状态就好, 然后我们一个一个看.
11
可被GC标记的
对象头的后两位如果被设置为 11
, 则表示这个类是可GC的. 对象头里的其余信息则不需要关注了, 因为这个类即将被GC.
01
无锁
跟偏向多了一位来区分, 表示当前对象禁止偏向
01
偏向锁
这个对象是可偏向的, 它存在三种状况
- 匿名偏向
Anonymously biased
表示当前还没有线程偏向这个对象, 第一个试图获取锁的线程可以使用CAS指令去改变锁对象的对象头指向自己. 这个状态是可偏向对象所的初始状态 - 可重偏向
Rebiasable
epoch字段无效, 可以理解为之前这个锁对象偏向于某个线程, 但是这个线程已经退出了临界区, 这个时候如果另外一个线程来获取锁, 可以使用CAS指令去改变锁对象的对象头来指向自己 - 已偏向
Biased
epoch字段有效,表示锁对象当前已经偏向某一个线程.
00
轻量级锁
偏向锁存在竞争时, 进入轻量级锁的状态, 此时获取锁的线程开始自旋等待.
10
重量级锁
竞争锁的各个线程开始使用系统的互斥信号量
做同步, 回到最原始的状态.
我们知道了锁是怎么标记的之后, 来看一下被标记的锁状态之间是怎么流转的
从前面我们应该可以很容易知道, 锁的状态流转是
无锁
-> 偏向锁
-> 轻量级锁
-> 重量级锁
偏向锁是默认打开[?]
的, 所以基本上一个对象被线程持有之后, 就直接获取了偏向锁, 随着锁竞争的升级, 逐步变为轻量级锁, 以致到最后膨胀为重量级锁