Java 面试复习_4

2019-5-21
作者:水不要鱼

(注:能力有限,如有说错,请指正!)

  • volatile 和 synchronized 的区别

  1. volatile 是 Java 中保证内存可见性的关键字,注意是内存可见性
  2. synchronized 是 Java 中保证操作原子性的关键字,注意是操作原子性,当然,内存可见性也会得到保障
  3. volatile 会阻止指令重排序,让缓存失效。实际上,当一个变量被 volatile 修饰,所有对于这个变量的操作都会回到主内存中进行。
  4. 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 和 synchronized 的实现原理

  1. 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; }
    }
}
  1. synchronized 的原理:

同步代码块是使用 monitorenter 和 monitorexit 指令实现的,同步方法依靠的是方法修饰符上的 ACC_SYNCHRONIZED 实现。
由于 synchronized 是可重入的,所以每一次进入这个锁块,monitorenter 就会加 1,同理,释放锁的时候,
monitorexit 就会减 1,这涉及到 JVM 的底层原理了,感兴趣的可以去搜索相关内容,
这里重点聊聊 synchronized 在 JDK6 之后做的升级变化。

在 JDK5 中,synchronized 就是重量级锁,每一次都会进行加锁。这显然会带来很大的性能消耗。

所以在 JDK6 中,对 synchronized 进行了修改,现在锁的量级有四种,分别是偏向锁轻量级锁自旋锁重量级锁
重量级锁没啥好说的,就是直接同步串行执行。

当一个线程已经获取到锁的时候,此时无论再怎么获取锁,也都是这个线程获得,所以这时候其实是没必要加锁的,
这样可以省去大量加锁操作,这时 JVM 就会采用偏向锁进行优化。但是线程数上来了,不止一个线程来竞争锁的时候,偏向锁就失效了,
这时就会升级为轻量级锁。

轻量级锁适合线程交替执行同步块不会产生竞争的情景,如果不是这种情景,就会再次升级为自旋锁。
自旋锁和 CAS 的思想很像,都是通过让线程执行循环来等待锁,避免进入内核态。由于循环等待就有可能等待很久,和 CAS 一样,存在浪费 CPU 时间的问题,
所以 JVM 中,自旋会有固定的次数,这是可以通过 JVM 指令去调的。当循环次数达到了,但还是没能获取到锁,就会升级为重量级锁了。

  • synchronized 和 Lock 的细谈

  1. synchronized 是 Java 的关键字,所以加锁与释放锁都是 JVM 管理,我们不用操心
  2. Lock 就是一个正常的类,加锁与释放锁都需要我们自己去做,而且为了保证锁一定被释放,最好在 finally 中执行
class Test {
    
    // 使用可重入锁
    private final Lock lock = new ReentrantLock();
    
    public static void main(String[] args){
        
        lock.lock();
        try {
            // 执行操作
        } finally{
            lock.unLock();
        }
    }
}
  1. 相比之下,synchronized 的锁粒度要比 Lock 的要粗,也就是说 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 类中的等待线程队列是否有线程,有的话就直接进等待队列了。
非公平锁则是每次来一个线程就竞争一次,而不会去管线程等待队列的线程。

今晚就到这里,晚安!
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值