Java 多线程和高并发面试题
Posted Javachichi
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java 多线程和高并发面试题相关的知识,希望对你有一定的参考价值。
volatile
对 volatile的理解
volatile 是一种轻量级的同步机制。
- 保证数据可见性
- 不保证原子性
- 禁止指令重排序
JMM
JMM(Java 内存模型)是一种抽象的概念,描述了一组规则或规范,定义了程序中各个变量的访问方式。
JVM运行程序的实体是线程,每个线程创建时 JVM 都会为其创建一个工作内存,是线程的私有数据区域。JMM中规定所有变量都存储在主内存,主内存是共享内存。线程对变量的操作在工作内存中进行,首先将变量从主内存拷贝到工作内存,操作完成后写会主内存。不同线程间无法访问对方的工作内存,线程通信(传值)通过主内存来完成。
JMM 对于同步的规定:
- 线程解锁前,必须把共享变量的值刷新回主内存
- 线程加锁前,必须读取主内存的最新值到自己的工作内存
- 加锁解锁是同一把锁
JMM 的三大特性
- 可见性
- 原子性
- 顺序性
原子性是不可分割,某个线程正在做某个具体业务时,中间不可以被分割,要么全部成功,要么全部失败。
重排序:计算机在执行程序时,为了提高性能,编译器和处理器常常对指令做重排序,源代码经过编译器优化重排序、指令并行重排序、内存系统的重排序之后得到最终执行的指令。
在单线程中保证程序最终执行结果和代码执行顺序执行结果一致。
多线程中线程交替执行,由于重排序,两个线程中使用的变量能否保证一致性无法确定,结果无法确定。
处理器在处理重排序时需要考虑数据的依赖性。
volatile 实现禁止指令重排序,避免多线程环境下程序乱序执行。是通过内存屏障指令来执行的,通过插入内存屏障禁止在内存屏障后的指令执行重排序优化,并强制刷出缓存数据,保证线程能读取到这些数据的最新版本。
实例1:volatile 保证可见性
class MyData {
//volatile int number = 0;//case2
//int number=0; //case1
public void change() {
number = 60;
}
}
public class VolatileDemo {
public static void main(String[] args) {
MyData data=new MyData();
new Thread(()->{
System.out.println(Thread.currentThread().getName()+"\\t come in");
try{ TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) {e.printStackTrace();}
data.change();
System.out.println(Thread.currentThread().getName()+"\\t updated number value:"+data.number);
},"A").start();
while(data.number==0){}
System.out.println(Thread.currentThread().getName()+"\\t over, get number:"+data.number);
}
}
当我们使用case1的时候,也就是number没有volatile修饰的时候,运行结果:
A come in
A updated number value:60
并且程序没有执行结束,说明在main线程中由于不能保证可见性,一直在死循环。
当执行case2的时候:
A come in
A updated number value:60
main over, get number:60
保证了可见性,因此main成功结束。
实例2: volatile 不保证原子性
class MyData {
volatile int number = 0;
public void change() {
number = 60;
}
public void addOne() {
number++;
}
}
public class VolatileDemo {
public static void main(String[] args) {
case2();
}
//验证原子性
public static void case2() {
MyData myData = new MyData();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
myData.addOne();
}
}, String.valueOf(i)).start();
}
while(Thread.activeCount()>2){
Thread.yield();
}
System.out.println(Thread.currentThread().getName()+"\\t number value:"+myData.number);
}
}
最终输出结果可以发现并不是 20000,且多次输出结果并不一致,因此说明 volatile 不能保证原子性。
如何保证原子性
- 加锁:使用 synchronized 加锁
- 使用 AtomicInteger
实例3:volatile 和 单例模式
DCL模式的单例模式
public class Singleton {
private static Singleton instance=null;
private Singleton(){
System.out.println(Thread.currentThread().getName()+" constructor");
}
//DCL 双端检锁机制
public static Singleton getInstance(){
if(instance==null){
synchronized (Singleton.class){
if(instance==null)
instance=new Singleton();
}
}
return instance;
}
}
DCL 机制不能完全保证线程安全,因为有指令重排序的存在。
原因在于instance = new Singleton(); 可以分为三步:
1. memory=allocate();//分配内存空间
2. instance(memory);//初始化对象
3. instance=memory;//设置instance指向分配的内存地址,分配成功后,instance!=null
由于步骤2和步骤3不存在数据依赖关系,且无论重排序与否执行结果在单线程中没有改变,因此这两个步骤的重排序是允许的。也就是说指令重排序只会保证单线程串行语义的一致性(as-if-serial),但是不会关心多线程间的语义一致性。
因此,重排序之后,先执行3会导致instance!=null,但是对象还未被初始化。此时,别的线程在调用时,获取了一个未初始化的对象。
因此,在声明 instance 时,使用 volatile 进行修饰,禁止指令重排序。
private static volatile Singleton instance = null;
CAS
CAS 的全程是 CompareAndSwap,是一条 CPU 并发原语。它的功能是判断内存某个位置的值是否为预期值,如果是则更新为新的值,这个过程是原子的。
CAS 的作用是比较当前工作内存中的值和主内存中的值,如果相同则执行操作,否则继续比较直到主内存和工作内存中的值一致为止。主内存值为V,工作内存中的预期值为A,要修改的更新值为B,当且仅当A和V相同,将V修改为B,否则什么都不做。
CAS 底层原理:
在原子类中,CAS 操作都是通过 Unsafe 类来完成的。
//AtomicInteger i++
public final int getAndIncrement(){
return unsafe.getAndAddInt(this,valueoffset,1);
}
其中 this 是当前对象, valueoffset 是一个 long ,代表地址的偏移量。
//AtomicInteger.java
private static final Unsafe unsfae=Unsafe.getUnsafe();//unsafe对象
private static final long valueOffset;//地址偏移量
static{
try{
valueoffset=unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value");
}catch(Excepthion ex){throw new Error(ex);}
}
private volatile int value;//存储的数值
- Unsafe
Unsafe 类是 rt.jar 下的 sun.misc 包下的一个类,基于该类可以直接操作特定内存的数据。
Java方法无法直接访问底层系统,需要使用 native 方法访问,Unsafe 类的内部方法都是 native 方法,其中的方法可以像C的指针一样直接操作内存,Java 中的 CAS 操作的执行都依赖于 Unsafe 类的方法。
- valueOffset
该变量表示变量值在内存中的偏移地址, Unsafe 就是根据内存偏移地址获取数据的。
Unsafe类
CAS 并发源于体现在 Java 中就是 Unsafe 类的各个方法。调用该类中的 CAS 方法,JVM会帮我们实现出 CAS 汇编指令,这是一种完全依赖于硬件的功能。
原语是由若干条指令组成的,用于完成某个功能的过程。原语的执行必须是连续的,执行过程不允许被中断。所以 CAS 是一条 CPU 的原子指令,不会造成数据不一致问题。
下边是 AtomicInteger 中实现 i++ 功能所调用的 Unsafe 类的函数。
//unsafe.getAndAddInt
public final int getAndAddInt(Object var1,long var2,int var4){
int var5;
do{
//获取当前的值的地址
var5=this.getIntVolatile(var1,var2);
//var1代表对象,var2和var5分别代表当前对象的真实值和期望值,如果二者相等,更新为var5+var4
}while(!this.compareAndSwapInt(var1,var2,var5,var5+var4);
return var5;
}
在 getAndAddInt 函数中,var1 代表了 AtomicInteger 对象, var2 代表了该对象在内存中的地址, var4 代表了期望增加的数值。
首先通过 var1 和 var2 获取到当前的主内存中真实的 int 值,也就是 var5。
然后通过循环来进行数据更改,当比较到真实值和对象的当前值相等,则更新,退出循环;否则再次获取当前的真实值,继续尝试,直到成功。
在 CAS 中通过自旋而不是加锁来保证一致性,同时和加锁相比,提高了并发性。
具体情境来说:线程A和线程B并发执行 AtomicInteger 的自增操作:
- AtomicInteger 中的 value 原始值为 3。主内存中 value 为 3, 线程A和线程B的工作内存中有 value 为 3 的副本;
- 线程 A 通过 getIntVolatile() 获取到 value 的值为3,并被挂起。
- 线程 B 也获取到 value 的值为3,然后执行 compareAndSwapInt 方法,比较到内存真实值也是 3,因此成功修改内存值为4.
- 此时线程 A 继续执行比较,发现对象中的 value 3 和主内存中的 value 4 不一致,说明已经被修改,A 重新进入循环。
- 线程 A 重新获取 value,由于 value 被 volatile 修饰,所以线程 A 此时 value 为4,和主内存中 value 相等,修改成功。
CAS的缺点
- 如果CAS失败,会一直尝试。如果CAS长时间不成功,会给CPU带来很大的开销。
- CAS 只能用来保证单个共享变量的原子操作,对于多个共享变量操作,CAS无法保证,需要使用锁。
- 存在 ABA 问题。
ABA问题
CAS 实现一个重要前提需要取出内存中某个时刻的数据并在当下时刻比较并替换,这个时间差会导致数据的变化。
线程1从内存位置V中取出A,线程2也从V中取出A,然后线程2通过一些操作将A变成B,然后又把V位置的数据变成A,此时线程1进行CAS操作发现V中仍然是A,操作成功。尽管线程1的CAS操作成功,但是不代表这个过程没有问题。
这个问题类似于幻读问题,通过新增版本号的机制来解决。在这里可以使用 AtomicStampedReference 来解决。
AtomicStampedReference
通过 AtomicStampedReference 来解决这个问题。
public class SolveABADemo {
static AtomicStampedReference<Integer> atomicStampedReference=new AtomicStampedReference<>(100,1);
new Thread(()->{
int stamp=atomicStampedReference.getStamp();
System.out.println(Thread.currentThread().getName()+"\\t 版本号:"+stamp);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
atomicStampedReference.compareAndSet(100,101,atomicStampedReference.getStamp(),atomicStampedReference.getStamp()+1);
System.out.println(Thread.currentThread().getName()+"\\t 版本号:"+atomicStampedReference.getStamp());
atomicStampedReference.compareAndSet(101,100,atomicStampedReference.getStamp(),atomicStampedReference.getStamp()+1);
System.out.println(Thread.currentThread().getName()+"\\t 版本号:"+atomicStampedReference.getStamp());
},"t1").start();
new Thread(()->{
int stamp=atomicStampedReference.getStamp();
System.out.println(Thread.currentThread().getName()+"\\t 版本号:"+stamp);
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean ret=atomicStampedReference.compareAndSet(100,2019,stamp,stamp+1);
System.out.println(Thread.currentThread().getName()+"\\t"+ret
+" stamp:"+atomicStampedReference.getStamp()
+" value:"+atomicStampedReference.getReference());
},"t2").start();
}
}
t1 版本号:1
t2 版本号:1
t1 版本号:2
t1 版本号:3
t2 false stamp:3 value:100
集合类的线程安全问题
ConcurrentModificationException
这个异常也就是并发修改异常,java.util.ConcurrentModificationException。
导致这个异常的原因,是集合类本身是线程不安全的。
解决方案:
- 使用 Vector, Hashtable 等同步容器
- 使用 Collections.synchronizedxxx(new XX) 创建线程安全的容器
- 使用 CopyOnWriteList, CopyOnWriteArraySet, ConcurrentHashMap 等 j.u.c 包下的并发容器。
CopyOnWriteArrayList
底层使用了 private transient volatile Object[] array;
CopyOnWriteArrayList 采用了写时复制、读写分离的思想。
public boolean add(E e){
final ReentrantLock lock=this.lock;
try{
//旧数组
Object[] elements = getArray();
int len = elements.length;
//复制新数组
Object[] newElements = Arrays.copyOf(elements, len+1);
//修改新数组
newElements[len] = e;
//更改旧数组引用指向新数组
setArray(newElements);
return true;
}finally{
lock.unlock();
}
}
添加元素时,不是直接添加到当前容器数组,而是复制到新的容器数组,向新的数组中添加元素,添加完之后将原容器引用指向新的容器。
这样做的好处是可以对该容器进行并发的读,而不需要加锁,因为读时容器不会添加任何元素。
CopyOnWriteArraySet 本身就是使用 CopyOnWriteArrayList 来实现的。
Java锁
公平锁和非公平锁
ReentrantLock 可以指定构造函数的 boolean 类型得到公平或非公平锁,默认是非公平锁,synchronized也是非公平锁。
公平锁是多个线程按照申请锁的顺序获取锁,是 FIFO 的。并发环境中,每个线程在获取锁时先查看锁维护的等待队列,为空则战友,否则加入队列。
非公平锁是指多个线程不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。高并发情况下可能导致优先级反转或者饥饿现象。并发环境中,上来尝试占有锁,尝试失败,再加入等待队列。
可重入锁(递归锁)
可冲入锁指的是同一线程外层函数获取锁之后,内层递归函数自动获取锁。也就是线程能进入任何一个它已经拥有的锁所同步着的代码块。
ReentrantLock 和 synchronized 都是可重入锁。
可重入锁最大的作用用来避免死锁。
自旋锁
自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式尝试获取锁。好处是减少线程上下文切换的消耗,缺点是循环时会消耗CPU资源。
实现自旋锁:
public class SpinLockDemo {
//使用AtomicReference<Thread>来更新当前占用的 Thread
AtomicReference<Thread> threadAtomicReference=new AtomicReference<>();
public static void main(String[] args) {
SpinLockDemo demo=new SpinLockDemo();
new Thread(()->{
demo.myLock();
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
demo.myUnlock();
},"t1").start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(()->{
demo.myLock();
demo.myUnlock();
},"t2").start();
}
public void myLock(){
Thread thread=Thread.currentThread();
System.out.println(Thread.currentThread().getName()+"\\t come in");
//如果当前占用的线程为null,则尝试获取更新
while(!threadAtomicReference.compareAndSet(null,thread)){
}
}
public void myUnlock(){
Thread thread=Thread.currentThread();
//释放锁,将占用的线程设置为null
threadAtomicReference.compareAndSet(thread,null);
System.out.println(Thread.currentThread().getName()+"\\t unlocked");
}
}
读写锁
独占锁:该锁一次只能被一个线程持有,如 ReentrantLock 和 synchronized。
共享锁:该锁可以被多个线程持有。
ReentrantReadWriteLock 中,读锁是共享锁,写锁时独占锁。读读共享保证并发性,读写互斥。
并发工具类
CountDownLatch
CountDownLatch 的作用是让一些线程阻塞直到另外一些线程完成一系列操作后才被唤醒。
CountDownLatch 在初始时设置一个数值,当一个或者多个线程使用 await() 方法时,这些线程会被阻塞。其余线程调用 countDown() 方法,将计数器减去1,当计数器为0时,调用 await() 方法被阻塞的线程会被唤醒,继续执行。
可以理解为,等大家都走了,保安锁门。
CyclicBarrier
CyclicBarrier 是指可以循环使用的屏障,让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障,屏障才会开门,被屏障拦截的线程才会继续工作,线程进入屏障通过 await() 方法。
可以理解为,大家都到齐了,才能开会。
Semaphore
信号量用于:
- 多个共享资源的互斥使用
- 并发线程数的控制
可以理解为,多个车抢停车场的多个车位。当进入车位时,调用 acquire() 方法占用资源。当离开时,调用 release() 方法释放资源。
阻塞队列
阻塞队列首先是一个队列,所起的作用如下:
- 当阻塞队列为空,从队列中获取元素的操作将会被阻塞
- 当阻塞队列为满,向队列中添加元素的操作将会被阻塞
试图从空的阻塞队列中获取元素的线程将会被阻塞,直到其他线程向空的队列中插入新的元素。同样的,试图向已满的阻塞队列中添加新元素的线程同样会被阻塞,直到其他线程从队列中移除元素使得队列重新变得空闲起来并后序新增。
阻塞:阻塞是指在某些情况下会挂起线程,即阻塞,一旦条件满足,被挂起的线程又会自动被唤醒。
优点:BlockingQueue 能帮助我们进行线程的阻塞和唤醒,而无需关心何时需要阻塞线程,何时需要唤醒线程。同时兼顾了效率和线程安全。
阻塞队列的架构
BlokcingQueue 接口实现了 Queue 接口,该接口有如下的实现类:
- ArrayBlockingQueue: 由数组组成的有界阻塞队列
- LinkedBlockingQueue: 由链表组成的有界阻塞队列(默认大小为 Integer.MAX_VALUE)
- PriorityBlockingQueue:支持优先级排序的无界阻塞队列
- DelayQueue:使用优先级队列实现的延迟无界阻塞队列
- SynchronousQueue: 不存储元素的阻塞队列,单个元素的队列,同步提交队列
- LinkedTransferQueue:链表组成的无界阻塞队列
- LinkedBlockingDeque:链表组成的双向阻塞队列
阻塞队列的方法
方法类型 | 抛出异常 | 特殊值 | 阻塞 | 超时 |
---|---|---|---|---|
插入 | add(e) | offer(e) | put(e) | offer(e,time,unit) |
移除 | remove() | poll() | take() | poll(time,unit) |
检查 | element() | peek() | 无 | 无 |
- 抛出异常:当队列满,
add(e)
会抛出异常IllegalStateException: Queue full
;当队列空,remove()
和element()
会抛出异常NoSuchElementException
- 特殊值:
offer(e)
会返回 true/false。peek()
会返回队列元素或者null。 - 阻塞:队列满,
put(e)
会阻塞直到成功或中断;队列空take()
会阻塞直到成功。 - 超时:阻塞直到超时后退出,返回值和特殊值中的情况一样。
生产者消费者模式
方式1. 使用Lock
class ShareData {
private int number = 0;
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();
public void increment() throws Exception {
lock.lock();
try {
//判断
while (number != 0) {
condition.await();
}
//干活
number++;
System.out.println(Thread.currentThread().getName() + " produce\\t" + number);
//通知唤醒
condition.signalAll();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public void decrement()throws Exception{
lock.lock();
try {
//判断
while (number == 0) {
condition.await();
}
//干活
number号称史上最全Java多线程与并发面试题总结—基础篇