Java并发进阶面试
<h2>1. Synchronized关键字</h2>
<h3>1.1 synchronized的理解</h3>
<p>synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。</p>
<h3>1.2 synchronized的使用方式</h3>
<p>synchronized关键字最主要的三种使用方式:修饰实例方法、修饰静态方法、修饰代码块。
synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁。synchronized 关键字加到实例方法上是给对象实例上锁。尽量不要使用 synchronized(String a) 因为JVM中,字符串常量池具有缓存功能!</p>
<h3>1.3 synchronized的底层原理</h3>
<pre><code>synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor的持有权。
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。</code></pre>
<h3>1.4 JDK1.6 之后的synchronized 关键字底层做了哪些优化</h3>
<p>JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。</p>
<p>锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。</p>
<ul>
<li>偏向锁:偏向锁会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的。如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。</li>
<li>轻量级锁:轻量级锁是由偏向所升级来的,轻量级锁不是为了代替重量级锁,它的加锁和解锁都用到了CAS操作;在锁竞争很小的情况下,减少重量级锁使用操作系统互斥量产生的性能消耗,在竞争激烈的情况下,轻量级锁会膨胀为重量级锁。</li>
<li>自旋锁:如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(忙循环、自旋),等持有锁的线程释放锁后即可立即获取锁,避免用户态和内核态切换的消耗。自旋锁默认不开启,需要设置--XX:+UseSpinning参数来开启。</li>
<li>锁消除:指虚拟机即使编译器在运行时,如果检测到那些共享数据不可能存在竞争,那么就执行锁消除。锁消除可以节省毫无意义的请求锁的时间。</li>
</ul>
<h3>1.5 synchronized和ReentrantLock 的区别</h3>
<ol>
<li>
<p>两者都是可重入锁
两者都是可重入锁。“可重入锁”概念是:自己可以再次获取自己的内部锁。</p>
</li>
<li>
<p>synchronized 依赖于 JVM 而 ReenTrantLock 依赖于 API
synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReenTrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。</p>
</li>
<li>
<p>ReenTrantLock 比 synchronized 增加了一些高级功能
相比synchronized,ReenTrantLock增加了一些高级功能。主要来说主要有三点:①等待可中断;②可实现公平锁;③可实现选择性通知(锁可以绑定多个条件)</p>
</li>
<li>性能已不是选择标准
JDK1.6 之后,synchronized 和 ReenTrantLock 的性能基本是持平了。而且虚拟机在未来的性能改进中会更偏向于原生的synchronized,所以还是提倡在synchronized能满足你的需求的情况下,优先考虑使用synchronized关键字来进行同步!</li>
</ol>
<h2>2. volatile关键字</h2>
<p>请移步<a href="https://www.showdoc.cc/439784909434024?page_id=3639842747464033">volatile关键字</a></p>
<h2>3. ThreadLocal</h2>
<p>请移步<a href="https://www.showdoc.cc/439784909434024?page_id=3695666250150035">ThreadLocal</a></p>
<h2>4. 线程池</h2>
<h3>4.1 为什么要使用线程池</h3>
<blockquote>
<p>池化技术相比大家已经屡见不鲜了,线程池、数据库连接池、Http 连接池、常量池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。</p>
</blockquote>
<p>线程池提供了一种限制和管理资源(包括执行一个任务)。每个线程池还维护一些基本统计信息,例如已完成任务的数量。</p>
<p>这里借用《Java 并发编程的艺术》提到的来说一下使用线程池的好处:</p>
<p>降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。</p>
<h3>4.2 实现Runnable接口和Callable接口的区别</h3>
<p>Runnable自Java 1.0以来一直存在,但Callable仅在Java 1.5中引入,目的就是为了来处理Runnable不支持的用例。Runnable 接口不会返回结果或抛出检查异常,但是<strong>Callable 接口</strong>可以。所以,如果任务不需要返回结果或抛出异常推荐使用 Runnable 接口,这样代码看起来会更加简洁。</p>
<pre><code>1. Runnable.java
@FunctionalInterface
public interface Runnable {
/**
* 被线程执行,没有返回值也无法抛出异常
*/
public abstract void run();
}
2. Callable.java
@FunctionalInterface
public interface Callable<V> {
/**
* 计算结果,或在无法这样做时抛出异常。
* @return 计算得出的结果
* @throws 如果无法计算结果,则抛出异常
*/
V call() throws Exception;
}
</code></pre>
<h3>4.3 执行execute()方法和submit()方法的区别是什么呢?</h3>
<ol>
<li>execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;</li>
<li>submit()方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功,并且可以通过 Future 的 get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。</li>
</ol>
<p>我们以<strong>AbstractExecutorService</strong>接口中的一个 submit 方法为例子来看看源代码:</p>
<pre><code>public Future<?> submit(Runnable task) {
if (task == null) throw new NullPointerException();
RunnableFuture<Void> ftask = newTaskFor(task, null);
execute(ftask);
return ftask;
}</code></pre>
<p>上面方法调用的 newTaskFor 方法返回了一个 FutureTask 对象。</p>
<pre><code>protected <T> RunnableFuture<T> newTaskFor(Runnable runnable, T value) {
return new FutureTask<T>(runnable, value);
}</code></pre>
<p>我们再来看看execute()方法:</p>
<pre><code>public void execute(Runnable command) {
...
}</code></pre>
<h3>4.4 如何创建线程池</h3>
<p>《阿里巴巴Java开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险</p>
<blockquote>
<p>Executors 返回线程池对象的弊端如下:</p>
<ul>
<li>FixedThreadPool 和 SingleThreadExecutor :允许请求的队列长度为 Integer.MAX_VALUE ,可能堆积大量的请求,从而导致OOM。</li>
<li>CachedThreadPool 和 ScheduledThreadPool :允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致OOM。</li>
</ul>
</blockquote>
<ol>
<li>方式一:通过ThreadPoolExecutor构造方法实现,推荐用该方法。</li>
<li>方式二:通过Executor 框架的工具类Executors来实现我们可以创建三种类型的ThreadPoolExecutor:
2.1 FixedThreadPool :该方法返回一个固定线程数量的线程池。
2.2 SingleThreadExecutor: 方法返回一个只有一个线程的线程池。
2.3 CachedThreadPool: 该方法返回一个可根据实际情况调整线程数量的线程池。
这三种方法内部实际上都是调用ThreadPoolExecutor构造方法。</li>
</ol>
<h3>4.5 ThreadPoolExecutor 类分析</h3>
<p>详情请转至<a href="https://www.showdoc.cc/439784909434024?page_id=3695520347955026">juc 5.1节ThreadPoolExecutor类</a> 查看</p>
<h3>4.6 线程池原理分析</h3>
<p>详情请转至<a href="https://www.showdoc.cc/439784909434024?page_id=3695520347955026">juc 5.2节深入剖析线程池实现原理</a> 查看</p>
<p>这里也可以放一份execute()方法的源码查看</p>
<pre><code> // 存放线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount)
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static int workerCountOf(int c) {
return c & CAPACITY;
}
private final BlockingQueue<Runnable> workQueue;
public void execute(Runnable command) {
// 如果任务为null,则抛出异常。
if (command == null)
throw new NullPointerException();
// ctl 中保存的线程池当前的一些状态信息
int c = ctl.get();
// 下面会涉及到 3 步 操作
// 1.首先判断当前线程池中之行的任务数量是否小于 corePoolSize
// 如果小于的话,通过addWorker(command, true)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
// 2.如果当前之行的任务数量大于等于 corePoolSize 的时候就会走到这里
// 通过 isRunning 方法判断线程池状态,线程池处于 RUNNING 状态才会被并且队列可以加入任务,该任务才会被加入进去
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
// 再次获取线程池状态,如果线程池状态不是 RUNNING 状态就需要从任务队列中移除任务,并尝试判断线程是否全部执行完毕。同时执行拒绝策略。
if (!isRunning(recheck) && remove(command))
reject(command);
// 如果当前线程池为空就新创建一个线程并执行。
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
//3. 通过addWorker(command, false)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。
//如果addWorker(command, false)执行失败,则通过reject()执行相应的拒绝策略的内容。
else if (!addWorker(command, false))
reject(command);
}</code></pre>
<h2>5 Atomic</h2>
<p>详情请转至<a href="https://www.showdoc.cc/439784909434024?page_id=3695520347955026">juc 2 原子操作类</a></p>
<h2>6 AQS</h2>
<h3>6.1 AQS介绍</h3>
<p>AQS的全称为(AbstractQueuedSynchronizer),这个类在java.util.concurrent.locks包下面</p>
<p>AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。当然,我们自己也能利用AQS非常轻松容易地构造出符合我们自己需求的同步器。</p>
<h3>6.2 AQS原理</h3>
<h4>6.2.1 AQS原理概览</h4>
<p>AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。</p>
<blockquote>
<p>CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。</p>
</blockquote>
<p>AQS原理图
<img src="https://s2.ax1x.com/2020/03/08/3xUmrR.png" alt="AQS原理图" />
AQS使用一个int成员变量 state 来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改。</p>
<h4>6.2.2 AQS对资源的共享方式</h4>
<p><strong>AQS定义两种资源共享方式</strong></p>
<ul>
<li>Exclusive(独占):只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁:
<ol>
<li>公平锁:按照线程在队列中的排队顺序,先到者先拿到锁</li>
<li>非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的</li>
</ol></li>
<li>Share(共享):多个线程可同时执行,如Semaphore/CountDownLatch。Semaphore、CountDownLatch、 CyclicBarrier、ReadWriteLock 我们都会在后面讲到。</li>
</ul>
<p>ReentrantReadWriteLock 可以看成是组合式,因为ReentrantReadWriteLock也就是读写锁允许多个线程同时对某一资源进行读。</p>
<p>不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。</p>
<h4>6.2.3 自定义同步器AQS</h4>
<p>同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):</p>
<ol>
<li>使用者继承AbstractQueuedSynchronizer并重写指定的方法。(这些重写方法很简单,无非是对于共享资源state的获取和释放)</li>
<li>将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。</li>
</ol>
<p><strong>AQS使用了模板方法模式,自定义同步器时需要重写下面几个AQS提供的模板方法:</strong></p>
<pre><code>isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。</code></pre>
<p>默认情况下,每个方法都抛出 UnsupportedOperationException。这些方法的实现必须是内部线程安全的,并且通常应该简短而不是阻塞。AQS类中的其他方法都是final ,所以无法被其他类使用,只有这几个方法可以被其他类使用。</p>
<p>以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。</p>
<p>再以CountDownLatch以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS(Compare and Swap)减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作。</p>
<p>一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。</p>
<h4>6.2.4 公平锁和非公平锁的实现</h4>
<p>state变量,初始值为0,假设当前线程为A,每当A获取一次锁,status++. 释放一次,status--.锁会记录当前持有的线程。
当A线程拥有锁的时候,status>0. B线程尝试获取锁的时候会对这个status有一个CAS(0,1)的操作,尝试几次失败后就挂起线程,进入一个等待队列。
如果A线程恰好释放,--status==0, A线程会去唤醒等待队列中第一个线程,即刚刚进入等待队列的B线程,B线程被唤醒之后回去检查这个status的值,尝试CAS(0,1),而如果这时恰好C线程也尝试去争抢这把锁</p>
<ul>
<li>
<p>非公平锁实现:
C直接尝试对这个status CAS(0,1)操作,并成功改变了status的值,B线程获取锁失败,再次挂起,这就是非公平锁,B在C之前尝试获取锁,而最终是C抢到了锁。</p>
</li>
<li>公平锁实现:
C发现有线程在等待队列,直接将自己进入等待队列并挂起,B获取锁。</li>
</ul>
<h3>6.3 AQS组件总结</h3>
<ul>
<li>Semaphore(信号量)-允许多个线程同时访问: synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。</li>
<li>CountDownLatch (倒计时器): CountDownLatch是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。</li>
<li>CyclicBarrier(循环栅栏): CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。CyclicBarrier默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用await()方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。</li>
</ul>