Java面试系列2—线程与锁

 

目录

1、说说volatile关键字实现?

2、进程与线程的区别?

3、进程间的通信方式?

4、进程的同步方式?

5、线程的同步方式?

6、Java如何创建线程?

7、线程有哪些状态?

8、什么是死锁?如何检测与避免?

9、CAS原理?

10、CAS的优缺点?

11、说说线程池原理?

12、使用线程池的好处?

13、execute()与submit()方法有什么区别?

14、线程池参数?

15、几种常见的线程池?

16、线程池有哪几种工作队列?

17、线程池的拒绝策略?

18、线程池的异常如何处理?

19、线程池有哪几种状态?

20、线程池大小怎么确定?

21、synchronized关键字原理?

22、Monitor是什么?它怎么做到?

23、synchronized与lock区别?

24、synchronized与volatile关键字区别?

25、AQS原理?

26、AQS的应用?

28、说说ThreadLocal?内存泄露如何解决?父线程的线程本地变量如何传给子类线程?

29、说说Java的锁机制?种类?

30、JVM对锁做的优化有哪些?

31、Atomic原子类知道吗?大概说说?

32、关注wx公众号:青年泛,将持续输出面试系列及知识系列,一起bb啊


1、说说volatile关键字实现?

被volatile关键字修饰的变量具有可见性与有序性,但是不保证原子性

可见性指的是对volatile变量的修改,其它线程可以立即感知。Jvm通过在对volatile修饰的变量进行写操作的时候加上lock前缀指令,这有两个作用,一是将当前处理器缓存行的数据写回系统内存,即将工作缓存重新写回到主内存中。二是将这个操作会使得其它CPU里缓存了该内存地址的数据失效(其它处理器通过嗅探在总线上传递的数据来检查自己的缓存是否失效)。而且,根据先行发生规则中的volatile变量的写操作要先于读操作,这也保证了可见性。

volatile的有序性是通过禁止指令重排序来实现的,Java内存模型会为volatile的读写操作加内存屏障,来限制编译器和处理器的重排序。

volatile写指的是:把该线程对应的本地内存中的共享变量值刷新到主内存

volatile读指的是:该线程对应的本地内存置为无效,线程接下来将从主内存读取共享变量

在每个volatile写操作前插入一个StoreStore屏障,禁止上面的普通写和下面的volatile写重排序。写操作后插入一个StoreLoad屏障,防止上面的volatile写与下面的volatile读写重排序。

在每个volatile读操作后先插入一个LoadLoad屏障,禁止之后的普通读操作和上面的volatile读重排序,再插入一个LoadStore屏障,禁止之后的普通写操作和上面的volatile读重排序。

不保证原子性,如volatile方式的i++,总共是四个步骤:i++实际为load、Increment、store、Memory Barriers 四个操作。内存屏障是线程安全的,但是内存屏障之前的指令并不是。

2、进程与线程的区别?

①地址空间和其它资源:进程间拥有独立内存,进程是资源分配的基本单位;线程隶属于某一进程,且同一进程的各线程间共享内存(资源),线程是cpu调度的基本单位。

②通信:进程间相互独立,通信困难,线程间可以直接读写进程数据段(如全局变量)来进行通信。

③调度和切换:线程上下文切换比进程上下文切换要快。进程间切换要保存上下文,加载另一个进程;而线程则共享了进程的上下文环境,切换更快。

3、进程间的通信方式?

每个进程的用户地址空间都是独立的,一般而言是不能互相访问的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核

Linux内核提拱了管道、共享内存、消息队列、socket、信号量、信号。

(1)管道:就是内核里面的一串缓存。从管道的一段写入的数据,实际上是缓存在内核中的,另一端读取,也就是从内核中读取这段数据。单向传输。

对于匿名管道,它的通信范围是存在父子关系的进程。因为管道没有实体,也就是没有管道文件,只能通过 fork 来复制父进程 fd 文件描述符,来达到通信的目的。

对于命名管道,它可以在不相关的进程间也能相互通信。因为命令管道,提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。

管道的通信方式是效率低的,因此管道不适合进程间频繁地交换数据。

(2)消息队列:

消息队列是保存在内核中的消息链表,在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。

消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在,而前面提到的匿名管道的生命周期,是随进程的创建而建立,随进程的结束而销毁。

缺点:通信不及时、消息体大小受限制,不适合比较大数据的传输,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限。在 Linux 内核中,会有两个宏定义 MSGMAX 和 MSGMNB,它们以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度。

存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。

(3)共享内存:(解决内核态与用户态直接的数据拷贝开销)

消息队列的读取和写入的过程,都会有发生用户态与内核态之间的消息拷贝过程。那共享内存的方式,就很好的解决了这一问题。

现代操作系统,对于内存管理,采用的是虚拟内存技术,也就是每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中。所以,即使进程 A 和 进程 B 的虚拟地址是一样的,其实访问的是不同的物理内存地址,对于数据的增删查改互不影响。

共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。

带来新的问题,那就是如果多个进程同时修改同一个共享内存,很有可能就冲突了。例如两个进程都同时写一个地址,那先写的那个进程会发现内容被别人覆盖了。

(4)信号量:

信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据

可以发现,信号初始化为 1,就代表着是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。

可以发现,信号初始化为 0,就代表着是同步信号量,它可以保证进程 A 应在进程 B 之前执行。

(5)信号:

 Linux 操作系统中, 为了响应各种各样的事件,提供了几十种信号,分别代表不同的意义。

信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程。进程收到信号后,有相应的处理方式:

①执行默认操作

②捕捉信号

③忽略信号

(6)Socket套接字:

跨网络与不同主机上的进程之间通信,就需要 Socket 通信。

4、进程的同步方式?

(1)临界区:通过多线程的串行化来访问公共资源或一段代码。

(2)互斥量:采用互斥对象机制,只有拥有互斥对象的线程才能访问公共资源

(3)信号量:信号允许多个线程同时使用共享资源,但是限制了同时访问资源的最大线程数。

(4)事件:通过通知操作的方式来保持线程同步。

 

5、线程的同步方式?

(1)互斥锁:通过锁机制实现线程间的同步

(2)条件变量:条件变量用来自动阻塞一个线程,直到某特殊情况发生为止。通常条件变量和互斥锁同时使用。

条件变量分为两部分: 条件和变量。条件本身是由互斥量保护的。线程在改变条件状态前先要锁住互斥量。条件变量使我们可以睡眠等待某种条件出现。

条件变量是利用线程间共享的全局变量进行同步的一种机制,主要包括两个动作:一个线程等待”条件变量的条件成立”而挂起;另一个线程使”条件成立”(给出条件成立信号)。

(3)信号量:如同进程一样。

 

6、Java如何创建线程?

(1)继承Thread类,重写run方法

(2)实现Runnable接口,重写run方法,实现类的实例作为Thread构造函数的target

(3)实现Callable接口,重写call方法,用FutureTask包装,再传入Thread类的构造函数。

 

7、线程有哪些状态?

  • New:初始状态,表示线程被构造,没有执行start()方法。
  • Runnable:运行状态,Java把操作系统中的就绪状态和运行状态笼统称为运行状态。执行Thread的start方法后。
  • Blocked:阻塞状态,线程被锁阻塞,没有获取到锁资源
  • Waiting:等待状态,表示当前线程需要等待其他线程通知或中断。
  • Time_Waiting:超时等待状态,可以在指定时间内回到Runnable状态。
  • Terminated:终止状态,表示当前线程以执行完毕。

8、什么是死锁?如何检测与避免?

产生死锁需要满足4个条件:

①互斥条件:该资源任意一个时刻只有一个线程占用

②请求与保持条件:一个线程因请求资源而阻塞是,对已获得的资源保持不放

③不可剥夺条件:线程已经获得的资源未使用完之前不能被其他线程剥夺。

④循环等待条件:若干个进程或线程形成一种头尾相接的循环等待资源的关系。

避免死锁:

①破坏互斥条件 :这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。

②破坏请求与保持条件 :一次性申请所有的资源

③破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。

④破坏循环等待条件 :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。(银行家算法)

排查死锁问题:

(1)jps查看正在运行的java进程,找到进程pid,jstack pid 打印线程堆栈信息,会有“Found one Java-level deadlock”,表示程序中发现了一个死锁

(2)使用Jconsole,打开jconsole,连接我们的程序,查看线程堆栈信息,点击检测死锁,会看到详细的死锁信息。

(3)使用Jvisualvm,打开visualvm,点击我们的程序,切换到线程窗口,会检测到死锁情况,点击线程dump,查看线程堆栈快照,与jstack看的一样。

9、CAS原理?

CAS(Compare And Swap)比较并交换,基于冲突与检测的原子操作的乐观并发策略,先进行操作,如果没有冲突出现,就操作成功,如果有冲突,则再进行重试,直到没有冲突。这种策略不会把线程阻塞挂起,所以也叫非阻塞同步,无锁编程

CAS底层是需要硬件指令集支持的(如x86中的cmpxchg指令),因为比较与交换必须具有原子性

CAS指令需要3个操作数,分别是内存值V,旧的预期值A,准备设置的新值B,当且仅当V符合A时,处理器才会用B更新V的值,否则,就不更新

在JDK9之前,只有Java类库可以使用CAS,比如JUC包下的整数原子类,其中的compareAndSet()和getAndIncrement()等方法都使用了Unsafe类的CAS操作来实现,如果用户也有使用需求,要么就使用反射机制突破Unsafe的访问限制,要么就通过java类库间接使用它。

JDK9之后,在VarHandle类里才开放面向用户程序使用的CAS操作。

10、CAS的优缺点?

优点:没有线程阻塞下实现多线程之间的变量同步,无锁编程,高效。

缺点:

①ABA问题:CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。

ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。

②循环时间长开销大:CAS操作如果长时间不成功,会一致自旋,给CPU带来非常大的开销。

③只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。 

 

11、说说线程池原理?

线程池由任务队列和工作线程组成,它可以用重用线程来避免线程创建的开销,在任务过多时,通过阻塞队列避免创建过多的线程来减少系统资源的消耗与竞争

核心实现是ThreadPoolExecutor类,这个类维护了一组Worker对象(封装Thread对象),同时,定义了核心线程数量corePoolSize属性,最大线程数量maximumPoolSize,及等待队列workQueue等属性,来达到对线程的重用与管理。

核心逻辑的处理方法是execute方法,在提交一个任务到线程池时,会执行以下判断:

①如果池里运行的线程少于 corePoolSize,则创建新线程来处理任务,即使线程池中的其他线程是空闲的;

②如果线程池中的线程数量大于等于 corePoolSize 且小于 maximumPoolSize,阻塞队列未满,则把任务添加到阻塞队列,只有当workQueue满时才创建新的线程去处理任务;

③如果运行的线程数量大于等于maximumPoolSize,这时如果workQueue已经满了,则通过handler所指定的策略来处理任务

12、使用线程池的好处?

(1)降低资源消耗:通过重复利用已创建的线程 降低 线程的创建与消耗造成的资源消耗。

(2)提高响应速度:当任务到达时,不需要等线程创建就可以执行。

(3)提高线程的可管理性:使用线程池可以对线程进行统一分配,调优与监控。

13、execute()与submit()方法有什么区别?

①execute方法是Executor接口定义的,是线程池核心方法,submit方法是继承Executor接口的ExecuteService里的方法,submit方法会把Runnable或者Callable包装成RunnableFuture,再传入调用execute方法

execute方法没有返回值,而submit可以从返回的Future对象里调用get获取返回值或者异常信息

14、线程池参数?

①corePoolSize:核心线程数量

②maximumPoolSize:最大线程数量

③workQueue:等待队列,用于保存等待执行任务的阻塞队列,当任务提交时,如果线程池中的线程数大于等于corePoolSize时且阻塞队列未满,会把任务封装成一个Worker对象放入等待队列。

④keepAliveTime:线程池维护的线程所允许的空闲时间。当线程池中的线程数量大于了corePoolSize的时候,如果这时没有新的任务提交,非核心线程不会立即消耗,而是会等待,直到时间超过keepAliveTime。

⑤TimeUnit:空闲时间的单位。

⑥threadFactory:创建线程的工厂,用来创建新的线程。默认使用Executors.defaultThreadFactory()来创建线程。

⑦handler:RejectedExecutionHandler类型变量,线程池饱和的拒绝策略,如果阻塞队列满了且线程池数目达到最大线程数,这时再提交的任务就会采取拒绝策略,线程池提供了4种策略:

A.AbortPolicy:直接抛出异常,默认策略。

B.CallerRunsPolicy:用调用者所在线程来执行任务

C.DiscardOldestPolicy:丢弃掉阻塞队列最前的任务,并执行当前任务

D.DiscardPolicy:直接丢弃任务

也可以自己实现RejectedExecutionHandler接口重写rejectedExecution方法。

15、几种常见的线程池?

Executors类提供了几个方法来创建线程池。

①FixedThreadPool:固定线程数的,把核心线程数与最大线程数都设置为传入的固定值,使用无界阻塞队列LinkedBlockingQueue。最大线程数将失效,运行中的FixedThreadPool不会拒绝任务,容易导致OOM

②SingleThreadExecutor:只有一个核心线程的线程池,最大线程数也是1,使用无界阻塞队列LinkedBlockingQueue,也不会拒绝任务,容易导致OOM

③CachedThreadPool:核心线程数为0,最大线程数为Integer.MAX_VALUE,使用SychronousQueue,同步阻塞队列,如果主线程提供任务的速度高于线程池中线程处理任务的速度,线程池将不断的创建新的线程,会导致OOM

④ScheduledThreadPoolExecutor:使用传入的核心线程数,最大线程数为Integer.MAX_VALUE,使用延迟队列DelayedWorkQueue,封装了一个 PriorityQueue,PriorityQueue 会对队列中的任务进行排序,执行所需时间短的放在前面先被执行(ScheduledFutureTask 的 time 变量小的先执行),如果执行所需时间相同则先提交的任务将被先执行(ScheduledFutureTask 的 squenceNumber 变量小的先执行)。

说明:Executors各个方法的弊端:

1)newFixedThreadPool和newSingleThreadExecutor:

  主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至OOM。

2)newCachedThreadPool和newScheduledThreadPool:

  主要问题是线程数最大数是Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至OOM。

 

16、线程池有哪几种工作队列?

无界队列:队列大小无限制,其实是Integer.MAX_VALUE,常用的是LinkedBlockingQueue,当任务耗时较长时可能会导致大量新任务在队列中堆积最终导致OOM。

有界队列:队列大小有限,一类是遵循FIFO的ArrayBlockingQueue,一类是优先队列的PriorityBlockingQueue,任务优先级由Comparator指定。使用有界队列时队列大小需和线程池大小互相配合,线程池较小有界队列较大时可减少内存消耗,降低cpu使用率和上下文切换,但是可能会限制系统吞吐量。

同步队列:SynchronousQueue队列,不是一个真正的队列,而是一种线程之间移交的机制。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态。只有在使用无界线程池或者有饱和策略时才建议使用该队列。

 

17、线程池的拒绝策略?

线程池饱和的拒绝策略,如果阻塞队列满了且线程池数目达到最大线程数,这时再提交的任务就会采取拒绝策略,线程池提供了4种策略:

①AbortPolicy:直接抛出异常,默认策略。

②CallerRunsPolicy:用调用者所在线程来执行任务。

③DiscardOldestPolicy:丢弃掉阻塞队列最前的任务,并执行当前任务。

④DiscardPolicy:直接丢弃任务。

也可以自己实现RejectedExecutionHandler接口重写rejectedExecution方法。

 

18、线程池的异常如何处理?

线程池真正执行任务是在ThreadPoolExecutor,runWorker方法里,这个方法对任务执行进行try-catch处理,捕获包括Error在内的所有异常,在finally把出现过的异常和当前任务传递给afterExecute方法,而ThreadPoolExecutor里的afterExecute方法里是空方法。

这样做,够保证我们提交的任务抛出了异常不会影响其他任务的执行,同时也不会对用来执行该任务的线程产生任何影响。

但是,如果我们的任务抛出了异常,我们也无法立刻感知到。即使感知到了,也无法查看异常信息。

如何避免?

①在提交的任务中将异常捕获并处理,不抛给线程池。

②异常抛给线程池,但是由我们及时处理异常。

针对①,就是对业务逻辑代码都try-catch包围,不把异常抛出,这种方式有2个缺点,所有不同任务类型都要try-catch,不存在受检异常的地方也要try-catch处理,代码量多且不优雅。

针对②有3种实现方法:

(1)自定义线程池,继承ThreadPoolExecutor并重写afterExecute方法

(2)实例化线程池时,传入自己的ThreadFactory,设置Thread.UncaughtExceptionHandler处理未受检异常

(3)使用submit方法提交任务,该方法将返回一个Future对象,所有的异常以及处理结果都可以通过future对象获取

19、线程池有哪几种状态?

ctl是对线程池的运行状态(runState)和线程池中有效线程的数量进行控制(workerCount)的一个字段, 它包含两部分的信息: 线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount),这里可以看到,使用了Integer类型来保存,高3位保存runState,低29位保存workerCount。

下面再介绍下线程池的运行状态. 线程池一共有五种状态, 分别是:

①RUNNING :能接受新提交的任务,并且也能处理阻塞队列中的任务;

②SHUTDOWN:关闭状态,不再接受新提交的任务,但却可以继续处理阻塞队列中已保存的任务。在线程池处于 RUNNING 状态时,调用 shutdown()方法会使线程池进入到该状态。(finalize() 方法在执行过程中也会调用shutdown()方法进入该状态);

③STOP:不能接受新任务,也不处理队列中的任务,会中断正在处理任务的线程。在线程池处于 RUNNING 或 SHUTDOWN 状态时,调用 shutdownNow() 方法会使线程池进入到该状态;

④TIDYING:如果所有的任务都已终止了,workerCount (有效线程数) 为0,线程池进入该状态后会调用 terminated() 方法进入TERMINATED 状态。

⑤TERMINATED:在terminated() 方法执行完后进入该状态,默认terminated()方法中什么也没有做。进入TERMINATED的条件如下:

A.线程池不是RUNNING状态;

B.线程池状态不是TIDYING状态或TERMINATED状态;

C.如果线程池状态是SHUTDOWN并且workerQueue为空;

D.workerCount为0;

E.设置TIDYING状态成功。

20、线程池大小怎么确定?

实践:

线程池负载关注的核心问题是:基于当前线程池参数分配的资源够不够。对于这个问题,我们可以从事前和事中两个角度来看。事前,线程池定义了“活跃度”这个概念,来让用户在发生Reject异常之前能够感知线程池负载问题,线程池活跃度计算公式为:

线程池活跃度 = activeCount/maximumPoolSize。

这个公式代表当活跃线程数趋向于maximumPoolSize的时候,代表线程负载趋高。事中,也可以从两方面来看线程池的过载判定条件,一个是发生了Reject异常,一个是队列中有等待任务(支持定制阈值)。以上两种情况发生了都会触发告警。

CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。

I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。

如何判断是 CPU 密集任务还是 IO 密集任务?

CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。

凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。

21、synchronized关键字原理?

synchronized关键字解决的是多个线程之间访问资源的同步性(通过Monitor监视器来实现),它可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。通过Monitor监视器实现。

每个对象都拥有自己的监视器(其中monitor的本质是依赖于底层操作系统的Mutex Lock实现),当这个对象由同步块或者这个对象的同步方法调用时,执行方法的线程必须先获取该对象的监视器才能进入同步块和同步方法,如果没有获取到监视器的线程将会被阻塞在同步块和同步方法的入口处,进入到BLOCKED状态。

synchronized修饰代码块时,使用javap -c -s  -v -l xxx.class 反编译后,

synchronized关键字经过编译后,会在同步块的前后分别形成monitorenter(指令指向同步代码块开始的位置)和monitorexit(同步代码块的结束位置)两个字节码指令,每条monitorenter指令都必须执行其对应的monitorexit指令,同时,为了保证方法异常完成时这两条指令依然能正确执行,编译器会自动产生一个异常处理器,其目的就是用来执行monitorexit指令。

synchronized修饰方法时,反编译发现flags字段有个ACC_SYCHRONIZED访问标志

方法级同步没有通过字节码指令来控制,它实现在方法调用和返回操作之中。当方法调用时,调用指令会检查方法ACC_SYNCHRONIZED访问标志是否被设置,若设置了则执行线程需要持有监视器(Monitor)才能运行方法,当方法完成(无论是否出现异常)时释放监视器。

 

22、Monitor是什么?它怎么做到?

Monitor是线程私有的数据结构,每个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。其结构如下:

Owner:初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL

EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程

RcThis:表示blocked或waiting在该monitor record上的所有线程的个数

Nest:用来实现重入锁的计数

HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)

Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁。(避免羊群效应)

 

23、synchronized与lock区别?

① 两者都是可重入锁

两者都是可重入锁。“可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。

② synchronized 依赖于 Monitor 而 ReentrantLock 依赖于 AQS

synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。

ReentantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。

③ ReentrantLock 比 synchronized 增加了一些高级功能

a.等待可中断:ReentrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。

b.可实现公平锁ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。 ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。

c.可实现选择性通知(锁可以绑定多个条件)

synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制,ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition() 方法。Condition是JDK1.5之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。

在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是Condition接口默认提供的。

而synchronized关键字就相当于整个Lock对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition实例的signalAll()方法 只会唤醒注册在该Condition实例中的所有等待线程。

 

24、synchronized与volatile关键字区别?

(1)volatile关键字是线程同步的轻量级实现,但是volatile关键字只能用于变量而synchronized关键字可以修饰方法以及代码块。

(2)多线程访问volatile关键字不会发生阻塞,而synchronized关键字可能会发生阻塞。

(3)volatile关键字能保证数据的可见性,但不能保证数据的原子性。synchronized关键字两者都能保证。

volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized关键字解决的是多个线程之间访问资源的同步性。

 

25、AQS原理?

AQS (AbstractQueueSynchronizer)是一个用来构建锁和同步器的框架,提供了原子式管理同步状态、阻塞和唤醒线程功能以及队列模型,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 ReentrantLock,Semaphore,其他的诸如 ReentrantReadWriteLock,SynchronousQueue,FutureTask(jdk1.7) 等等皆是基于 AQS 的。

AQS的核心思想是:如果被请求的资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源锁定

如果被请求的资源被占用,就需要一套线程阻塞等待与被唤醒时锁分配的机制。这个机制就是用CLH队列(Carig.Landin And Hagersten)实现的,即将暂时获取不到锁的线程加入到队列

AQS将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。AQS使用一个Volatile的int类型的成员变量state来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作,通过CAS完成对State值的修改

26、AQS的应用?

同步工具

与AQS关联

ReentrantLock

使用AQS保存锁重复持有的次数。当一个线程获取锁时,ReentrantLock记录当前获得锁的线程标识,用于检测是否重复获取,以及错误线程试图解锁操作时异常情况的处理。

Semaphore

使用AQS同步状态来保存信号量的当前计数。tryRelease会增加计数,acquireShared会减少计数。

CountDownLatch

使用AQS同步状态来表示计数。计数为0时,所有的Acquire操作(CountDownLatch的await方法)才可以通过。

ReetrantReadWriteLock

使用AQS同步状态中的16位保存写锁持有的次数,剩下的16位用于保存读锁的持有次数。

ThreadPoolExecutor

Worker利用AQS同步状态实现对独占线程变量的设置(tryAcquire和tryRelease)

Semaphore(信号量)-允许多个线程同时访问: synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。

Semaphore与CountDownLatch一样,也是共享锁的一种实现。它默认构造AQS的state为permits。当执行任务的线程数量超出permits,那么多余的线程将会被放入阻塞队列Park,并自旋判断state是否大于0。只有当state大于0的时候,阻塞的线程才能继续执行,此时先前执行任务的线程继续执行release方法,release方法使得state的变量会加1,那么自旋的线程便会判断成功。 如此,每次只有最多不超过permits数量的线程能自旋成功,便限制了执行任务线程的数量。

CountDownLatch (倒计时器): CountDownLatch是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。

CountDownLatch是共享锁的一种实现,它默认构造 AQS 的 state 值为 count。当线程使用countDown方法时,其实使用了tryReleaseShared方法以CAS的操作来减少state,直至state为0就代表所有的线程都调用了countDown方法。当调用await方法的时候,如果state不为0,就代表仍然有线程没有调用countDown方法,那么就把已经调用过countDown的线程都放入阻塞队列Park,并自旋CAS判断state == 0,直至最后一个线程调用了countDown,使得state == 0,于是阻塞的线程便判断成功,全部往下执行。

CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。

CyclicBarrier(循环栅栏): CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。CyclicBarrier默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用await()方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。

对于 CountDownLatch 来说,重点是“一个线程(多个线程)等待”,而其他的 N 个线程在完成“某件事情”之后,可以终止,也可以等待。而对于 CyclicBarrier,重点是多个线程,在任意一个线程没有完成,所有的线程都必须等待。

CountDownLatch 是计数器,线程完成一个记录一个,只不过计数不是递增而是递减,而 CyclicBarrier 更像是一个阀门,需要所有线程都到达,阀门才能打开,然后继续执行。

CountdownLatch适用于所有线程通过某一点后通知方法,而CyclicBarrier则适合让所有线程在同一点同时执行

CountdownLatch利用继承AQS的共享锁来进行线程的通知,利用CAS来进行--,而CyclicBarrier则利用ReentrantLock的Condition来阻塞和通知线程

28、说说ThreadLocal?内存泄露如何解决?父线程的线程本地变量如何传给子类线程?

对象已经没有被应用程序使用,但是垃圾回收器没办法移除它们,因为还在被引用着。
在Java中,内存泄漏就是存在一些被分配的对象,这些对象有下面两个特点,首先,这些对象是可达的,即在有向图中,存在通路可以与其相连;其次,这些对象是无用的,即程序以后不会再使用这些对象。如果对象满足这两个条件,这些对象就可以判定为Java中的内存泄漏,这些对象不会被GC所回收,然而它却占用内存。

如果threadLocal外部强引用被置为null(threadLocalInstance=null)的话,threadLocal实例就没有一条引用链路可达,很显然在gc(垃圾回收)的时候势必会被回收,因此entry就存在key为null的情况,无法通过一个Key为null去访问到该entry的value。同时,就存在了这样一条引用链:threadRef->currentThread->threadLocalMap->entry->valueRef->valueMemory,导致在垃圾回收的时候进行可达性分析的时候,value可达从而不会被回收掉,但是该value永远不能被访问到,这样就存在了内存泄漏。当然,如果线程执行结束后,threadLocal,threadRef会断掉,因此threadLocal,threadLocalMap,entry都会被回收掉。可是,在实际使用中我们都是会用线程池去维护我们的线程,比如在Executors.newFixedThreadPool()时创建
线程的时候,为了复用线程是不会结束的,所以threadLocal内存泄漏就值得我们关注。

 利用InheritableThreadLocal可以实现父线程的线程本地变量传给子类线程

29、说说Java的锁机制?种类?

在 Java 早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

 Java 6 之后 Java 官方对从 JVM 层面对synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

乐观锁:乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。

悲观锁:悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。

自旋锁:当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。

自适应自旋:自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

无锁:无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。

偏向锁:偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。

在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。

轻量级锁:锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

重量级锁:升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

综上,偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。

公平锁:多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。

非公平锁:多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

可重入锁:可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。

不可重入锁:任何线程不管是否已获得锁,都会先去获取锁,失败则阻塞。

排它锁:是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。

共享锁:锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据

30、JVM对锁做的优化有哪些?

(1)锁消除:指虚拟机即时编译器在运行时,对一些代码要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。主要判断依据来源于逃逸分析的数据支持。

(2)锁粗化:在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据 的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等 待锁的线程也能尽可能快地拿到锁。但是如果一系列的连续操作都对同一个对象反复加锁和 解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导 致不必要的性能损耗。如果虚拟机探测到有这样一串零碎的操作 都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。

31、Atomic原子类知道吗?大概说说?

 atomic 包下的原子操作类的也主要是通过 Unsafe 类提供的 compareAndSwapInt,compareAndSwapLong 等一系列提供 CAS 操作的方法来进行实现

32、关注wx公众号:青年泛,将持续输出面试系列及知识系列,一起bb啊

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值