目录
一、wait和notify这个为什么要在synchronized代码块中?
二、ThreadLocal是什么?它的实现原理呢?
三、基于数组的阻塞队列ArrayBlockingQueue原理
四、怎么理解线程安全?
五、请简述一下伪共享的概念以及如何避免
六、什么是可重入,什么是可重入锁?它用来解决什么问题?
七、ReentrantLock的实现原理
八、简述一下你对线程池的理解?
九、如何中断一个正在运行的线程?
十、为什么引入偏向锁、轻量级锁,介绍下升级流程
一、wait和notify这个为什么要在synchronized代码块中?
1. wait和notify用来实现多线程之间的协调,wait表示让线程进入到阻塞状态,notify表示让阻塞的线程唤醒。
2. wait和notify必然是成对出现的,如果一个线程被wait()方法阻塞,那么必然需要另外一个线程通过notify()方法来唤醒这个被阻塞的线程,从而实现多线程之间的通信。
3. (如图)在多线程里面,要实现多个线程之间的通信,除了管道流以外,只能通过共享变量的方法来实现,也就是线程t1修改共享变量s,线程t2获取修改后的共享变量s,从而完成数据通信。
但是多线程本身具有并行执行的特性,也就是在同一时刻,多个线程可以同时执行。在这种情况下,线程t2在访问共享变量s之前,必须要知道线程t1已经修改过了共享变量s,否则就需要等待。
同时,线程t1修改过了共享变量S之后,还需要通知在等待中的线程t2。
所以要在这种特性下要去实现线程之间的通信,就必须要有一个竞争条件控制线程在什么条件下等待,什么条件下唤醒。
4. 而Synchronized同步关键字就可以实现这样一个互斥条件,也就是在通过共享变量来实现多个线程通信的场景里面,参与通信的线程必须要竞争到这个共享变量的锁资源,才有资格对共享变量做修改,修改完成后就释放锁,那么其他的线程就可以再次来竞争同一个共享变量的锁来获取修改后的数据,从而完成线程之前的通。
5. 所以这也是为什么wait/notify需要放在Synchronized同步代码块中的原因,有了Synchronized同步锁,就可以实现对多个通信线程之间的互斥,实现条件等待和条件唤醒。
6. 另外,为了避免wait/notify的错误使用,jdk强制要求把wait/notify写在同步代码块里面,否则会抛出IllegalMonitorStateException。
7. 最后,基于wait/notify的特性,非常适合实现生产者消费者的模型,比如说用wait/notify来实现连接池就绪前的等待与就绪后的唤醒。
二、ThreadLocal是什么?它的实现原理呢?
从三个方面来回答:
- 1. ThreadLocal是一种线程隔离机制,它提供了多线程环境下对于共享变量访问的安全性。
- 2. 在多线程访问共享变量的场景中(出现下面第一个图),一般的解决办法是对共享变量加锁(出现下面第二个图),从而保证在同一时刻只有一个线程能够对共享变量进行更新,并且基于Happens-Before规则里面的监视器锁规则,又保证了数据修改后对其他线程的可见性。
3.但是加锁会带来性能的下降,所以ThreadLocal用了一种空间换时间的设计思想,也就是说在每个线程里面,都有一个容器来存储共享变量的副本,然后每个线程只对自己的变量副本来做更新操作,这样既解决了线程安全问题,又避免了多线程竞争加锁的开销。
4.ThreadLocal的具体实现原理是,在Thread类里面有一个成员变量ThreadLocalMap,它专门来存储当前线程的共享变量副本,后续这个线程对于共享变量的操作,都是从这个ThreadLocalMap里面进行变更,不会影响全局共享变量的值。
三、基于数组的阻塞队列ArrayBlockingQueue原理
1. (如图)阻塞队列(BlockingQueue)是在队列的基础上增加了两个附加操作:
a. 在队列为空的时候,获取元素的线程会等待队列变为非空。
b. 当队列满时,存储元素的线程会等待队列可用。
2. 由于阻塞队列的特性,可以非常容易实现生产者消费者模型,也就是生产者只需要关心数据的生产,消费者只需要关注数据的消费,所以如果队列满了,生产者就等待,同样,队列空了,消费者也需要等待。
3. 要实现这样的一个阻塞队列,需要用到两个关键的技术,队列元素的存储、以及线程阻塞和唤醒。
4. 而ArrayBlockingQueue是基于数组结构的阻塞队列,也就是队列元素是存储在一个数组结构里面,并且由于数组有长度限制,为了达到循环生产和循环消费的目的,ArrayBlockingQueue用到了循环数组。
5. 而线程的阻塞和唤醒,用到了J.U.C包里面的ReentrantLock和Condition。Condition相当于wait/notify在JUC包里面的实现。
四、怎么理解线程安全?
简单来说,在多个线程访问某个方法或者对象的时候,不管通过任何的方式调用以及线程如何去交替执行。
在程序中不做任何同步干预操作的情况下,这个方法或者对象的执行/修改都能按照预期的结果来反馈,那么这个类就是线程安全的。
实际上,线程安全问题的具体表现体现在三个方面:原子性、有序性、可见性。
原子性:指当一个线程执行一系列程序指令操作的时候,它应该是不可中断的,因为一旦出现中断,站在多线程的视角来看,这一系列的程序指令会出现前后执行结果不一致的问题。
这个和数据库里面的原子性是一样的,简单来说就是一段程序只能由一个线程完整的执行完成,而不能存在多个线程干扰。
(如图)CPU的上下文切换,是导致原子性问题的核心,而JVM里面提供了Synchronized关键字来解决原子性问题。
可见性:说在多线程环境下,由于读和写是发生在不同的线程里面,有可能出现某个线程对共享变量的修改,对其他线程不是实时可见的。
导致可见性问题的原因有很多,比如CPU的高速缓存、CPU的指令重排序、编译器的指令重排序。
有序性:指的是程序编写的指令顺序和最终CPU运行的指令顺序可能出现不一致的现象,这种现象也可以称为指令重排序,所以有序性也会导致可见性问题。
可见性和有序性可以通过JVM里面提供了一个Volatile关键字来解决。
导致有序性、原子性、可见性问题的本质,是计算机工程师为了最大化提升CPU利用率导致的。比如为了提升CPU利用率,设计了三级缓存、设计了StoreBuffer、设计了缓存行这种预读机制、在操作系统里面,设计了线程模型、在编译器里面,设计了编译器的深度优化机制。
五、请简述一下伪共享的概念以及如何避免
对于这个问题,要从几个方面来回答:
首先,计算机工程师为了提高CPU的利用率,平衡CPU和内存之间的速度差异,在CPU里面设计了三级缓存。
CPU在向内存发起IO操作的时候,一次性会读取64个字节的数据作为一个缓存行,缓存到CPU的高速缓存里面。
在Java中一个long类型是8个字节,意味着一个缓存行可以存储8个long类型的变量。
这个设计是基于空间局部性原理来实现的,也就是说,如果一个存储器的位置被引用,那么将来它附近的位置也会被引用。
所以缓存行的设计对于CPU来说,可以有效的减少和内存的交互次数,从而避免了CPU的IO等待,以提升CPU的利用率。
正是因为这种缓存行的设计,导致如果多个线程修改同一个缓存行里面的多个独立变量的时候,基于缓存一致性协议,就会无意中影响了彼此的性能,这就是伪共享的问题。
(如图)像这样一种情况,CPU0上运行的线程想要更新变量X、CPU1上的线程想要更新变量Y,而X/Y/Z都在同一个缓存行里面。
每个线程都需要去竞争缓存行的所有权对变量做更新,基于缓存一致性协议。
一旦运行在某个CPU上的线程获得了所有权并执行了修改,就会导致其他CPU中的缓存行失效。
这就是伪共享问题的原理。
因为伪共享会问题导致缓存锁的竞争,所以在并发场景中的程序执行效率一定会收到较大的影响。
这个问题的解决办法有两个:
1. 使用对齐填充,因为一个缓存行大小是64个字节,如果读取的目标数据小于64个字节,可以增加一些无意义的成员变量来填充。
2. 在Java8里面,提供了@Contented注解,它也是通过缓存行填充来解决伪共享问题的,被@Contented注解声明的类或者字段,会被加载到独立的缓存行上。
六、什么是可重入,什么是可重入锁?它用来解决什么问题?
可重入是多线程并发编程里面一个比较重要的概念,
简单来说,就是在运行的某个函数或者代码,因为抢占资源或者中断等原因导致函数或者代码的运行中断,等待中断程序执行结束后,重新进入到这个函数或者代码中运行,并且运行结果不会受到影响,那么这个函数或者代码就是可重入的。
(如图)而可重入锁,简单来说就是一个线程如果抢占到了互斥锁资源,在锁释放之前再去竞争同一把锁的时候,不需要等待,只需要记录重入次数。
在多线程并发编程里面,绝大部分锁都是可重入的,比如Synchronized、ReentrantLock等,但是也有不支持重入的锁,比如JDK8里面提供的读写锁StampedLock。
锁的可重入性,主要解决的问题是避免线程死锁的问题。
因为一个已经获得同步锁X的线程,在释放锁X之前再去竞争锁X的时候,相当于会出现自己要等待自己释放锁,这很显然是无法成立的。
七、ReentrantLock的实现原理
关于这个问题从这几个方面来回答:
1、什么是ReentrantLock
2、ReentrantLock的特性
3、ReentrantLock的实现原理
首先,ReentrantLock是一种可重入的排它锁,主要用来解决多线程对共享资源竞争的问题。
它的核心特性有几个:
1. 它支持可重入,也就是获得锁的线程在释放锁之前再次去竞争同一把锁的时候,不需要加锁就可以直接访问。
2. 它支持公平和非公平特性
3. 它提供了阻塞竞争锁和非阻塞竞争锁的两种方法,分别是lock()和tryLock()。
(如图)然后,ReentrantLock的底层实现有几个非常关键的技术。
4. 锁的竞争,ReentrantLock是通过互斥变量,使用CAS机制来实现的。
5. 没有竞争到锁的线程,使用了AbstractQueuedSynchronizer这样一个队列同步器来存储,底层是通过双向链表来实现的。当锁被释放之后,会从AQS队列里面的头部唤醒下一个等待锁的线程。
6. 公平和非公平的特性,主要是体现在竞争锁的时候,是否需要判断AQS队列存在等待中的线程。
7. 最后,关于锁的重入特性,在AQS里面有一个成员变量来保存当前获得锁的线程,当同一个线程下次再来竞争锁的时候,就不会去走锁竞争的逻辑,而是直接增加重入次数。
八、简述一下你对线程池的理解?
关于这个问题,从几个方面来回答:
首先,线程池本质上是一种池化技术,而池化技术是一种资源复用的思想,比较常见的有连接池、内存池、对象池。
而线程池里面复用的是线程资源,它的核心设计目标,有两个:
1. 减少线程的频繁创建和销毁带来的性能开销,因为线程创建会涉及到CPU上下文切换、内存分配等工作。
2. 线程池本身会有参数来控制线程创建的数量,这样就可以避免无休止的创建线程带来的资源利用率过高的问题,起到了资源保护的作用。
其次,简单说一下线程池里面的线程复用技术。因为线程本身并不是一个受控的技术,也就是说线程的生命周期时由任务运行的状态决定的,无法人为控制。
(图片)所以为了实现线程的复用,线程池里面用到了阻塞队列,简单来说就是线程池里面的工作线程处于一直运行状态,它会从阻塞队列中去获取待执行的任务,一旦队列空了,那这个工作线程就会被阻塞,直到下次有新的任务进来。
也就是说,工作线程是根据任务的情况实现阻塞和唤醒,从而达到线程复用的目的。最后,线程池里面的资源限制,是通过几个关键参数来控制的,分别是核心线程数、最大线程数。
核心线程数表示默认长期存在的工作线程,而最大线程数是根据任务的情况动态创建的线程,主要是提高阻塞队列中任务的处理效率。
九、如何中断一个正在运行的线程?
关于这个问题,从几个方面来回答:
(如图)首先,线程是系统级别的概念,在Java里面实现的线程,最终的执行和调度都是由操作系统来决定的,JVM只是对操作系统层面的线程做了一层包装而已。
所以我们在Java里面通过start方法启动一个线程的时候,只是告诉操作系统这个线程可以被执行,但是最终交给CPU来执行是操作系统的调度算法来决定的。
因此,理论上来说,要在Java层面去中断一个正在运行的线程,只能像类似于Linux里面的kill命令结束进程的方式一样,强制终止。
所以,Java Thread里面提供了一个stop方法可以强行终止,但是这种方式是不安全的,因为有可能线程的任务还没有,导致出现运行结果不正确的问题。
要想安全的中断一个正在运行的线程,只能在线程内部埋下一个钩子,外部程序通过这个钩子来触发线程的中断命令。
(如图)因此,在Java Thread里面提供了一个interrupt()方法,这个方法配合isInterrupted()方法使用,就可以实现安全的中断机制。
这种实现方法并不是强制中断,而是告诉正在运行的线程,你可以停止了,不过是否要中断,取决于正在运行的线程,所以它能够保证线程运行结果的安全性。
十、为什么引入偏向锁、轻量级锁,介绍下升级流程
比如:共享锁、排它锁、偏向锁、轻量级锁、自旋锁、重量级锁、间隙锁、临键锁、意向锁、读写锁、乐观锁、悲观锁、表锁、行锁。
高手
1.Synchronized在jdk1.6版本之前,是通过重量级锁的方式来实现线程之间锁的竞争。
之所以称它为重量级锁,是因为它的底层底层依赖操作系统的MutexLock来实现互斥功能。
(如图)Mutex是系统方法,由于权限隔离的关系,应用程序调用系统方法时需要切换到内核态来执行。
这里涉及到用户态向内核态的切换,这个切换会带来性能的损耗。
2.在jdk1.6版本中,synchronized增加了锁升级的机制,来平衡数据安全性和性能。简单来说,就是线程去访问synchronized同步代码块的时候,synchronized根据线程竞争情况,会先尝试在不加重量级锁的情况下去保证线程安全性。所以引入了偏向锁和轻量级锁的机制。
偏向锁,就是直接把当前锁偏向于某个线程,简单来说就是通过CAS修改偏向锁标记,这种锁适合同一个线程多次去申请同一个锁资源并且没有其他线程竞争的场景。
轻量级锁也可以称为自旋锁,基于自适应自旋的机制,通过多次自旋重试去竞争锁。自旋锁优点在于它避免避免了用户态到内核态的切换带来的性能开销。
3.(如图)Synchronized引入了锁升级的机制之后,如果有线程去竞争锁:
首先,synchronized会尝试使用偏向锁的方式去竞争锁资源,如果能够竞争到偏向锁,表示加锁成功直接返回。如果竞争锁失败,说明当前锁已经偏向了其他线程。
需要将锁升级到轻量级锁,在轻量级锁状态下,竞争锁的线程根据自适应自旋次数去尝试抢占锁资源,如果在轻量级锁状态下还是没有竞争到锁,就只能升级到重量级锁,在重量级锁状态下,没有竞争到锁的线程就会被阻塞,线程状态是Blocked。
处于锁等待状态的线程需要等待获得锁的线程来触发唤醒。
总的来说,Synchronized的锁升级的设计思想,本质上是一种性能和安全性的平衡,也就是如何在不加锁的情况下能够保证线程安全性。
这种思想在编程领域比较常见,比如Mysql里面的MVCC使用版本链的方式来解决多个并行事务的竞争问题。