常见JAVA面试题总结<2020 java面试必备>(二)多线程

本文详细探讨了Java中的多线程概念,重点讲解了volatile关键字的作用,包括其对可见性和禁止指令重排序的保障。同时,文章还介绍了CAS(Compare and Swap)机制,解释了为何CAS能保证原子性以及其在Java线程安全中的应用。此外,还涉及了AQS(AbstractQueuedSynchronizer)和不同类型的锁,如公平锁、非公平锁、读写锁等,并讨论了死锁和线程池的相关知识。通过对这些核心概念的解析,帮助读者深入理解Java多线程编程的关键点。
摘要由CSDN通过智能技术生成

多线程

说一下 Runnable 和 Callable 有什么区别?

(1)Runnable接口中的run()方法的返回值是void,它只是去执行run()方法中的代码;
(2)Callable接口中的call()方法返回值是一个泛型,和Future、FutureTask配合可以用来获取异步执行的结果;

谈谈你对volatile的理解

对volatile整体性的描述

volatile是Java虚拟机提供的轻量级的同步机制
​(1)保证可见性
(2)不保存原子性
(3) 禁止指令重排序

什么是可见性,谈谈你对可见性的认识

什么是可见性问题
      可见性问题就是一个线程在将共享变量修改后,还没有来的及将缓存中的变量返回给主存中,另外一个线程就对共享变量进行修改,那么这个线程拿到的值是主存中未被修改的值,这就是可见性的问题
为什么会有可见性问题,了解CPU底层硬件架构

image

      每个处理器都有自己的寄存器(register),多个处理器各自运行一个线程的时候,可能导致某个变量给放到寄存器里去,接着就会导致各个线程没法看到其他处理器寄存器里的变量的值修改了
      比如一个处理器运行的线程对变量的写操作都是针对写缓冲器来的,并不是直接更新主内存,所以很可能导致一个线程更新了变量,但是仅仅是在写缓冲区里罢了,没有更新到主内存里去
     这个时候,其他处理器的线程是没法读到他的写缓冲区的变量值的,所以此时就是会有可见性的问题
     然后即使这个时候一个处理器的线程更新了写缓冲区之后,将更新同步到了自己的高速缓存里(或者是主内存),然后还把这个更新通知给了其他的处理器,但是其他处理器没有更新他的高速缓存
     此时其他处理器的线程从高速缓存里读数据的时候,读到的还是过时的旧值​
实现可见性问题,了解MESI协议
      MESI是针对底层硬件协议有一种实现,就是一个处理器将另外一个处理器的高速缓存中的更新后的数据拿到自己的高速缓存中来更新一下,这样大家的缓存不就实现同步了,然后各个处理器的线程看到的数据就一样了
      在MESI协议的实现中,有两个重要的机制:flush处理器缓存、refresh处理器缓存。

flush处理器缓存
就是把自己更新的值刷新到高速缓存里去(或者是主内存),因为必须要刷到高速缓存(或者是主内存)里,才有可能在后续通过一些特殊的机制让其他的处理器从自己的高速缓存(或者是主内存)里读取到更新的值
*refresh处理器缓存 *
就是说处理器中的线程在读取一个变量的值的时候,如果发现其他处理器的线程更新了变量的值,必须从其他处理器的高速缓存(或者是主内存)里,读取这个最新的值,更新到自己的高速缓存中

Java内存模型,简称JMM

JMM它是一个抽象的概念,描述的是一组规则或规范
JMM关于同步的规定:
1)线程解锁前,必须把共享变量的值刷新回主内存。
2)线程加锁前,必须读取主内存的最新值到自己的工作内存。
3)加锁解锁是同一把锁。

可见性基本遵循了Java的内存模型规范,采用的就是缓存与主存的方式对变量进行操作,也就是说,每个线程都有自己的缓存空间,对变量的操作都是在缓存中进行的,之后再将修改后的值返回到主存中

volitile是如何保证可见性
      在volatile变量写操作的前面会加入一个Store内存屏障,确保在volatile写之前的任何读写操作都不会指令重排,然后根据MESI协议,写完数据之后,立马会执行flush处理器缓存的操作,把自己更新的值刷新到高速缓存里去(或者是主内存)
     在volatile变量读操作的前面会加入一个Load屏障,这样就可以保证对这个变量的读取时,如果被别的处理器修改过了,必须得从其他处理器的高速缓存(或者主内存)中加载到自己本地高速缓存里,保证读到的是最新数据;
      所以说,volitile保证可见性的原理,在底层是通过内存屏障、MESI协议、flush处理器缓存和refresh处理器缓存,这一整套机制来保障的​

什么是原子性,谈谈你对原子性的认识

      原子性是不可分割,完整性,也即某个线程正在做某个具体业务时,中间不可以被加塞或者分割,Java中只有对基本类型变量的赋值和读取是原子操作
      如i = 1的赋值操作,但是像j = i或者i++这样的操作都不是原子操作,因为他们都进行了多次原子操作,比如先读取i的值,再将i的值赋值给j,两个原子操作加起来就不是原子操作了
通过案例场景来说明为什么volitile不能保证原子性

(1)一个变量i被volatile修饰,两个线程想对这个变量修改,都对其进行自增操作也就是i++,i++的过程可以分为三步,首先获取i的值,其次对i的值进行加1,最后将得到的新值写回到缓存中
(2)线程A首先得到了i的初始值100,但是还没来得及修改,就阻塞了
(3)这时线程B开始了,它也得到了i的值,由于i的值未被修改,即使是被volatile修饰,主存的变量还没变化,那么线程B得到的值也是100,之后对其进行加1操作,得到101后,将新值写入到缓存中,再刷入主存中
(4)根据可见性的原则,这个主存的值可以被其他线程可见
(5)问题来了,线程A已经读取到了i的值为100,也就是说读取的这个原子操作已经结束了,所以这个可见性来的有点晚,线程A阻塞结束后,继续将100这个值加1,得到101,再将值写到缓存,最后刷入主存,所以即便是volatile具有可见性,也不能保证对它修饰的变量具有原子性

如何才能保证原子性

1)加Synchronized
2)使用我们的JUC下AtomicInteger getAndIncrement() 方法

什么是指令重排序,谈谈你对指令重排序的认识

指令重排序是编译器和处理器为了高效对程序进行优化的手段
重排序过程:
源代码 —> 编译器优化的重排 —> 指令并行的重排 —> 内存系统的重排 —> 最终执行指令

1)单线程环境里面无须考虑这个问题,程序最终执行结果和代码顺序执行始终是结果一致。
2)多线程环境中,处理器在进行重排序时必须考虑指令之间的数据依赖性。比如要:先有A再有B(遵循happens-before原则),由于在多线程环境中线程交替执行,再加上有编译器优化重排的存在,所以两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。

happens-before原则
      编译器、指令器可能对代码重排序,乱排,但一定要遵守 happens-before 原则,只要符合happens-before的原则,那么就不能胡乱重排,如果不符合这些规则的话,那就可以自己重排
      8条 happens-before 原则避免说出现乱七八糟扰乱秩序的指令重排,但是8条规则之外,可以随意重排指令,以下是8条规则中列举的其中3条
程序次序规则
       一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
锁定规则
       一个unLock操作先行发生于后面对同一个锁的lock操作,比如说在代码里有先对一个`lock.lock()`,然后才有 `lock.unlock()`
volatile变量规则
     对一个volatile变量的写操作先行发生于后面对这个volatile变量的读操作,volatile变量必须保证是先写,再读
volitile是如何禁止指令重排序的
      volatile修饰的对象都会加入读写的内存屏障,是一个CPU指令(涉及到汇编和硬件方面的东西),以此来保证可见性,这时候代码顺序就不会被CPU重排序

1)对volitile变量进行读操作时,会在读操作前加入一条Load内存屏障指令,禁止指令重排并且从主存中读取最新的变量值
2)对volitile变量进行写操作时,会在写操作后加入一条Store内存屏障指令,禁止指令重排的同时将工作内存中的共享变量刷新到主内存中

谈谈你对CAS的理解

CAS是什么

     CAS的全称是CompareAndSwap(比较并替换),它的功能是比较当前工作内存中的值和主内存中的值,如果相同则执行规定操作,否则继续比较直到主内存和工作内存中的值一致为止
     CAS是一条CPU并发原语。所谓的原语它是属于操作系统用语范畴,是由若干指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断

CAS为什么能保证原子性

      之所以称CAS是CPU并发原语,它靠的就是Unsafe类来保证的,它是CAS的核心类,由于Java方法无法直接访问底层系统,需要通过本地(native)方法来访问,Unsafe类中的所有方法都是native修饰的,Unsafe类中的方法都可以直接调用操作系统底层资源执行相应。
       Unsafe类在 jdk 的 rt.jar 包中,属于JVM自带的,其内部方法操作可以像C的指针一样直接操作内存地址。
CAS底层原理是什么,为什么使用Unsafe就能保证线程安全

image

CAS中的方法,比如 getAndIncrement(),调用的是底层Unsafe类中的 getAndAddInt() 方法,它会传3个参数
1)当前对象this,
2)变量valueOffset,表示该变量在内存中的偏移地址,因为Unsafe就是根据内存偏移地址获取数据的
3)用volatile修饰的变量value

Unsafe类 getAndAddInt() 方法

在这里插入图片描述

Unsafe类的 getAndAddInt 方法做的就是三件事
1)先调用 getIntVolatile 方法去取获取value值,因为变量value被volatile修饰,所以其他线程对它的修改,总是能够看到
2)执行compareAndSwapInt方法比较,value值与内存值是否一致,如果一致则执行+1操作,然后将值刷新到主内存中,不一致则继续比较,直到一致为止
3)整个一直比较的过程是通过 do-while 方式来实现

CAS保证线程安全的核心
      CAS保证线程安全的核心就在于调用的是Unsafe类中的native修饰的 compareAndSwapInt 方法,属于底层汇编知识
      相当于说CAS是在底层的硬件级别给你保证一定是原子的,同一时间只有一个线程可以执行CAS,先比较再设值,其他的线程的CAS同时间去执行此时会失败​

为什么要使用CAS,而不使用Synchronized

Synchronized加锁,同一时间段只允许有一个线程来访问,一致性得到了保证,但并发性会下降
CAS使用的是 getAndAddInt(),底层是通过 volatile + do-while的方式来,即保证了一致性,也保证了并发性

CAS的缺点

循环时间长开销很大

如果CAS失败,会一直进行尝试。如果CAS长时间一直不成功,可能会给CPU带来很大的开销。

只能保证一个共享变量的原子操作

CAS只能保证一个共享变量的原子操作,如果对多个共享变量操作时,CAS就无法保证操作的原子性,这个时候就只能用加锁来保证原子性了

ABA问题
什么是ABA问题
       如果线程1从内存地址V中取的值是A,并且在准备赋值的时候检查到它的值仍然为A,那就能说它的值没有被其他线程改变过了吗? 
      如果在这期间线程2将它的值改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。
      CAS算法实现的一个重要前提是取出内存中某时刻的数据并在当下时刻比较并替换,那么在这个时间差内会导致数据的变化,这个漏洞称为CAS操作
如何解决ABA问题

AtomicStampedReference,新增了一种机制就是修改版本号 ,类似于乐观锁机制;
线程1从内存地址V中取值A时版本号是1,如果线程2将它的值改成了B,版本号+1,又改回A,版本号+1,此时线程1修改时发现版本号不对,就会取最新的值,以最新版本号的值来操作

谈谈你对AQS的理解

      Java 中 ReentrantLock 和 Synchronized 的设计都是可重入锁,可重入锁的一个优点是可一定程度避免死锁,它们的原理其实都是通过父类的AQS来实现的

AQS的实现原理

      AQS可以看成是一个组件,它里面维护了一个同步状态state和一个队列,state用来记录重入次数,初始值为0,当有多个线程同时访问某个资源时,会执行CAS操作来更新state值,如果某个线程更新成功了,则把state置为1,线程开始执行,其它更新失败的线程,则会进入队列中等待锁的释放
      当锁释放以后,队列中等待的线程会重新抢锁(执行CAS操作更新state值),而并不是说先到的线程会执行成功,因为 ReentrantLock 和 Synchronized 本身设计是属于非公平锁
      当在某个线程执行的内部时,如果又遇到了加锁的地方,此时 `state != 0`,可重入锁的设计就是会去判断当前线程是否是获取到这个锁的线程,如果是的话执行 `state+1`,且当前线程可以再次获取锁;如果是非可重入锁是直接去获取并尝试更新当前state的值,如果 s`tate != 0` 的话会导致其获取锁失败,当前线程阻塞,这样就会出现死锁的问题,所以ReentrantLock 和 Synchronized 的设计天生就是可重入锁
     释放锁时,可重入锁同样先获取当前state的值,在当前线程是持有锁的线程的前提下。如果 state-1=0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁;而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将state置为0,将锁释放

讲讲你知道的线程中的锁

公平锁和非公平锁

(1)公平锁:多个线程能够按照申请锁的顺序来获取锁,先来后到
​(2)非公平锁:多个线程获取锁的顺序并不是按照申请锁的顺序

那如何能保证每个线程都能拿到锁

JUC并发包中 ReentrantLock 创建时可以指定构造函数,默认是 false 表示非公平锁
ReentrantLock lock = new ReentrantLock(true); 如果给true表示公平锁

非公平锁优缺点

缺点:
​(1)非公平锁对锁的获取是乱序的,即有一个抢占锁的过程
(2)高并发下,非公平锁可能会造成线程饥饿,饥饿的意思是:线程长时间得不到需要的资源而不能执行的现象

优点:
(2)非公平锁的优点在于吞吐量比公平锁大
(3)非公平锁能更充分的利用cpu的时间片,尽量的减少cpu空闲的状态时间

使用场景

(1)如果业务中线程占用(处理)时间要远长于线程等待,那用非公平锁其实效率并不明显
​(2)但是用公平锁会给业务增强很多的可控制性

可重入锁(递归锁)和不可重入锁

(1)可重入锁:又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞

(2)不可重入锁:即若当前线程执行某个方法已经获取了该锁,那么在方法中尝试再次获取锁时,就会获取不到被阻塞,在重复调用同步资源时会出现死锁

自旋锁

是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁
好处是:减少线程上下切换的消耗,不会阻塞
坏处是:循环会消耗CPU,如果长时间不能获取到锁,性能会下降

读锁(共享锁)、写锁(独享锁)

独享锁:指该锁一次只能被一个线程所持有。对ReentrantLock和Synchronized而言都是独享锁
共享锁:指该锁可被多个线程所持有

读写锁应用场景

写线程在数据修改期间,想保证读线程一定能读到最新数据,可以使用读写锁,提前必须是同一把锁

读写锁设计原则
      读 + 读 :相当于无锁,可以并发读

​ 写 + 写:阻塞方式
写+ 读:等待写锁释放
读 + 写:有读锁,写也需要等待
总结就是:只要有写锁存在,写锁没有释放,读就必须等待

多线程中的死锁

      死锁是指两个或者两个以上的进程在执行过程中,因抢夺资源而造成的一种互相等待的现象,若无外力干涉它们将都无法推进下去
产生死锁的主要原因

(1)系统资源不足
(2)进程运行推进的顺序不合适
(3)资源分配不当

死锁问题定位分析

首先使用 jps 命令查看当前正在运行的应用程序,定位到应用程序进程号
然后使用 jstack 进程号, 分析程序原因,找到死锁

程序死锁了就只能具体定位问题原因,然后修改代码,重新部署程序

数据库的悲观锁,乐观锁

悲观锁
       mysql里的悲观锁是走`select * from table where id=1 for update`,意思是我很悲观,我担心自己拿不到这把锁,我必须先锁死,然后就我一个人可以干这事儿,别人都干不了了,不能加共享锁,也不能加排他锁。
      一般悲观锁什么时候用呢?比如你查出来了一条数据,要在内存中修改后再更新到数据库中去,但是如果这个过程中数据被别人更新了,你是不能直接干这个操作的,这个时候,你就得走上面那个操作,查询之后就不让别人更新了,你搞完了再说
       但是真有这种场景,推荐你还是用乐观锁把,悲观锁实现简单一点,但是太有风险了,很容易很容易死锁,比如事务A拿了数据1的锁,事务B拿了数据2的锁,然后事务A又要获取数据2的锁就会等待,事务B又要获取数据1的锁,也会等待,此时尴尬了,死锁,卡死,互相等待,永不释放。​
乐观锁
       乐观锁不需要提前搞一把锁,我就先查出来某个数据同时带着它的版本号,接着再执行各种业务逻辑之后再修改,比较一下这条数据的当前版本号跟我之前查出来的版本号是不是一样的,如果是一样的就修改然后把版本号加1,否则就不会更新任何一行数据,此时就重新查询后再次更新。

Java虚拟机对锁的优化:锁消除、锁粗化、偏向锁

从JDk 1.6开始,JVM就对synchronized锁进行了很多的优化

锁消除
      锁消除是JIT编译器对synchronized锁做的优化,在编译的时候,JIT会通过逃逸分析技术,来分析synchronized锁对象,是不是只可能被一个线程来加锁,没有其他的线程来竞争加锁,这个时候编译就不用加入monitorenter和monitorexit的指令
      这就是,仅仅一个线程争用锁的时候,就可以消除这个锁了,提升这段代码的执行的效率,因为可能就只有一个线程会来加锁,不涉及到多个线程竞争锁​
锁粗化
      这个意思就是,JIT编译器如果发现有代码里连续多次加锁释放锁的代码,会给合并为一个锁,就是锁粗化,把一个锁给搞粗了,避免频繁多次加锁释放锁
偏向锁
       这个意思就是说,monitorenter和monitorexit是要使用CAS操作加锁和释放锁的,开销较大,因此如果发现大概率只有一个线程会主要竞争一个锁,那么会给这个锁维护一个偏好(Bias),后面他加锁和释放锁,基于Bias来执行,不需要通过CAS,性能会提升很多
      如果有其他的线程来竞争这个锁,此时就会收回之前那个线程分配的偏好​

CountDownLatch 线程计数器

      让一个阻塞的线程直到另一些线程完成一系列操作后才被唤醒
      CountDownLatch主要有两个方法,await 和 countDown,当一个线程调用await方法时会被阻塞。其他线程调用countDown方法会将计数器减1,countDown 不会阻塞线程,当计数器的值变为0时,调用 await 方法的线程就会被唤醒,继续执行。
通过代码示例演示CountDownLatch
// 创建一个CountDownLatch 初始值为6
​CountDownLatch countDownLatch = new CountDownLatch(6);
 
// 其它线程分别调用 countDown  将计数器减1for(int i=1;i<=6;i++){
            new Thread(()->{
                System.out.println(Thread.currentThread().getName()+"\t 上完自习,离开教室");
                countDownLatch.countDown();
            },String.valueOf(i)).start();}
// 主线程调用 await 方法进行阻塞
countDownLatch.await();// 当计数器的值变为0时,主线程继续执行以下代码
​System.out.println(Thread.currentThread().getName()+"\t *****班长最后关门走人");

##CyclicBarrier 线程屏障
CyslicBarrier意思是可循环使用的屏障。
​ CyslicBarrier可以让一组线程到达屏障时被阻塞,直到最后一个线程到达屏障时,所有被屏障拦截的线程才会继续干活,通过调用CyclicBarrier的await()方法让线程进入屏障。

通过代码示例演示CyclicBarrier

// 创建一个 CyclicBarrier,当计数器值到达7后才执行后面的函数
​CyclicBarrier cyclicBarrier = new CyclicBarrier(7,()->{System.out.println("召唤神龙");});// 其它线程分别将计数器值 +1,然后调用 await 进入阻塞状态,直到最后一次循环计数器值为7了,才释放 for(int i=1;i<=7;i++){
	final int tempInt = i;
	new Thread(()->{
		System.out.println(Thread.currentThread().getName()+"\t 收集到第:"+tempInt+"龙珠");
		try{
			cyclicBarrier.await();
		} catch (InterruptedException e){
			e.printStackTrace();
		} catch (BrokenBarrierException e){
			e.printStackTrace();
		}
	},String.valueOf(i)).start();
}

Semaphore 信号量

      信号量主要用于两个目的,一个是用于多个共享资源的互斥使用,另一个用于并发线程数的控制。
      Semaphore 主要有两个方法, acquire 和 release,当一个线程调用 acquire 方法时表示抢到了资源,当调用 release 方法时表示将资源释放
       如果将 Semaphore 初始值设置为1,则可以代替 Synchronized  和 Lock 的作用
       Semaphore 可以用来做 秒杀、限流,在实际业务场景中非常实用

通过代码示例演示Semaphore

// 模拟3个车位
​Semaphore semaphore = new Semaphore(3);// 模拟6部车for(int i=1;i<=6;i++) { 
	new Thread(()->{
		try{
			semaphore.acquire();
			System.out.println(Thread.currentThread().getName()+"\t抢到车位");
			TimeUnit.SECONDS.sleep(3);
			System.out.println(Thread.currentThread().getName()+"\t停车3秒后离开车位");
		} catch (InterruptedException e){
			e.printStackTrace();
		} finally {
			semaphore.release();
		}
	},String.valueOf(i)).start();
}

说一下 Synchronized 及其实现原理

      Synchronized可以保证方法或者代码块在运行时,同一时刻只有一个线程可以进入执行,同时它还可以保证共享变量的内存可见性


Java中每一个对象都可以作为锁
​(1)当Synchronized作用在普通方法,锁是当前实例对象(this);
(2)当Synchronized作用在静态方法时,锁是当前类的class对象,因为Class数据存在于永久代,因此静态方法锁相当于该类的一个全局锁
(3)当Synchronized作用在同步方法块,锁是括号里面的对象

Synchronized的实现原理

     synchronized在底层编译后的jvm指令中,会有monitorenter和monitorexit两个指令
     monitorenter控制着加锁,monitorexit控制着解锁
执行加锁指令(monitorenter)时候会干什么
     每个对象都有一个关联的monitor(监视器),如果要对这个对象加锁,要先获取这个对象关联的monitor的lock锁,他里面的原理和思路大概是这样的
     monitor里面有一个计数器,从0开始的。如果一个线程要获取monitor的锁,就看看他的计数器是不是0,如果是0的话,那么说明没人获取锁,他就可以获取锁了,然后对计数器加1,这个monitor的锁是支持重入加锁的
     这个时候,其他的线程在第一次synchronized那里,会发现这个对象的monitor锁的计数器是大于0的,意味着被别人加锁了,然后此时线程就会进入block阻塞状态,什么都干不了,就是等着获取锁
执行解锁指令(monitorexit)时候会干什么
       如果出了synchronized修饰的代码片段的范围,就会有一个monitorexit的指令,在底层。此时获取锁的线程就会对对象的monitor的计数器减1,如果有多次重入加锁就会对应多次减1,直到最后计数器是0
      然后后面block住阻塞的线程,会再次尝试获取锁,但是只有一个线程可以获取到锁
      monitorexit指令在底层jvm编译后会出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁​
monitor的底层实现原理
      java对象都是分为对象头和实例变量两块的,对象头包含了两块东西,一个是Mark Word(包含hashCode、锁数据、GC数据,等等),另一个是Class Metadata Address(包含了指向类的元数据的指针)
     在Mark Word里就有一个指针,是指向了这个对象实例关联的monitor的地址,这个monitor是c++实现的,不是java实现的。在c++里这个monitor实际上是实现的一个ObjectMonitor对象,里面包含了一个_owner指针,指向了持有锁的线程
      ObjectMonitor里还有一个entrylist,想要加锁的线程全部先进入这个entrylist等待获取机会尝试加锁,加锁成功的线程,就会设置_owner指针指向自己,然后对_count 计数器累加1次
      各个线程尝试竞争进行加锁,此时竞争加锁是在JDK 1.6以后优化成了基于CAS来进行加锁,操作将_count值尝试从0变为1,如果成功了,那么加锁成功;如果失败了,那么加锁失败
      然后释放锁的时候,先是对_count计数器递减1,如果为0了就会设置_owner为null,不再指向自己,代表自己彻底释放锁

Synchronized关键字,同时可以保证原子性、可见性以及有序性的

原子性
      原子性的层面而言,有一个加锁和释放锁的机制,加锁了之后,同一段代码就只有他可以执行了
可见性
      他会通过加入一些内存屏障,他在同步代码块对变量做的写操作,都会在释放锁的时候,全部强制执行flush操作,在进入同步代码块的时候,对变量的读操作,全部会强制执行refresh的操作
      更新了数据,别的线程关只要进入了代码块,就一定可以读到的

有序性
通过加各种各样的内存屏障,来保证说,解决LoadLoad、StoreStore等等重排序

Synchronized 与 Reentrantlock 的区别

原始构成的不同

首先Synchronized是java内置关键字,属于jvm层面,Reentrantlock 是Java Concurrency API类;

使用方法的不同

Synchronized无法判断是否获取锁的状态,Reentrantlock 可以判断是否获取到锁;
Synchronized会自动释放锁,Reentrantlock 需在finally中手工释放锁,否则容易造成线程死锁;

等待是否可中断的不同

Synchronized如果线程1阻塞,线程2则会一直等待下去,不可中断,除非抛异常或正常运行完成
​Reentrantlock锁可设置超时方法,如果尝试获取不到锁,线程可以不用一直等待就结束了;

加锁是否公平

Synchronized默认就是非公平锁
Reentrantlock两都可以,根据构造方法传的boolean值来定,默认是非公平锁

绑定多个条件的Condition

Reentrantlock 可以实现分组唤醒需要唤醒的线程,精确唤醒
Synchronized 要么随便唤醒一个,要么唤醒全部线程

阻塞队列

什么是阻塞队列
      阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。
      这两个附加的操作是
                当队列为空时,获取元素的线程将会被阻塞。
                当队列为满时,添加元素的线程将会被阻塞。
      阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。
为什么使用阻塞队列,带来好处是什么
      因为在多线程领域,线程在某些情况下会被阻塞,一旦满足条件,被阻塞的线程又会自动被唤醒
      通过使用阻塞队列,我们不需要关心什么时候需要阻塞线程,什么时候需要唤醒线程,因为这一切阻塞队列都给你包办了
     如果不使用,那都必须去自己控制这些细节,尤其还要兼顾效率和线程安全,而这会给程序带来不小的复杂度
Java中常用的阻塞队列
ArrayBlockingQueue

由数组结构组成的有界阻塞队列

LinkedBlockingQueue

由链表结构组成的无界阻塞队列,之所以为无界,是因为它的边界是 Integer.MAX_VALUE

DelayQueue

延迟无界阻塞队列,在指定时间才能获取队列元素的功能,队列头元素是最接近过期的元素

ConcurrentLinkedQueue

使用CAS原语无锁方式来实现的无界非阻塞的线程安全队列,在多线程访问场景,一般可以提供较高吞吐量

SynchronousQueue

不存储元素的阻塞队列(单个元素队列),每个插入操作必须等到另一个线程调用移出操作,否则插入操作一直处于阻塞状态,吞吐量通常要高

ThreadPool 线程池

为什么使用线程池,它的优势是什么

      系统是不可能说让他无限制的创建很多很多的线程,会构建一个线程池,有一定数量的线程,让他们执行各种各样的任务,线程执行完任务之后,不要销毁掉自己,继续去等待执行下一个任务
     线程复用:降低资源消耗,避免出现频繁的创建线程,销毁线程,创建线程,销毁线程
     控制最大并发数:提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行
     管理线程:提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控

关于线程池的几个重要参数

corePoolSize:核心线程数,能够同时执行的任务数量
maximumPoolSize:除去缓冲队列中等待的任务,线程池能够容纳同时执行的最大线程数(该值是包括了核心线程池数量)
​keepAliveTime:多余空闲线程的存活时间
unit:keepAliveTime的单位
workQueue:阻塞等待线程的队列,被提交但尚未被执行的任务
threadFactory:表示创建线程池中工作线程的工厂,用于创建线程一般用默认的即可
​handler:当任务数超过maximumPoolSize时,对任务的处理策略,默认策略是拒绝添加

说说线程池的底层工作原理

      提交任务,先看一下线程池里的线程数量是否小于corePoolSize,如果小于,直接创建一个线程出来执行你的任务,当执行完你的任务之后,这个线程是不会销毁的,他会尝试从缓冲队列中获取新的任务,如果没有新的任务,此时就会阻塞住,等待新的任务到来
     你持续提交任务,只要线程池的线程数量小于corePoolSize,都会直接创建新线程来执行这个任务,执行完了就尝试从缓冲队列中获取新的任务,当线程池里的corePoolSize数量满了
     接着再次提交任务,后面添加的任务将直接进入缓冲队列workQueue等待,线程会争抢获取任务执行
     当缓冲队列workQueue 也满的时候,看是否超过 maximumPoolSize 线程数
                如果超过,对任务进行处理策略,默认拒绝执行
                如果没有超过,会继续创建额外的线程放入线程池里,来处理这些任务
      当 corePoolSize 满了以后,某个线程空闲时间达到 keepAliveTime 值时,那么这个线程会被销毁,直到只剩下 corePoolSize 个线程为止

线程池几个拒绝策略

AbortPolicy:丢弃任务并抛出RejectedExecutionException异常
CallerRunsPolicy:由调用线程处理该任务(将任务回退给调用者)
DiscardPolicy:丢弃任务,但是不抛出异常。
DiscardOldestPolicy:丢弃队列最前面的任务,然后重新提交被拒绝的任务

你在工作中是如何使用线程池的,是否自定义过线程池使用

        JDK1.5并发包中提供 Executors  可以创建线程池,但在工作中是不允许的,因为 Executors下创建的线程池
        FixedThreadPool、SingleThreadPool 、CachedThreadPool 它们内部默认使用的都是无界阻塞队列,相当允许的请求队列长度为 Integer.MAX_VALUE,一但出现调用超时,可能会堆积大量的请求,然后队列变得越来越大,此时会导致内存飙升起来,而且还可能会导致你会OOM,内存溢出
        工作中使用的是 ThreadPoolExecutor,通过合理分配参数加上设置合理的拒绝策略的方式,来自定义线程池

合理配置线程池你是如何考虑的

首先要了解服务器的CPU是几核的,然后参考以下两种场景来设置

CPU密集型
      CPU密集型任务是指需要大量的运算,程序在内存中执行时间较长,而没有阻塞,CPU一直全速运行。
      CPU密集型时,任务可以少配置线程数,一般配置为CPU核数+1,这样可以使得每个线程都在执行任务
IO密集型
      IO密集型任务是指线程并不是一直执行任务,而是经常要连接数据库,redis等服务器作交互
      IO密集型时,大部分线程都阻塞,故需要多配置线程数,2*cpu核数

sleep 和 wait 有什么区别?

        `sleep()` 方法属于Thread类中的。而wait()方法,则是属于Object类中的
        sleep() 方法会让程序暂停执行指定的时间,线程不会释放对象锁,当指定的时间到了又会自动恢复运行状态
        wait() 方法线程会放弃对象锁,进入到一个和该对象相关的等待池,只有针对此对象调用notify()方法后本线程才进入对象锁定池准备

为什么 wait,notify 和 notifyAll 是在 Object 类中定义的而不是在 Thread 类中定义

1、wait 和 notify 不仅仅是普通方法或同步工具,更重要的是它们是 Java 中两个线程之间的通信机制。对语言设计者而言, 如果不能通过 Java 关键字(例如 synchronized)实现通信此机制,同时又要确保这个机制对每个对象可用, 那么 Object 类则是的正确声明位置

2、每个对象都可上锁,这也是为什么定义在 Object 类而不是 Thread 类中的另一个原因

说下对ThreadLocal的理解,项目中应用场景

什么是ThreadLocal

      ThreadLocal 的作用是提供线程内的局部变量,不同的线程之间不会相互干扰,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或组件之间一些公共变量传递的复杂度

总结:

  1. 线程并发:使用在多线程并发的场景下
  2. 传递数据:我们可以通过ThreadLocal在同一线程,不同组件中传递公共变量
  3. 线程隔离:每个线程的变量都是独立的,不会互相影响​

ThreadLocal与synchronized的区别

      ThreadLocal 与synchronized 都可以处理多线程并发访问变量的问题,不过两者处理问题的角度和思路不同
      ThreadLocal采用'以空间换时间'的方式,为每一个线程都提供了一份变量的副本,从而实现同时访问而互相不干扰,多线程中让每个线程之间的数据相互隔离
      synchronized同步机制采用'以时间换空间'的方式,只提供了一份变量,让不同的线程排队访问,多个线程之间访问资源的同步
     所以使用ThreadLocal在传递数据、线程隔离的场景下更有利于提高程序的并发性

ThreadLocal的内部结构

     在JDK8中 ThreadLocal的设计是:每个线程内部都维护一个ThreadLocalMap,在ThreadLocalMap中,也是用Entry来保存K-V结构数据的,这个key是ThreadLocal实例本身,这点在构造方法中已经限定死了,value是真正要存储的值Object
      这个Entry继承了WeakReference,也就是key -> ThreadLocal 本身是弱引用,其目的是将 ThreadLocal 对象的生命周期和线程生命周期解绑,避免内存泄露问题
     当线程销毁之后,对应的 ThreadLocalMap 也会随之销毁,能减少内存的使用​
关于ThreadLocal内存泄漏的问题,为什么ThreadLocal是弱引用,而不是强引用
了解什么是内存泄露,弱引用、强引用
      所谓的内存泄漏是指程序中己分配的堆内存由于某种原因导致无法回收,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。内存泄漏的堆积最终就导致内存溢出
     强引用(Strong Reference)就是我们最常见的普通对象引用,只要还有强引用指向一个对象,就能表明对象还“活着”,垃圾回收器就不会回收这种对象
     弱引用(WeakReference)垃圾回收器一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存
关于ThreadLocal内存泄漏的问题
    在使用ThreadLocal的过程中会发现有内存泄漏的情况发生,其实这个跟ThreadLocal是弱引用还是强引用是没有直接关系的
    假设在业务代码中使用完ThreadLocal ,然后栈中的ThreadLocal Ref被回收了
    如果是强引用的话,那么ThreadLocalMap中的Entry强引用了ThreadLocal,那么就会造成ThreadLocal无法被回收
    如果是弱引用的话,那么ThreadLocalMap只持有ThreadLocal的弱引用,没有任何强引用指向ThreadLocal实例,这时 ThreadLocal就可以顺利被gc回收,回收后此时Entry中的key=null,但是在没有手动删除这个Entry以及CurrentThread依然运行的前提下,那么这个Entry中的value也是存着强引用的,而这块value永远不会被访问到了,导致value内存泄漏
    所以说内存泄漏的发生跟ThreadLocalMap中的key是否使用强或者弱引用是没有关系的。     
   ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏
为什么ThreadLocal是弱引用,而不是强引用
    因为弱引用比强引用可以多一层保障,弱引用的ThreadLocal会被回收,回收后Entry中的key=null,在ThreadLocalMap中的set/getEntry方法中,会对key为null(也即是ThreadLocal为null)进行判断
    如果为null的话,那么是会对value置为null的,这样对应的value在下一次ThreadLocalMap调用set,get,remove中的任一方法的时候会被清除,从而避免内存泄漏

ThreadLocalMap解决哈希冲突问题

      在ThreadLocalMap中的set方法源码中,它会遍历ThreadLocalMap的Entry数组,里面就有一个nextIndex方法去获取环形数组的下一个索引位置
      其实ThreadLocalMap使用的是线性探测法来解决哈希冲突的,该方法探测下一个有空的地址后插入,若整个空间都找不到空余的地址,则产生溢出
      比如当前table长度为16,计算出来key的hash值为14,如果table[14]上已经有值,并且其key与当前key不一致,那么就发生了hash冲突,这个时候调用nextIndex方法获取下一个位置15,取table[15]进行判断,这个时候如果还是冲突,那就位置就会回到0,取table[0],以此类推,直到可以插入
      按照上面的描述,所以说可以把Entry[]  table看成是一个环形数组​

=========================================================

常见JAVA面试题总结<2020 java面试必备>(五) 网络
常见JAVA面试题总结<2020 java面试必备>(四)设计模式
常见JAVA面试题总结<2020 java面试必备>(三)JVM
常见JAVA面试题总结<2020 java面试必备>(二)多线程
常见JAVA面试题总结<2020 java面试必备>(一)

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值