Java 并发面试系列-05

2022年7月17日
大约 12 分钟

Java 并发面试系列-05

1. 什么是 ABA 问题?

ABA问题是指在CAS操作中带来的潜在问题,CAS的全称是compare and swap或compare and set。

对于一个要更新的变量A,提供一个它的旧值a和新值b,如果变量A的值等于旧值a那么更新成功,否则失败。

如果CAS操作是基于CPU内核的原子操作,那基本是不会出现ABA问题的,但是如果CAS本身操作不满足原子性,则会带来ABA问题。

比如两个线程 线程1查询A的值为a,与旧值a比较 线程2查询A的值为a,与旧值a比较,相等,更新为b值 线程2查询A的值为b,与旧值b比较,相等,更新为a值 线程1相等,更新B的值为c

可以看到此情况下,线程1可以正常进行CAS操作,将值从a变为c但是在这之间,实际A值已经发了a->b b->a的转换。

ABA问题通俗的可以理解为别人挪用了你的钱,但是在你发现之前又还了回来。但是别人却已经触犯了法律。

2. 如何解决 ABA 问题?

ABA问题:如果线程1第一次读取的值为A,线程1准备对A执行写操作,但这段时间,线程2完成了A->B->A的更改,当线程1准备写时,A已经不是原来的A。

JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。如果当前引用 == 预期引用,并且当前标志等于预期标志,则以原子方式将该引用和该标志的值设置为给定的更新值。

如何解决ABA问题?

JDK1.5版本后,推出两种办法解决或改善ABA问题,分别是AtomicStampedRefence和AtomicMarkableReference。

1、AtomicStampedRefence

观察其源码发现:其内部的value被pair代替,即:

private volatile Pair<V> pair;

pair源码:

private static class Pair<T> {
    final T reference;
    final int stamp;
    private Pair(T reference, int stamp) {
        this.reference = reference;
        this.stamp = stamp;
    }
    static <T> Pair<T> of(T reference, int stamp) {
        return new Pair<T>(reference, stamp);
    }
}

可以看到其内部不仅有T引用模板,还有一个int类型的stamp作为版本号,等修改时比较当前版本号与当前线程持有的版本号是否一致,若一直则修改,并stamp+1。

2、AtomicMarkableReference

观察其源码发现:其内部的value被pair代替,即:

private volatile AtomicMarkableReference.Pair<V> pair;

pair源码:

static <T> AtomicMarkableReference.Pair<T> of(T var0, boolean var1) {
    return new AtomicMarkableReference.Pair(var0, var1);
}

可以看到其内部不仅有T引用模板,还有一个boolean类型的var1。

var1的值有两个true和false,修改时在这两个版本号之间来回切换,这样做并不能解决ABA问题,但可以降低其发生的几率。

解决的办法

加个版本号就可以解决ABA问题。真正要做到严谨的CAS机制,在Compare阶段不仅要比较期望值A和地址V中的实际值,还要比较变量的版本号是否一致。

用例子来说明一下,假设地址V中存储着变量值A,当前版本号是01。

线程1获得了当前值A和版本号01,想要更新为B,但是被阻塞了。

此时内存地址V中的变量发生了多次改变,版本号提升为03,但是变量值仍然是A。

随后线程1恢复运行,进行Compare操作。经过比较,线程1所获得的值和地址V的实际值都是A,但是版本号不相等,所以这一次更新失败。在Java当中AtomicStampedReference类就实现了用版本号做比较的CAS机制。

3. Java 中无锁队列的原理是什么?

SynchronousQueue队列是无锁队列,其内部是使用CAS自旋操作保证线程安全。

4. Java 中延迟阻塞队列的原理是什么?

DelayQueue底层使用了PriorityQueue优先级队列,而PriorityQueue实际上一个二叉堆结构,通过比较堆内元素过期时间,将堆内最快过期的元素放在堆头,以保证每次获取的元素是最二叉堆中最快过期的元素。

5. String变量可以存放在 DelayQueue 队列吗?

DelayQueue要求元素必须实现Delayed接口,也就是说DelayQueue的元素必须是Delayed的子类。而String类并没有实现Delayed 接口,所以String变量是不能放在DelayQueue队列里。

6. SynchronousQueue 队列的大小是多少?

实际上SynchronousQueue本身是没有容量的大小,所以也无法查看其容量的大小,其内部的size方法都是写死的返回0值。

7. 超出 LinkedBlockingQueue 容量值会出现什么情况?

如果限定LinkedBlockingQueue的容量,当达到容量值的时候put操作和offer操作加上超时时间会阻塞。

如果没有限定容量的话,就可认为容量是无限大,直到耗尽机器的资源,发生OOM。

所以建议在使用LinkedBlockingQueue的时候最好能够设置容量,防止耗尽内容。

8. 什么是协程?

协程(Coroutine):是单线程下的并发,又称微线程,纤程。简单理解就是线程中的线程。

优点

轻量,创建成本小,降低了内存消耗

用户态调度,减少了 CPU 上下文切换的开销,提高了 CPU 缓存命中率

减少同步加锁,提高了性能

可以用同步思维写异步代码

缺点

在协程执行中不能有阻塞操作,否则整个线程被阻塞

不擅长处理 CPU 密集型

适用场景

高性能要求,考虑牺牲公平性换取吞吐量

IO 密集型任务

Generator 式的流式计算

9. Java 支持协程吗?

Java 官方目前是还没推出协程。

目前可用性比较高的有Quasar和ea-async两个第三方库,都是通过byte code Instrument,把编译后同步程序class文件修改为异步的操作。

10. 什么是 CAS?

CAS是compare and swap的缩写,即我们所说的比较交换。

cas是一种基于锁的操作,而且是乐观锁。在java中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加version来获取数据,性能较悲观锁有很大的提高。

CAS操作包含三个操作数分别是内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成B。CAS是通过无限循环来获取数据的,若果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。

11. CAS 有什么缺点?

1、CAS容易造成ABA问题。

一个线程a将数值改成了b,接着又改成了a,此时CAS认为是没有变化,其实是已经变化过了,而这个问题的解决方案可以使用版本号标识,每操作一次version加1。在JDK1.5版本中,已经提供了AtomicStampedReference来解决问题。

2、CAS造成CPU利用率增加。之前说过了CAS里面是一个循环判断的过程,如果线程一直没有获取到状态,cpu资源会一直被占用。

12. 如何保证线程按顺序执行?

保证线程按顺序执行的方法有以下几种:

1、使用线程的join()方法

2、使用主线程的join()方法

3、使用线程的wait()方法

4、使用线程的线程池方法

5、使用线程的Condition(条件变量)方法

6、使用线程的CountDownLatch(倒计数)方法

7、使用线程的CyclicBarrier(回环栅栏)方法

8、使用线程的Semaphore(信号量)方法

13. 什么是守护线程?

守护线程(即daemon thread),是个服务线程,准确地来说就是服务其他的线程,而其他的线程只有一种,那就是用户线程。

java中线程可以划分为2种

1、守护线程,比如垃圾回收线程,就是最典型的守护线程。 2、用户线程,就是应用程序里的自定义线程。

守护线程

1、守护线程,专门用于服务其他的线程,如果其他的线程(即用户自定义线程)都执行完毕,连main线程也执行完毕,那么jvm就会退出(即停止运行)——此时,连jvm都停止运行了,守护线程当然也就停止执行了。

2、如果存在用户自定义线程的话,jvm就不会退出,此时守护线程也不能退出,也就是它还要运行,就是为了执行垃圾回收的任务。

3、守护线程又被称为“服务进程”“精灵线程”“后台线程”,是指在程序运行是在后台提供一种通用的线程,这种线程并不属于程序不可或缺的部分。

通俗点讲,任何一个守护线程都是整个JVM中所有非守护线程的“保姆”。

14. 公平锁和非公平锁有什么区别?

公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。

优点:所有的线程都能得到资源,不会饿死在队列中。

缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。

非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。

优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。

缺点:可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。

15. 锁优化的方法有哪些?

减少锁持有时间

减少其他线程等待的时间,只在有线程安全要求的程序代码上加锁。

减小锁粒度

将大对象(这个对象可能会被很多线程访问),拆成小对象,大大增加并行度,降低锁竞争。降低了锁的竞争,偏向锁,轻量级锁成功率才会提高。

锁分离

读写锁ReadWriteLock,根据功能进行分离成读锁和写锁,这样读读不互斥,读写互斥,写写互斥。即保证了线程安全,又提高了性能。

读写分离思想可以延伸,只要操作互不影响,锁就可以分离。

锁粗化

为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早的获得资源执行任务。

锁消除

在即时编译器时,如果发现不可能被共享的对象,则可以消除这些对象的锁操作。

16. 线程池的原理是什么?

线程池状态

线程池和线程一样拥有自己的状态,在ThreadPoolExecutor类中定义了一个volatile变量runState来表示线程池的状态,线程池有四种状态,分别为RUNNING、SHURDOWN、STOP、TERMINATED。

1)线程池创建后处于RUNNING状态。

2)调用shutdown后处于SHUTDOWN状态,线程池不能接受新的任务,会等待缓冲队列的任务完成。

3)调用shutdownNow后处于STOP状态,线程池不能接受新的任务,并尝试终止正在执行的任务。

4)当线程池处于SHUTDOWN或STOP状态,并且所有工作线程已经销毁,任务缓存队列已经清空或执行结束后,线程池被设置为TERMINATED状态。

线程池原理: 预先启动一些线程,线程无限循环从任务队列中获取一个任务进行执行,直到线程池被关闭。如果某个线程因为执行某个任务发生异常而终止,那么重新创建一个新的线程而已,如此反复。

17. 什么是 Java 优先级队列(Priority Queue)?

PriorityQueue是一个基于优先级堆的无界队列,它的元素是按照自然顺序(natural order)排序的。在创建的时候,可以给它提供一个负责给元素排序的比较器。

PriorityQueue不允许null值,因为它们没有自然顺序,或者说它们没有任何的相关联的比较器。

最后,PriorityQueue不是线程安全的,入队和出队的时间复杂度是O(log(n))。

18. Java 中的 ReadWriteLock 是什么?

ReadWriteLock读写锁是用来提升并发程序性能的锁分离技术的成果。

ReadWriteLock管理一组锁,一个是只读的锁,一个是写锁。

Java并发库中ReetrantReadWriteLock实现了ReadWriteLock接口并添加了可重入的特性。

19. 【阿里巴巴】ThreadLocal 如何保证到线程隔离的?

16529412601.jpg

通过上图,可以看出ThreadLocal之所以能达到变量的线程隔离,其实就是每个线程都有一个自己的ThreadLocalMap对象来存储同一个threadLocal实例set的值,而取值的时候也是根据同一个threadLocal实例去自己的ThreadLocalMap里面查找,自然就互不影响,从而达到线程隔离的目的。