这是你遇到的juc面试吗?(一)

179 阅读6分钟

0. 工作中有用过多线程吗?有没有考虑过线程安全的问题?

有的:实际业务中,比如做数据迁移的场景,我们会使用多个线程,每个线程负责一部分数据,多个线程共同完成数据迁移的工作。 会考虑线程安全的问题,如果遇到多个线程访问共享变量的情况,我们会使用reentrantlock或是synchronized加锁

1. ok,那ReentrantLock 和 synchronized有啥区别吗?

  • 前者是java语言层面,juc包下提供,后者是jvm虚拟机层面
  • 前者需要手动解锁,后者会自动解锁
  • 前者能够响应中断,后者不能响应中断
  • 前者支持公平锁与非公平锁两种模式,后者仅支持非公平模式
  • 前者可以绑定多个条件,后者相当于仅有一个隐含条件

2. ReentrantLock能够响应中断,谈谈怎么体现的?

当持有锁的线程A迟迟不释放锁的时候,如果使用的synchronized这种锁,那么另外一个线程B想要获取锁的时候就会被无限期的阻塞。但是如果使用的是RentrantLock这种锁,出现这种情况时,可以对B线程进行中断,从而使得B线程获取锁的行为不会被无限期的阻塞。体现在代码上就是使用RentrantLock的lockInterruptibly方法。 下面是个lockInterruptibly的例子:

public static void main(String[] args) {
    ReentrantLock reentrantLock = new ReentrantLock();
    Thread thread1 = new Thread(new Runnable() {
        @Override
        public void run() {
            try {
                reentrantLock.lockInterruptibly();
                System.out.println("线程1获取到锁并执行");
                while (true) {

                }
            } catch (InterruptedException e) {
                e.printStackTrace();
                Thread.currentThread().interrupt();
            } finally {
                if(reentrantLock.isHeldByCurrentThread()){
                    reentrantLock.unlock();
                }
            }
        }
    });
    Thread thread2 = new Thread(new Runnable() {
        @Override
        public void run() {
            try {
                reentrantLock.lockInterruptibly();
                System.out.println("线程2获取到锁并执行");
            } catch (InterruptedException e) {
                System.out.println("线程2被中断,可以决定是否退出阻塞...(这里直接退出)");
                return;
            } finally {
                if(reentrantLock.isHeldByCurrentThread()){
                    reentrantLock.unlock();
                }
            }
        }
    });

    thread1.start();
    try {
        // 保证线程1先拿到锁
        TimeUnit.SECONDS.sleep(1);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    thread2.start();

    try {
        TimeUnit.SECONDS.sleep(10);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println("等待10s,线程2还没有拿到锁,给线程2设个中断标志,相当于给个通知...");
    thread2.interrupt();
}

3. ReenrantLock支持公平和非公平锁,那介绍下什么是公平锁与非公平锁?

这个很好理解,公平锁也就是锁的获取过程是公平的,先调用lock的先拿到锁,后调用lock的后拿到锁,先来后到。而非公平锁的获取则不讲武德,一上来就插队,插队不成功才排队。

4. 那如何构造公平锁或是非公平锁呢?

ReentrantLock提供了一个构造方法,允许用户传入一个bool类型的变量fair,代表要创建的是公平锁还是非公平锁。fair=true则为公平,fair=false则为非公平。 这里有个公平锁和非公平锁使用的例子:

ReentrantLock reentrantLock = new ReentrantLock(true);
new Thread(new Runnable() {
    @Override
    public void run() {
        while (true){
            reentrantLock.lock();
            try {
                System.out.println("线程1获得锁");
            }finally {
                reentrantLock.unlock();
            }
        }
    }
}).start();
new Thread(new Runnable() {
    @Override
    public void run() {
        while (true){
            reentrantLock.lock();
            try {
                System.out.println("线程2获得锁");
            }finally {
                reentrantLock.unlock();
            }
        }
    }
}).start();

根据输出可以发现,如果是公平锁,那么等到线程1和2都在运行时,他们是交互执行的:线程1先获得锁,此时线程2要获取锁则阻塞,于是排队。接着线程1释放锁,线程2获得锁,线程1的锁请求再次入队。

5. 你知道公平锁具体是怎么实现的吗?

ReentrantLock依赖内部的FairSync对象实现公平锁,FairSync为ReentrantLock的内部类,它继承自ReentrantLock的另一个内部类Sync,而Sync则是继承自AQS。AQS是个模板类,它预先定义好了模板方法,将需要动态变化的部分下放到子类中实现,从而降低实现一个同步工具的难度。所以要回答这个问题,我能否先讲下AQS。否则有点难描述。

6. 可以的,那你先简单说下AQS。

aqs的全称为AbstractQueuedSynchronizer,中文名即抽象队列同步器,它是典型的模板方法模式实现的。主要用来降低同步工具的编写难度,在JDK中有很多工具都借助了AQS来实现,比如ReentrantLock中的FairSync/NonfairSync,Semaphore中的FairSync/NonfairSync,CountDownlatch中的Sync,RentrantReadWriteLock中的Sync等。 image.png

之所以采用模板方法模式,是因为这么做可以帮我们屏蔽掉线程入队出队,线程之间唤醒这些模板代码。否则每个同步器都要实现一遍。

可以举一个更简单的例子说明模板方法:比如说你现在可能要去调用外部公司的A,B,C接口,但是这个外部公司的接口很不稳定,于是你采用了重试策略,只要调用失败就重试3次。如果你代码中在任何调用A,B,C的地方都去while(i<3) 岂不是会写很多重复代码?而且很low。

因此,既然重试的策略是固定的,只是调用的接口可能是A,B,或是C而已,那就可以使用一个模板来完成这个需求:

abstract static class TryTemplate{

    /**
     * 3. 子类指明不固定的部分该怎么实现
     * @return
     */
    abstract boolean requst();

    /**
     * 1. 重试机制的流程是固定的
     */
    void retryCall(){
        int i=0;
        while (i<3){
            boolean flag = requst(); // 2. 具体请求是不固定的
            if(flag){
                // 调用成功,直接返回
                return;
            }
            // 否则继续重试
            i++;
        }
    }
}

public static void main(String[] args) {
    // 使用模板的时候只要实现可能变的部分,就自动拥有了重试功能。
    new TryTemplate() {
        @Override
        boolean requst() {
            System.out.println("请求A");
            A();
            return true;
        }
    }.retryCall();

    new TryTemplate() {
        @Override
        boolean requst() {
            System.out.println("请求B");
            B();
            return true;
        }
    }.retryCall();
}

7. 模板这块说的不错,你既然说AQS也是模板,那这个模板中固化了怎么样的流程呢?它又是把什么功能下放给子类实现呢?

AQS中,并没有直接将变化的部分声明为抽象方法,而是直接给了一个默认实现:即抛出UnsupportedOperationException。比如tryAcquire,tryRelease,tryAcquireShared,tryReleaseShared,isHeldExclusively这几个方法都是直接抛出异常,AQS期望子类去实现这些方法。根据我们对模板方法的理解,之所以下放这些方法到子类,是因为这些方法在不同的子类可能有不同的实现。事实也确实如此,比如公平锁的tryAcquire需要有排队操作,但是非公平锁的tryAcquire就不需要排队,既然AQS没法判断你们具体想怎么tryAcquire,干脆就交给子类了。 公平锁和非公平锁对AQS中的tryAcquire的不同实现: image.png

至于AQS中固化了哪些流程? 比如说AQS中acquire方法就是一个固化的流程,这个方法也是获取锁的核心方法: 先tryAcquire: 交给子类实现了,子类说明清楚该怎么去抢锁,排队抢呢,还是插队抢呢等等。 再acquireQueued:aqs帮你实现好了 再selfInterrupt:aqs帮你实现好了 这个固化流程意思是说:子类告诉我怎么获取锁,获取成功则直接返回。如果获取失败,我就帮你加入到队列中(acquireQueued),然后死循环在这个队列中等着,直到获取锁成功。如果在刚刚等待的过程中发生了中断,那么在获取成功以后要通过selfInterrupt将中断标志补上。

(通过这个固化流程,AQS直接给你安排了一条龙服务)

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

因此要说清楚AQS,将acquireQueued说清楚就差不多了。 首先说下addWaiter: 当线程尝试获取资源失败,即tryAcquire失败后,将使用addWaiter将想要获取资源的线程封装成一个node后加入到所有准备获取资源的队列尾部。至于加入的方法就是先快速尝试一次,如果快速尝试失败了,就死循环,一直CAS操作直到加入队列尾部成功为止。 接着就是acquireQueued:将节点加入队列之后,就通过这个方法不停的看刚加入的这个节点的前驱节点是不是已经成头结点,如果是就说明轮到自己了,于是尝试去获取同步标志即锁,获取成功就退出循环。 但是如果获取失败,就要看自己要不要阻塞:如果当前节点的前驱节点waitStatus=-1,那么说明前一个节点将要被唤醒,自己可以再等会,如果前驱节点waitStatus>0则表示前驱节点出于取消状态,那么就要跳过(删除)前驱节点,如果waitStatus<0,那么就设置前驱节点为-1。

7. 现在可以说说公平锁具体是怎么实现的吗?

当ReentrantLock采用公平锁模式并调用lock方法时,内部调用的是FairSync中的lock方法,进而调用了aqs的aquire方法,根据前面的描述,这个方法是个模板,所以公平锁实现了tryAcquire方法:如果state=0,检查是否有前驱节点,没有说明没人等待锁,那么直接就抢占了。如果state不为0,说明有线程已经获取了锁,那么就看看是不是自己,是自己就重入。以上都不满足直接返回获取锁失败。从上面的步骤可以看出,这种方式很讲武德,前面的没人才去抢。

对于非公平锁的实现来说,就没那么讲武德了:非公平锁的lock方法上来就先去抢占一次,也就是用cas修改state,没有抢成功再调用aqs的acquire,根据模板间接调用到自己的tryAcquire。对于非公平锁的tryAcquire来说,会再去抢一次,当然也会有判断重入的操作,拿不到返回false。因为tryAcquire每次不管前面有没有人在等就去使用cas抢锁,因此是非公平的。

8. 你刚说了在aqs中会使用cas操作共享变量state。可以简单介绍下cas吗?

好的,cas的全称是compare and swap,意思是比较并交换。它是jvm层面基于乐观锁思想实现的原子性更新数据的机制。通俗的来说,如果指定内存地址中存放的值等于期望值A的话,就把这块内存中存放的A改为B。

9. ok, 那cas的使用有啥坑吗?如何解决这些坑呢?

有,最主要是ABA问题,因为cas所要操作的内存值在整个过程中可能发生A->B->A的变化,这就导致cas以为目标值等于期望值,其实这个目标值也许已经更新好几轮了。因为这个机制,可能导致cas产生错误的更新。 还有一个是我们一般会在while循环中使用cas,直到cas成功才结束循环。这会造成一个问题:如果竞争非常激烈,那么循环很多次也许总是失败,对cpu的性能是个浪费。 最后一个是cas只能保证自身操作的原子性,如果前后还有两个cas,那这三个cas在一起并非是原子的。需要考虑线程安全问题了。

至于如何解决:ABA问题我们可以使用版本号,A1->B1->A2,虽然最终都是A,因为版本号发生了变化,对于程序来说就能识别当前的情况相比于自己的期望发生了变化。反映到代码中就是: AtomicMarkableReference、AtomicStampedReference,这两个类很像,AtomicStampedReference通过引入一个int类型的stamp来实现版本号的功能,通过这个stamp,除了能够解决aba的问题以外,还能看到我们所关注的值被更新了多少次,但是AtomicMarkableReference只关注中间有没有更改过,并不关心更改了多少次,因此它是采用引入一个bool变量的方式充当版本号。 cpu开销的问题,可以类比synchronized的锁升级过程,在代码中可以在超过指定循环次数还失败的情况升级成synchronized。 原子性的问题,如果我们想要同时原子更新多个变量的话,可以将这些变量封装成对象,然后通过AtomicReference原子更新引用的方式间接实现原子更新多个变量。

10. cas是jvm层面提供的原子更新机制,这点没错。你知道具体是有哪个类负责去调用cas的吗?

是UnSafe类,这个类里面提供了一些列的compareAndSwapXxx,正是cas的基础。

11. 那你平时代码中有用过Unsafe吗,用来做什么?有啥要注意的吗?

这个在业务代码中用的很少,仅仅用过unsafe的allocateMemory方法分配堆外内存去做一些测试。至于需要注意的点,就是unsafe对象的构造,这个类只能通过引导类加载器加载,即Bootstrap Loader。 (堆外内存可以引出jvm的面试,unsafe的构造可以引出类加载器面试,暂且不管)

未完待续。。。