Java 面试复习_4
2019-5-21
作者:水不要鱼
(注:能力有限,如有说错,请指正!)
- volatile 是 Java 中保证内存可见性的关键字,注意是
内存可见性
- synchronized 是 Java 中保证操作原子性的关键字,注意是
操作原子性
,当然,内存可见性也会得到保障 - volatile 会阻止指令重排序,让缓存失效。实际上,当一个变量被 volatile 修饰,所有对于这个变量的操作都会回到主内存中进行。
- synchronized 是可重入锁,而且是独占锁,也就是一时间只能被一个线程占有执行。
class Test {
private final Object lock = new Object();
public static void main(String[] args){
Test test = new Test();
// 这里需要指定一个对象来当锁,一把锁上的线程是排他的,也就是独占的
synchronized (test.lock) {
// 多线程安全
}
}
}
扩展:volatile 和 synchronized 的细谈
在说细节之前,首先需要知道 JMM,就是 Java Memory Model。在计算机中,为了解决不同级别设备速度的差别,比如 CPU 中的寄存器和磁盘速度差别很大,
就加入了内存,直到今天的多层缓存,CPU 内部有 1,2,3 甚至 4 四缓存,在磁盘上也有缓存,还有 Intel 去年推的傲腾设备,定位在内存和磁盘之间。
以上的例子只是为了说明一个点,就是计算机可以使用缓存来提高性能,另外,如果程序每一次操作数据都要操作主内存,性能是会受到影响的,
所以 Java 在线程内部搞了一个本地内存的概念,就是把数据从主内存中拷贝到线程本地缓存,然后在本地线程缓存中进行处理,
这样要比直接每次都操作主内存来的快。
但是,就是这么一个本地线程内存的设计,引起了一个内存不可见的问题。我们来假设一种情况,一个变量的值为 A,
线程 1 将这个变量复制到线程 1 的本地内存,值为 A,线程 2 也将它复制到线程 2 的本地内存,数据也为 A,
两个线程各自拥有自己的 A 变量,这时候,线程 2 将这个变量改为 B,当然啦,这时候 B 还是存在线程 2 的本地内存中,
而没有更新到主内存中,这个改动对线程 1 来说,是完全透明不可见的,即使线程 2 将这个变量的值更新到主内存,但只要线程 1 没有去主内存中取最新值的话,
线程 1 就始终不知道它被改了,也不知道这个值已经过时了。
这就是线程不可见的问题。要解决这个问题,首先必须保证两点:
- 线程在本地内存修改了变量的值之后,必须写回主内存
- 线程操作一个变量之前,必须去主内存拿最新的值更新自己本地内存的值
而 volatile 就是这个保证。当一个变量被 volatile 修饰之后,就相当于告诉 JVM,我要时刻保持这个值的最新!
然后每次修改,JVM 就会傻傻的把这个值强制更新到主内存,当然,也会傻乎乎的强制把这个值从主内存更新到线程本地内存。
这就形成了 volatile 保证内存可见性的说法。
当然,它并不保证对这个变量的操作是原子性的,很简单的例子,i++。我们知道 i++ 其实是三条指令:
- 取得 i 的值
- 计算 i + 1 的值
- 将计算结果写回 i
上面每一步都有可能被打断。还是拿上面的两个线程做例子,线程1 和线程 2 都从主内存中取得了 i 的值,
当线程 1 在自己的本地内存中将 i + 1 之后,由于这个变量被 volatile 修饰,本来是需要将线程本地变量写回主内存的,
但 CPU 时间片用完了,系统切换到了线程 2 执行,这时候线程 2 也对自己本地内存的 i 进行了 i + 1,这下好了,
后面不管是线程 2 先写回主内存,还是线程 1 先写回去,都会有一个线程的结果被覆盖了,
这就导致了虽然执行了两次 i++,但最后的结果只有一次 i++。
很明显,这个问题出现的根源就在于 volatile 虽然保证了内存可见性,但没有保证对这个变量的操作是不可打断的。
也就是说它没有保证对这个变量的操作是原子性的。
这时候,synchronized 站了出来,它强行限制了一些操作只能由一个线程执行,而且不能被“打断”,这个打断是指同样的操作不能被别的线程打断,比如 i++,
虽然是三条指令,但是它被限制为不能被打断,上面的例子中,就是即使线程 1 的 CPU 时间片用完了,
也不会切换到线程 2 执行,因为这时候线程 2 被挂起,也就不会执行 i + 1 的操作,同时线程 1 也能正常执行完 i + 1 然后写回主内存,
当线程 1 执行完这一个流程时,线程 2 终于有机会执行了,但由于这个值被修改了,所以线程 2 在执行 i + 1 时就需要重新获取最新值,
这时候拿到的自然就是线程 1 经过 i++ 之后的值了,同样经过线程 2 的一次 i++ 之后,i 就被操作了两次,结果正确。
我们仔细品味上面一段话,就会发现,这不就是让线程 1 和线程 2 串行执行?对!其实保证多线程安全的方法就只有一个,
就是把它变成串行。。。因为只有这样,才能保证前一个线程修改的值被后一个线程准确的拿到,从数据依赖性上分析,不难看出这点。
那 CAS 算法咧,这就没有加锁啊?其实还是一样的,CAS 是 Compare And Swap 比较并交换,就是使用循环操作将一个符合条件的值更新为另一个值,
虽然没有加锁,看起来好像不是串行的,但实际上 CAS 使用循环操作就是因为有可能会操作失败,这时候就需要重试。
比如线程 1 和线程 2 都执行 i++ 操作,只有一个线程会操作成功,而另外一个线程就会操作失败,然后重试,第二次执行成功了。
从时间上看,是不是还是相当于先执行完线程 1 再执行线程 2?
既然提到了 CAS,那就再说两句吧。CAS 因为是使用自旋重试的方法来解决执行失败的,所以当线程数很多的时候,由于只有一个线程能执行成功,
就会导致很多线程执行失败,然后再重试,注意这个重试是在循环中的,也就是会消耗 CPU 时间的,而不像加锁将线程挂起,从用户态变为内核态。
虽然线程从用户态变为内核态也要消耗性能,但线程数很多的情况下,CAS 有可能会浪费大量的 CPU 时间,效率上可能还不如直接加锁串行执行。
- volatile 原理:
在 JVM 底层 volatile 是使用缓存一致性协议(MESI协议)来确保每个缓存中使用的共享变量的副本是一致的。
其核心思想如下:当某个 CPU 在写数据时,如果发现操作的变量是共享变量,则会通知其他 CPU 告知该变量的缓存行是无效的,
因此其他 CPU 在读取该变量时,发现其无效会重新从主存中加载数据。
这其中还涉及到了 CPU 缓存行的事情。JDK 8 中新增了一个注解 sun.misc.Contended,就可以将数据填充满一个缓存行。
在 ConcurrentHashMap 中就有使用到:
class ConcurrentHashMap {
/**
* A padded cell for distributing counts. Adapted from LongAdder
* and Striped64. See their internal docs for explanation.
*/
@sun.misc.Contended static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
}
- synchronized 的原理:
同步代码块是使用 monitorenter 和 monitorexit 指令实现的,同步方法依靠的是方法修饰符上的 ACC_SYNCHRONIZED 实现。
由于 synchronized 是可重入的,所以每一次进入这个锁块,monitorenter 就会加 1,同理,释放锁的时候,
monitorexit 就会减 1,这涉及到 JVM 的底层原理了,感兴趣的可以去搜索相关内容,
这里重点聊聊 synchronized 在 JDK6 之后做的升级变化。
在 JDK5 中,synchronized 就是重量级锁,每一次都会进行加锁。这显然会带来很大的性能消耗。
所以在 JDK6 中,对 synchronized 进行了修改,现在锁的量级有四种,分别是偏向锁
,轻量级锁
,自旋锁
,重量级锁
,
重量级锁没啥好说的,就是直接同步串行执行。
当一个线程已经获取到锁的时候,此时无论再怎么获取锁,也都是这个线程获得,所以这时候其实是没必要加锁的,
这样可以省去大量加锁操作,这时 JVM 就会采用偏向锁进行优化。但是线程数上来了,不止一个线程来竞争锁的时候,偏向锁就失效了,
这时就会升级为轻量级锁。
轻量级锁适合线程交替执行同步块不会产生竞争的情景,如果不是这种情景,就会再次升级为自旋锁。
自旋锁和 CAS 的思想很像,都是通过让线程执行循环来等待锁,避免进入内核态。由于循环等待就有可能等待很久,和 CAS 一样,存在浪费 CPU 时间的问题,
所以 JVM 中,自旋会有固定的次数,这是可以通过 JVM 指令去调的。当循环次数达到了,但还是没能获取到锁,就会升级为重量级锁了。
- synchronized 是 Java 的关键字,所以加锁与释放锁都是 JVM 管理,我们不用操心
- Lock 就是一个正常的类,加锁与释放锁都需要我们自己去做,而且为了保证锁一定被释放,最好在 finally 中执行
class Test {
// 使用可重入锁
private final Lock lock = new ReentrantLock();
public static void main(String[] args){
lock.lock();
try {
// 执行操作
} finally{
lock.unLock();
}
}
}
- 相比之下,synchronized 的锁粒度要比 Lock 的要粗,也就是说 Lock 可以做到更精细的加锁。
上面说了 synchronized 和 Lock,还有 synchronized 的原理,作为一个普通的类,自然不像 synchronized 一样会得到 JVM 的支持。
那它是怎么实现的呢?
在 java.util.concurrent.locks 包下有几个关于 Lock 的类,首先就是 Lock 接口了:
public interface Lock {
// 加锁方法,这个方法死等着,一直到获取到锁为止
void lock();
// 这个方法也会死等着,只是可以被中断,这个中断是外界中断的,并非自己主动中断
void lockInterruptibly() throws InterruptedException;
// 这个方法会尝试获取锁,获取不到自动返回
boolean tryLock();
// 这个方法会尝试获取锁一段时间,一旦时间过了就返回了
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
// 这个方法是用于释放锁的
void unlock();
// 条件变量,用于更精确的控制线程休眠挂起和唤醒
Condition newCondition();
}
这个接口有一个实现类,java.util.concurrent.locks.ReentrantLock,这也是我们可以直接使用的可重入锁。
打开源码,发现内部有一个 Sync 类:
public class ReentrantLock implements Lock, java.io.Serializable {
// 内部拥有一个 Sync 对象
private final Sync sync;
// 可以看出来这个 Sync 是 AbstractQueuedSynchronizer 的子类
// 问题:AbstractQueuedSynchronizer 是干啥的
abstract static class Sync extends AbstractQueuedSynchronizer {
// ======================
// 省略了一部分代码。。。
// ======================
}
// Sync 类还有两个子类:NonfairSync 和 FairSync
// 从字面意思就能看出来,一个是非公平的策略,一个是公平的策略
static final class NonfairSync extends Sync {}
static final class FairSync extends Sync {}
// 默认构造函数,很明显,默认使用的是非公平的策略
public ReentrantLock() {
sync = new NonfairSync();
}
// 当然,你也可以选择是非公平的还是公平的
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
// 直接调用 sync 的方法
public void lock() {
sync.lock();
}
// 直接调用 sync 的方法
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
// 直接调用 sync 的方法
public boolean tryLock() {
return sync.nonfairTryAcquire(1);
}
// 直接调用 sync 的方法
public boolean tryLock(long timeout, TimeUnit unit)
throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
// 直接调用 sync 的方法
public void unlock() {
sync.release(1);
}
// 直接调用 sync 的方法
public Condition newCondition() {
return sync.newCondition();
}
// 直接调用 sync 的方法
public boolean isLocked() {
return sync.isLocked();
}
// 直接调用 sync 的方法
public final boolean isFair() {
return sync instanceof FairSync;
}
}
看了上面一堆精简之后的代码,我们产生了一个问题:AbstractQueuedSynchronizer 是干啥的?
另外,我们还发现这个就是 Sync 类的一个委托!也就是 ReentrantLock 内部就是依靠 Sync 类来实现加锁的。
而 Sync 又是 AbstractQueuedSynchronizer 的子类,所以立马进入 AbstractQueuedSynchronizer 类看看源码,让我们来看看它究竟是干啥的:
public abstract class AbstractQueuedSynchronizer
extends AbstractOwnableSynchronizer
implements java.io.Serializable {
// 首先,我们发现它定义了一个节点类
// 而且有保存上一个节点和下一个节点
// 莫非内部有链表存在?
static final class Node {
volatile Node prev;
volatile Node next;
}
// 看到下面两个方法,我们就明白了
public final boolean hasQueuedThreads() {
return head != tail;
}
// 果然,内部保存了一个链表,或者说使用链表做了一个队列,用来保存线程
public final boolean isQueued(Thread thread) {
if (thread == null)
throw new NullPointerException();
// 链表遍历
for (Node p = tail; p != null; p = p.prev)
if (p.thread == thread)
return true;
return false;
}
}
看到上面的代码,我们就明白了,AbstractQueuedSynchronizer 是一个同步器,并且使用一个队列保存了所有等待锁的线程!
也就是说,当多个线程竞争锁的时候,一个线程拿到了锁,而其他的线程自然就得等待,也就是进入 AbstractQueuedSynchronizer 的一个线程队列。
既然存在一个队列,线程需要入队,那入队是怎么入的?非公平和公平的差别在哪?
这时候就得来看 Sync 类的两个子类 NonfairSync 和 FairSync。先来看 NonfairSync 的入队规则:
static final class NonfairSync extends Sync {
// 首先是 lock() 获取锁的方法
// 熟悉的 CAS 算法执行更新,也就是只有一个线程会更新成功
// 而其他的线程就会进入 acquire(1) 方法等待
final void lock() {
if (compareAndSetState(0, 1))
// 更新成功的线程将被设置为排他的线程,也就是独占锁的尿性
setExclusiveOwnerThread(Thread.currentThread());
else
// 其他所有失败的线程都将进入这个方法等待
// 这个方法是 AbstractQueuedSynchronizer 类的
acquire(1);
// 我将这个方法提取出来:
// 发现内部调用了 tryAcquire(arg) 方法,而 NonfairSync 重写了这个方法
/*
public final void acquire(int arg) {
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
*/
}
// 这是 acquire(1) 中调用的方法
// 这里又调用了 nonfairTryAcquire(acquires)
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
// 我将 nonfairTryAcquire(acquires) 提取出来:
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
// 问题:这个 getState() 获取的是什么值,有什么意义
// 实际上,AbstractQueuedSynchronizer 并没有规定 state 属性的意义,
// 不同的类可能会有不同的意义,而 Sync 就用来记录锁的重入次数
int c = getState();
if (c == 0) {
// 当 state 为 0 也就是重入次数为 0 的时候,说明没有线程持有锁
// 再次执行 CAS 更新,多个线程开始竞争锁
if (compareAndSetState(0, acquires)) {
// 同样的,也只会有一个线程能获取到锁,进入这个方法内部
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
// 进入这里说明当前线程就是已经获得锁的线程
// 因为在 lock() 中:
// setExclusiveOwnerThread(Thread.currentThread());
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc); // 线程重入,state 锁重入次数需要增加
return true;
}
// 到这里说明当前线程拿不到锁,被排挤了哈哈
// 注意这里返回的 false 将导致上面的 acquire(1) 方法中调用的 tryAcquire(arg) 返回 true
return false;
}
// 我们再回到 acquire(1) 内部
// 好,我们已经知道没有抢到锁的线程会导致 tryAcquire(arg) 返回 false
public final void acquire(int arg) {
// 对于没有抢到锁的线程来说 !tryAcquire(arg) 将是 true
// 所以会执行 acquireQueued(addWaiter(Node.EXCLUSIVE), arg) 方法
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) {
selfInterrupt();
}
}
// 让我们再来看 acquireQueued(addWaiter(Node.EXCLUSIVE), arg) 干了什么:
// 首先是 addWaiter(Node.EXCLUSIVE) 方法:
private Node addWaiter(Node mode) {
// 将当前线程,也就是没有拿到锁的线程包装成 Node
// 也就是要被放进线程队列的元素!
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
if (pred != null) {
node.prev = pred;
// 使用 CAS 算法更新节点
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node); // 这边也会使用 CAS 更新节点
return node;
}
// 当上面 addWaiter 方法返回 node 之后
// acquireQueued() 就会将这个节点以不可中断的形式加到队列
// 自此,大致流程我们就明白了
}
看完了非公平的锁实现之后,我们来看 FairSync 的实现:
static final class FairSync extends Sync {
// 看过了 NonfairSync 的实现,我们对 FairSync 的源码也大致知道了
final void lock() {
// 同样,这里调用了 AbstractQueuedSynchronizer 的 acquire(1)
acquire(1);
}
// 还记得 acquire(1) 的实现吗?
// 同样也是调用了 tryAcquire(arg) 和 acquireQueued(addWaiter(Node.EXCLUSIVE), arg)
/*
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
*/
// 这个方法和 nonfairTryAcquire(acquires) 执行的事情几乎一样
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 当 state 为 0 也就是重入次数为 0 的时候,说明没有线程持有锁
// 再次执行 CAS 更新,多个线程开始竞争锁
// 慢着!这里比 NonfairSync 多调用了一个方法!
// 问题:hasQueuedPredecessors() 干了什么?
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
// 同样的,也只会有一个线程能获取到锁,进入这个方法内部
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
// 进入这里说明当前线程就是已经获得锁的线程
// 因为在 lock() 中:
// setExclusiveOwnerThread(Thread.currentThread());
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc); // 线程重入,state 锁重入次数需要增加
return true;
}
// 到这里说明当前线程拿不到锁,被排挤了哈哈
// 注意这里返回的 false 将导致上面的 acquire(1) 方法中调用的 tryAcquire(arg) 返回 true
return false;
}
}
我们对比了 NonfairSync 和 FairSync 的代码之后,发现,都白看了,因为就只有一个细节不一样,
那就是公平锁在线程竞争锁的时候多判断了一个 !hasQueuedPredecessors()
我们来看这个方法干了什么:
class AbstractQueuedSynchronizer {
public final boolean hasQueuedPredecessors() {
Node t = tail;
Node h = head;
Node s;
// 我们来看这几个条件:
// h != t:由于 h = head 和 t = tail,所以 h != t 就代表链表(队列)不为空
// (s = h.next) == null:h.next == null 由于头节点是没用的哨兵节点,所以要判断 h.next 是否为空,也就是没有多余的等待线程
// s.thread != Thread.currentThread():表示头节点的线程不是当前线程,也就是当前线程不是等待线程中的第一个
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
}
看到上面的条件之后,我们发现只有当队列不为空,而且队列没有线程或者当前头节点线程不等于当前线程,
才会返回 true,而调用时 Sync 的子类中还对结果取反了,也就是说当没有线程持有锁时,要得到锁,
hasQueuedPredecessors() 就必须返回 false,这个时候队列为空或者队列有元素都符合条件。
总结一句话,公平锁就是多了一次判断 AQS 类中的等待线程队列是否有线程,有的话就直接进等待队列了。
非公平锁则是每次来一个线程就竞争一次,而不会去管线程等待队列的线程。