Java多线程面试题二
1.如何实现乐观锁?
版本号机制
一般是在数据表中加上一个数据版本号 version
字段,表示数据被修改的次数。当数据被修改时,version
值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version
值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version
值相等时才更新,否则重试更新操作,直到更新成功。
CAS算法
CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
2.乐观锁存在哪些问题?
- 高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。不过,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。
- 乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些,这里需要格外注意。
- CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
- 乐观锁的问题:ABA 问题、循环时间长开销大、只能保证一个共享变量的原子操作。
3.为什么不推荐使用FixedThreadPool?
FixedThreadPool
使用无界队列 LinkedBlockingQueue
(队列的容量为 Integer.MAX_VALUE)作为线程池的工作队列会对线程池带来如下影响:
当线程池中的线程数达到 corePoolSize
后,新任务将在无界队列中等待,因此线程池中的线程数不会超过 corePoolSize
;
由于使用无界队列时 maximumPoolSize
将是一个无效参数,因为不可能存在任务队列满的情况。所以,通过创建 FixedThreadPool
的源码可以看出创建的 FixedThreadPool
的 corePoolSize
和 maximumPoolSize
被设置为同一个值。
使用无界队列时 keepAliveTime
将是一个无效参数;
运行中的 FixedThreadPool
(未执行 shutdown()
或 shutdownNow()
)不会拒绝任务,在任务比较多的时候会导致 OOM(内存溢出)。
4.使用线程池的好处?
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
5.Java
的四种引用类型:
- 强引用:我们常常 new 出来的对象就是强引用类型,只要强引用存在,垃圾回收器将永远不会回收被引用的对象,哪怕内存不足的时候
- 软引用:使用 SoftReference 修饰的对象被称为软引用,软引用指向的对象在内存要溢出的时候被回收
- 弱引用:使用 WeakReference 修饰的对象被称为弱引用,只要发生垃圾回收,若这个对象只被弱引用指向,那么就会被回收
- 虚引用:虚引用是最弱的引用,在 Java 中使用 PhantomReference 进行定义。虚引用中唯一的作用就是用队列接收对象即将死亡的通知
6.Runnable 和 Callable 有什么区别?
Runnable 接口中的 run() 方法的返回值是 void,它做的事情只是纯粹地去执行 run() 方法中的代码而已;
Callable 接口中的 call() 方法是有返回值的,是一个泛型,和 Future、FutureTask 配合可以用来获取异步执行的结果。
7.AQS 对资源的共享模式有哪些?
Exclusive(独占):只有一个线程能执行,如:ReentrantLock,又可分为公平锁和非公平锁:
Share(共享):多个线程可同时执行,如:CountDownLatch、Semaphore、CountDownLatch、 CyclicBarrier、ReadWriteLock。
8.AQS 底层使用了模板方法模式,你能说出几个需要重写的方法吗?
使用者继承 AbstractQueuedSynchronizer 并重写指定的方法。将 AQS 组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。
isHeldExclusively() :该线程是否正在独占资源。只有用到 condition 才需要去实现它。
tryAcquire(int) :独占方式。尝试获取资源,成功则返回 true,失败则返回 false。
tryRelease(int) :独占方式。尝试释放资源,成功则返回 true,失败则返回 false。
tryAcquireShared(int) :共享方式。尝试获取资源。负数表示失败;0 表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int) :共享方式。尝试释放资源,成功则返回 true,失败则返回 false。
9. 说下对信号量 Semaphore 的理解?
synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore (信号量)可以指定多个线程同时访问某个资源。
执行 acquire 方法阻塞,直到有一个许可证可以获得然后拿走一个许可证;每个 release 方法增加一个许可证,这可能会释放一个阻塞的 acquire 方法。然而,其实并没有实际的许可证这个对象,Semaphore 只是维持了一个可获得许可证的数量。Semaphore 经常用于限制获取某种资源的线程数量。当然一次也可以一次拿取和释放多个许可证,不过一般没有必要这样做。除了 acquire方法(阻塞)之外,另一个比较常用的与之对应的方法是 tryAcquire 方法,该方法如果获取不到许可就立即返回 false。
10.谈谈对 ThreadLocal 的理解?
Java 的 Web 项目大部分都是基于 Tomcat。每次访问都是一个新的线程,每一个线程都独享一个 ThreadLocal,我们可以在接收请求的时候 set 特定内容,在需要的时候 get 这个值。
ThreadLocal 提供 get 和 set 方法,为每一个使用这个变量的线程都保存有一份独立的副本。
public T get() {...}
public void set(T value) {...}
public void remove() {...}
protected T initialValue() {...}
get() 方法是用来获取 ThreadLocal 在当前线程中保存的变量副本;
set() 用来设置当前线程中变量的副本;
remove() 用来移除当前线程中变量的副本;
initialValue() 是一个 protected 方法,一般是用来在使用时进行重写的,如果在没有 set 的时候就调用 get,会调用 initialValue 方法初始化内容。
11.在哪些场景下会使用到 ThreadLocal?
在调用 API 接口的时候传递了一些公共参数,这些公共参数携带了一些设备信息(是安卓还是 ios),服务端接口根据不同的信息组装不同的格式数据返回给客户端。假定服务器端需要通过设备类型(device)来下发下载地址,当然接口也有同样的其他逻辑,我们只要在返回数据的时候判断好是什么类型的客户端就好了。上面这种场景就可以将传进来的参数 device 设置到 ThreadLocal 中。用的时候取出来就行。避免了参数的层层传递。
12.谈谈 synchronized 和 ReenTrantLock 的区别?
synchronized 是和 for、while 一样的关键字,ReentrantLock 是类,这是二者的本质区别。既然 ReentrantLock 是类,那么它就提供了比 synchronized 更多更灵活的特性:等待可中断、可实现公平锁、可实现选择性通知(锁可以绑定多个条件)、性能已不是选择标准。
synchronized 依赖于 JVM 而 ReenTrantLock 依赖于 API。synchronized 是依赖于 JVM 实现的,JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReenTrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。
13.CountDownLatch 和 CyclicBarrier 有什么区别?
CountDownLatch 是计数器,只能使用一次,而 CyclicBarrier 的计数器提供 reset 功能,可以多次使用。对于 CountDownLatch 来说,重点是“一个线程(多个线程)等待”,而其他的 N 个线程在完成“某件事情”之后,可以终止,也可以等待。而对于 CyclicBarrier,重点是多个线程,在任意一个线程没有完成,所有的线程都必须等待。
CountDownLatch 是计数器,线程完成一个记录一个,只不过计数不是递增而是递减,而 CyclicBarrier 更像是一个阀门,需要所有线程都到达,阀门才能打开,然后继续执行。
CountDownLatch 应用场景:
某一线程在开始运行前等待 n 个线程执行完毕:启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。
实现多个线程开始执行任务的最大并行性。注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。
死锁检测:一个非常方便的使用场景是,你可以使用 n 个线程访问共享资源,在每次测试阶段的线程数目是不同的,并尝试产生死锁。
CyclicBarrier 应用场景:
CyclicBarrier 可以用于多线程计算数据,最后合并计算结果的应用场景。比如:我们用一个 Excel 保存了用户所有银行流水,每个 Sheet 保存一个帐户近一年的每笔银行流水,现在需要统计用户的日均银行流水,先用多线程处理每个 sheet 里的银行流水,都执行完之后,得到每个 sheet 的日均银行流水,最后,再用 barrierAction 用这些线程的计算结果,计算出整个 Excel 的日均银行流水。
14.JDK 中提供了哪些并发容器?
JDK 提供的这些容器大部分在 java.util.concurrent 包中。
ConcurrentHashMap:线程安全的 HashMap;
CopyOnWriteArrayList:线程安全的 List,在读多写少的场合性能非常好,远远好于 Vector;
ConcurrentLinkedQueue:高效的并发队列,使用链表实现。可以看做一个线程安全的 LinkedList,这是一个非阻塞队列;
BlockingQueue:这是一个接口,JDK 内部通过链表、数组等方式实现了这个接口。表示阻塞队列,非常适合用于作为数据共享的通道;
ConcurrentSkipListMap:跳表的实现。这是一个 Map,使用跳表的数据结构进行快速查找。
15.谈谈对 CopyOnWriteArrayList 的理解?
在很多应用场景中,读操作可能会远远大于写操作。由于读操作根本不会修改原有的数据,因此对于每次读取都进行加锁其实是一种资源浪费。我们应该允许多个线程同时访问 List 的内部数据,毕竟读取操作是安全的。
CopyOnWriteArrayList 类的所有可变操作(add,set 等等)都是通过创建底层数组的新副本来实现的。当 List 需要被修改的时候,我们并不需要修改原有内容,而是对原有数据进行一次复制,将修改的内容写入副本。写完之后,再将修改完的副本替换原来的数据,这样就可以保证写操作不会影响读操作了。
从 CopyOnWriteArrayList 的名字就能看出 CopyOnWriteArrayList 是满足 CopyOnWrite 的 ArrayList,所谓 CopyOnWrite 也就是说:在计算机,如果你想要对一块内存进行修改时,我们不在原有内存块中进行写操作,而是将内存拷贝一份,在新的内存中进行写操作,写完之后,就将指向原来内存指针指向新的内存,原来的内存就可以被回收掉了。
CopyOnWriteArrayList 读取操作没有任何同步控制和锁操作,理由就是内部数组 array 不会发生修改,只会被另外一个 array 替换,因此可以保证数据安全。
CopyOnWriteArrayList 写入操作 add() 方法在添加集合的时候加了锁,保证了同步,避免了多线程写的时候会 copy 出多个副本出来。
16.谈谈对 ConcurrentSkipListMap 的理解?
对于一个单链表,即使链表是有序的,如果我们想要在其中查找某个数据,也只能从头到尾遍历链表,这样效率自然就会很低,跳表就不一样了。跳表是一种可以用来快速查找的数据结构,有点类似于平衡树。它们都可以对元素进行快速的查找。但一个重要的区别是:对平衡树的插入和删除往往很可能导致平衡树进行一次全局的调整。而对跳表的插入和删除只需要对整个数据结构的局部进行操作即可。这样带来的好处是:在高并发的情况下,你会需要一个全局锁来保证整个平衡树的线程安全。而对于跳表,你只需要部分锁即可。这样,在高并发环境下,你就可以拥有更好的性能。而就查询的性能而言,跳表的时间复杂度也是 O(logn) 。跳表的本质是同时维护了多个链表,并且链表是分层的。
---------------------------------------------------------------------------------------------------------------------------------