第二节 Java 并发面试题
一、线程池相关 ( ⭐⭐⭐ )
1、什么是线程池,如何使用?为什么要使用线程池?
答:线程池就是事先将多个线程对象放到一个容器中,使用的时候就不用 new
线程而是直接去池中拿线程即可,节 省了开辟子线程的时间,提高了代码执行
效率。
2、Java 中的线程池共有几种?
Java 有四种线程池:
第一种: newCachedThreadPool
不固定线程数量,且支持最大为 Integer.MAX_VALUE 的线程数量 :
public static ExecutorService newCachedThreadPool() {
// 这个线程池 corePoolSize 为 0 , maximumPoolSize 为 Integer.MAX_VALUE
// 意思也就是说来一个任务就创建一个 woker ,回收时间是 60s
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue());
}
可缓存线程池:
1 、线程数无限制。 2 、有空闲线程则复用空闲线程,若无空闲线程则新建线程。
3 、一定程序减少频繁创建 / 销毁线程,减少系统开销。
第二种: newFixedThreadPool 一个固定线程数量的线程池 :
public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory
threadFactory) {
// corePoolSize 跟 maximumPoolSize 值一样,同时传入一个无界阻塞队列
// 该线程池的线程会维持在指定线程数,不会进行回收
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue(),
threadFactory);
}
定长线程池:
1 、可控制线程最大并发数(同时执行的线程数)。 2 、超出的线程会在队列中
等待。
第三种: newSingleThreadExecutor
可以理解为线程数量为 1 的 FixedThreadPool:
public static ExecutorService newSingleThreadExecutor() {
// 线程池中只有一个线程进行任务执行,其他的都放入阻塞队列
// 外面包装的 FinalizableDelegatedExecutorService 类实现了 finalize 方法,在
JVM 垃圾回收的时候会关闭线程池
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue()));
} 单线程化的线程池:
1 、有且仅有一个工作线程执行任务。 2 、所有任务按照指定顺序执行,即遵循
队列的入队出队规则。
第四种: newScheduledThreadPool 。
支持定时以指定周期循环执行任务 :
public static ScheduledExecutorService newScheduledThreadPool(int
corePoolSize) {
return new ScheduledThreadPoolExecutor(corePoolSize);
}
注意:前三种线程池是 ThreadPoolExecutor 不同配置的实例,最后一种是
ScheduledThreadPoolExecutor 的实例。
3、 线程池原理?
从数据结构的角度来看,线程池主要使用了阻塞队列( BlockingQueue )和
HashSet 集合构成。 从任务提交的流程角度来看,对于使用线程池的外部来说,
线程池的机制是这样的:
1 、如果正在运行的线程数 < coreSize ,马上创建核心线程执行该 task ,不排队等待;
2 、如果正在运行的线程数 >= coreSize ,把该 task 放入阻塞队列;
3 、如果队列已满 && 正在运行的线程数 < maximumPoolSize ,创建新的非核心线程执行该
task ;
4 、如果队列已满 && 正在运行的线程数 >= maximumPoolSize ,线程池调用 handler 的 reject
方法拒绝本次提交。
理解记忆: 1-2-3-4 对应(核心线程 -> 阻塞队列 -> 非核心线程 ->handler 拒绝提
交)。
线程池的线程复用: 这里就需要深入到源码 addWorker() :它是创建新线程的关键,也是线程复用的
关键入口。最终会执行到 runWoker ,它取任务有两个方式:
firstTask :这是指定的第一个 runnable 可执行任务,它会在 Woker 这个
工作线程中运行执行任务 run 。并且置空表示这个任务已经被执行。
getTask() :这首先是一个死循环过程,工作线程循环直到能够取出
Runnable 对象或超时返回,这里的取的目标就是任务队列 workQueue ,
对应刚才入队的操作,有入有出。
其实就是任务在并不只执行创建时指定的 firstTask 第一任务,还会从任务队列
的中通过 getTask() 方法自己主动去取任务执行,而且是有 / 无时间限定的阻塞等
待,保证线程的存活。
信号量
semaphore 可用于进程间同步也可用于同一个进程间的线程同步。
可以用来保证两个或多个关键代码段不被并发调用。在进入一个关键代码段之
前,线程必须获取一个信号量;一旦该关键代码段完成了,那么该线程必须释放
信号量。其它想进入该关键代码段的线程必须等待直到第一个线程释放信号量。
4、线程池都有哪几种工作队列?
1 、 ArrayBlockingQueue
是一个基于数组结构的有界阻塞队列,此队列按 FIFO (先进先出)原则对元素
进行排序。
2 、 LinkedBlockingQueue
一个基于链表结构的阻塞队列,此队列按 FIFO (先进先出) 排序元素,吞吐
量通常要高于 ArrayBlockingQueue 。静态工厂方法 Executors.newFixedThreadPool() 和 Executors.newSingleThreadExecutor 使用了
这个队列。
3 、 SynchronousQueue
一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,
否则插入操作一直处于阻塞状态,吞吐量通常要高于 LinkedBlockingQueue ,静
态工厂方法 Executors.newCachedThreadPool 使用了这个队列。
4 、 PriorityBlockingQueue
一个具有优先级的无限阻塞队列。
5、怎么理解无界队列和有界队列?
有界队列
1. 初始的 poolSize < corePoolSize ,提交的 runnable 任务,会直接做为 new 一
个 Thread 的参数,立马执行 。 2. 当提交的任务数超过了 corePoolSize ,会将
当前的 runable 提交到一个 block queue 中。 3. 有界队列满了之后,如果 poolSize
< maximumPoolsize 时,会尝试 new 一个 Thread 的进行救急处理,立马执行
对应的 runnable 任务。 4. 如果 3 中也无法处理了,就会走到第四步执行 reject
操作。
无界队列
与有界队列相比,除非系统资源耗尽,否则无界的任务队列不存在任务入队失败
的情况。当有新的任务到来,系统的线程数小于 corePoolSize 时,则新建线程
执行任务。当达到 corePoolSize 后,就不会继续增加,若后续仍有新的任务加
入,而没有空闲的线程资源,则任务直接进入队列等待。若任务创建和处理的速
度差异很大,无界队列会保持快速增长,直到耗尽系统内存。 当线程池的任务 缓存队列已满并且线程池中的线程数目达到 maximumPoolSize ,如果还有任务
到来就会采取任务拒绝策略。
6、 多线程中的安全队列一般通过什么实现?
Java 提供的线程安全的 Queue 可以分为阻塞队列和非阻塞队列,其中阻塞队列
的典型例子是 BlockingQueue ,非阻塞队列的典型例子是
ConcurrentLinkedQueue.
对于 BlockingQueue ,想要实现阻塞功能,需要调用 put(e) take() 方法。而
ConcurrentLinkedQueue 是基于链接节点的、无界的、线程安全的非阻塞队列。
二、Synchronized、volatile、Lock(ReentrantLock)相关 ( ⭐⭐⭐ )
1、synchronized 的原理?
synchronized 代码块是由一对儿 monitorenter/monitorexit 指令实现的,
Monitor 对象是同步的基本实现,而 synchronized 同步方法使用了
ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行
相应的同步调用。
在 Java 6 之前, Monitor 的实现完全是依靠操作系统内部的互斥锁,因为需要
进行用户态到内核态的切换,所以同步操作是一个无差别的重量级操作。
现代的( Oracle ) JDK 中, JVM 对此进行了大刀阔斧地改进,提供了三种不同
的 Monitor 实现,也就是常说的三种不同的锁:偏斜锁( Biased Locking )、
轻量级锁和重量级锁,大大改进了其性能。
所谓锁的升级、降级,就是 JVM 优化 synchronized 运行的机制,当 JVM 检
测到不同的竞争状况时,会自动切换到适合的锁实现,这种切换就是锁的升级、
降级。 当没有竞争出现时,默认会使用偏斜锁。 JVM 会利用 CAS 操作,在对象头上
的 Mark Word 部分设置线程 ID ,以表示这个对象偏向于当前线程,所以并不
涉及真正的互斥锁。这样做的假设是基于在很多应用场景中,大部分对象生命周
期中最多会被一个线程锁定,使用偏斜锁可以降低无竞争开销。
如果有另外的线程试图锁定某个已经被偏斜过的对象, JVM 就需要撤销
( revoke )偏斜锁,并切换到轻量级锁实现。轻量级锁依赖 CAS 操作 Mark
Word 来试图获取锁,如果重试成功,就使用普通的轻量级锁;否则,进一步升
级为重量级锁(可能会先进行自旋锁升级,如果失败再尝试重量级锁升级)。
我注意到有的观点认为 Java 不会进行锁降级。实际上据我所知,锁降级确实是
会发生的,当 JVM 进入安全点( SafePoint )的时候,会检查是否有闲置的
Monitor ,然后试图进行降级。
2、Synchronized 优化后的锁机制简单介绍一下,包括自旋锁、偏向锁、轻量级锁、重量级锁?
自旋锁:
线程自旋说白了就是让 cpu 在做无用功,比如:可以执行几次 for 循环,可以执
行几条空的汇编指令,目的是占着 CPU 不放,等待获取锁的机会。如果旋的时
间过长会影响整体性能,时间过短又达不到延迟阻塞的目的。
偏向锁
偏向锁就是一旦线程第一次获得了监视对象,之后让监视对象 “ 偏向 ” 这个线程,
之后的多次调用则可以避免 CAS 操作,说白了就是置个变量,如果发现为 true
则无需再走各种加锁 / 解锁流程。
轻量级锁:
轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当
第二个线程加入锁竞争用的时候,偏向锁就会升级为轻量级锁; 重量级锁
重量锁在 JVM 中又叫对象监视器( Monitor ),它很像 C 中的 Mutex ,除了具备
Mutex(0|1) 互斥的功能,它还负责实现了 Semaphore( 信号量 ) 的功能,也就是说
它至少包含一个竞争锁的队列,和一个信号阻塞队列( wait 队列),前者负责做
互斥,后一个用于做线程同步。
3、谈谈对 Synchronized 关键字涉及到的类锁,方法锁,重入锁的理解?
synchronized 修饰静态方法获取的是类锁 ( 类的字节码文件对象 ) 。
synchronized 修饰普通方法或代码块获取的是对象锁。这种机制确保了同一时刻
对于每一个类实例,其所有声明为 synchronized 的成员函数中至多只有一个处
于可执行状态,从而有效避免了类成员变量的访问冲突。
它俩是不冲突的,也就是说:获取了类锁的线程和获取了对象锁的线程是不冲突
的!
public class Widget {
// 锁住了
public synchronized void doSomething() {
...
}
}
public class LoggingWidget extends Widget {
// 锁住了
public synchronized void doSomething() { System.out.println(toString() + ": calling doSomething");
super.doSomething();
}
}
因为锁的持有者是 “ 线程 ” ,而不是 “ 调用 ” 。
线程 A 已经是有了 LoggingWidget 实例对象的锁了,当再需要的时候可以继续
**“ 开锁 ”** 进去的!
这就是内置锁的可重入性。
4、wait、sleep 的区别和 notify 运行过程。
wait 、 sleep 的区别
最大的不同是在等待时 wait 会释放锁,而 sleep 一直持有锁。 wait 通常被用
于线程间交互, sleep 通常被用于暂停执行。
首先,要记住这个差别, “sleep 是 Thread 类的方法 ,wait 是 Object 类中
定义的方法 ” 。尽管这两个方法都会影响线程的执行行为,但是本质上是
有区别的。
Thread.sleep 不会导致锁行为的改变,如果当前线程是拥有锁的,那么
Thread.sleep 不会让线程释放锁。如果能够帮助你记忆的话,可以简单认
为和锁相关的方法都定义在 Object 类中,因此调用 Thread.sleep 是不会
影响锁的相关行为。
Thread.sleep 和 Object.wait 都会暂停当前的线程,对于 CPU 资源来说,
不管是哪种方式暂停的线程,都表示它暂时不再需要 CPU 的执行时间。 OS 会将执行时间分配给其它线程。区别是,调用 wait 后,需要别的线程
执行 notify/notifyAll 才能够重新获得 CPU 执行时间。
线程的状态参考 Thread.State 的定义。新创建的但是没有执行(还没有
调用 start()) 的线程处于 “ 就绪 ” ,或者说 Thread.State.NEW 状态。
Thread.State.BLOCKED (阻塞)表示线程正在获取锁时,因为锁不能获取
到而被迫暂停执行下面的指令,一直等到这个锁被别的线程释放。
BLOCKED 状态下线程, OS 调度机制需要决定下一个能够获取锁的线程是
哪个,这种情况下,就是产生锁的争用,无论如何这都是很耗时的操作。
notify 运行过程
当线程 A (消费者)调用 wait() 方法后,线程 A 让出锁,自己进入等待状态,同
时加入锁对象的等待队列。 线程 B (生产者)获取锁后,调用 notify 方法通知
锁对象的等待队列,使得线程 A 从等待队列进入阻塞队列。 线程 A 进入阻塞队
列后,直至线程 B 释放锁后,线程 A 竞争得到锁继续从 wait() 方法后执行。
5 、
synchronized 关键字和 Lock 的区别你知道吗?为什么 Lock 的性能好一些?
类别
synchronized
Lock (底层实现主要是 Volatile + CAS )
存在
层次
Java 的关键字,在 jvm 层面上
是一个类
锁的
释放
1 、已获取锁的线程执行完同步代码,释放锁 2 、线程
执行发生异常, jvm 会让线程释放锁。
在 finally 中必须释放锁,不然容易造成线程死锁。
锁的
获取
假设 A 线程获得锁, B 线程等待。如果 A 线程阻塞, B
分情况而定, Lock 有多个锁获取的方式,大致就是 Lock ( ReentrantLock )的底层实现主要是 Volatile + CAS (乐观锁),而
Synchronized 是一种悲观锁,比较耗性能。但是在 JDK1.6 以后对 Synchronized
的锁机制进行了优化,加入了偏向锁、轻量级锁、自旋锁、重量级锁,在并发量
不大的情况下,性能可能优于 Lock 机制。所以建议一般请求并发量不大的情况
下使用 synchronized 关键字。
6、volatile 原理。
在《 Java 并发编程:核心理论》一文中,我们已经提到可见性、有序性及原子性
问题,通常情况下我们可以通过 Synchronized 关键字来解决这些个问题,不过
如果对 Synchonized 原理有了解的话,应该知道 Synchronized 是一个较重量级
的操作,对系统的性能有比较大的影响,所以如果有其他解决方案,我们通常都
避免使用 Synchronized 来解决问题。
而 volatile 关键字就是 Java 中提供的另一种解决可见性有序性问题的方案。对于
原子性,需要强调一点,也是大家容易误解的一点:对 volatile 变量的单次读 /
写操作可保证原子性的,如 long 和 double 类型变量,但是并不能保证 i++ 这种
操作的原子性,因为本质上 i++ 是读、写两次操作。
volatile 也是互斥同步的一种实现,不过它非常的轻量级。
volatile 的意义?
线程会一直等待。
可以尝试获得锁,线程可以不用一直等待
锁状
态
无法判断
可以判断
锁类
型
可重入 不可中断 非公平
可重入 可判断 可公平(两者皆可)
性能
少量同步
大量同步
防止 CPU 指令重排序
volatile 有两条关键的语义:
保证被 volatile 修饰的变量对所有线程都是可见的
禁止进行指令重排序
要理解 volatile 关键字,我们得先从 Java 的线程模型开始说起。如图所示:
Java 内存模型规定了所有字段(这些字段包括实例字段、静态字段等,不包括局
部变量、方法参数等,因为这些是线程私有的,并不存在竞争)都存在主内存中,
每个线程会 有自己的工作内存,工作内存里保存了线程所使用到的变量在主内
存里的副本拷贝,线程对变量的操作只能在工作内存里进行,而不能直接读写主
内存,当然不同内存之间也 无法直接访问对方的工作内存,也就是说主内存是
线程传值的媒介。
我们来理解第一句话:
保证被 volatile 修饰的变量对所有线程都是可见的
如何保证可见性?
被 volatile 修饰的变量在工作内存修改后会被强制写回主内存,其他线程在使用
时也会强制从主内存刷新,这样就保证了一致性。
关于 “ 保证被 volatile 修饰的变量对所有线程都是可见的 ” ,有种常见的错误理解:
由于 volatile 修饰的变量在各个线程里都是一致的,所以基于 volatile 变
量的运算在多线程并发的情况下是安全的。
这句话的前半部分是对的,后半部分却错了,因此它忘记考虑变量的操作是否具
有原子性这一问题。
举个例子:
private volatile int start = 0;
private void volatile Keyword() {
Runnable runnable = new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
start++;
}
}
};
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(runnable);
thread.start();
}
Log.d(TAG, "start = " + start);
} 这段代码启动了 10 个线程,每次 10 次自增,按道理最终结果应该是 100 ,但是
结果并非如此。
为什么会这样?
仔细看一下 start++ ,它其实并非一个原子操作,简单来看,它有两步:
1 、取出 start 的值,因为有 volatile 的修饰,这时候的值是正确的。
2 、自增,但是自增的时候,别的线程可能已经把 start 加大了,这种情况下就有
可能把较小的 start 写回主内存中。 所以 volatile 只能保证可见性,在不符合以
下场景下我们依然需要通过加锁来保证原子性:
运算结果并不依赖变量当前的值,或者只有单一线程修改变量的值。(要
么结果不依赖当前值,要么操作是原子性的,要么只要一个线程修改变量
的值)
变量不需要与其他状态变量共同参与不变约束 比方说我们会在线程里加
个 boolean 变量,来判断线程是否停止,这种情况就非常适合使用
volatile 。
我们再来理解第二句话。
禁止进行指令重排序
什么是指令重排序?
指令重排序是指指令乱序执行,即在条件允许的情况下直接运行当前有能
力立即执行的后续指令,避开为获取一条指令所需数据而造成的等待,通
过乱序执行的技术提供执行效率。
指令重排序会在被 volatile 修饰的变量的赋值操作前,添加一个内存屏障,
指令重排序时不能把后面的指令重排序移到内存屏障之前的位置。
7、synchronized 和 volatile 关键字的作用和区别。 Volatile
1 )保证了不同线程对这个变量进行操作时的可见性即一个线程修改了某个变量
的值,这新值对其他线程来是立即可见的。
2 )禁止进行指令重排序。
作用
volatile 本质是在告诉 jvm 当前变量在寄存器(工作内存)中的值是不确定的,
需从主存中读取; synchronized 则是锁定当前变量,只有当前线程可以访问该变
量,其它线程被阻塞住。
区别
1.volatile 仅能使用在变量级别; synchronized 则可以使用在变量、方法、和类
级别的。
2.volatile 仅能实现变量的修改可见性,并不能保证原子性; synchronized 则可
以保证变量的修改可见性和原子性。
3.volatile 不会造成线程的阻塞; synchronized 可能会造成线程的阻塞。
4.volatile 标记的变量不会被编译器优化; synchronized 标记的变量可以被编译
器优化。
8、 ReentrantLock 的内部实现 。
ReentrantLock 实现的前提就是 AbstractQueuedSynchronizer ,简称 AQS ,是
java.util.concurrent 的核心, CountDownLatch 、 FutureTask 、 Semaphore 、
ReentrantLock 等都有一个内部类是这个抽象类的子类。由于 AQS 是基于 FIFO
队列的实现,因此必然存在一个个节点, Node 就是一个节点, Node 有两种模
式:共享模式和独占模式。 ReentrantLock 是基于 AQS 的, AQS 是 Java 并发包 中众多同步组件的构建基础,它通过一个 int 类型的状态变量 state 和一个 FIFO
队列来完成共享资源的获取,线程的排队等待等。 AQS 是个底层框架,采用模
板方法模式,它定义了通用的较为复杂的逻辑骨架,比如线程的排队,阻塞,唤
醒等,将这些复杂但实质通用的部分抽取出来,这些都是需要构建同步组件的使
用者无需关心的,使用者仅需重写一些简单的指定的方法即可(其实就是对于共
享变量 state 的一些简单的获取释放的操作)。 AQS 的子类一般只需要重写
tryAcquire(int arg) 和 tryRelease(int arg) 两个方法即可。
ReentrantLock 的处理逻辑:
其内部定义了三个重要的静态内部类, Sync , NonFairSync , FairSync 。 Sync 作
为 ReentrantLock 中公用的同步组件,继承了 AQS (要利用 AQS 复杂的顶层逻
辑嘛,线程排队,阻塞,唤醒等等); NonFairSync 和 FairSync 则都继承 Sync ,
调用 Sync 的公用逻辑,然后再在各自内部完成自己特定的逻辑(公平或非公平)。
接着说下这两者的 lock() 方法实现原理:
NonFairSync (非公平可重入锁)
1. 先获取 state 值,若为 0 ,意味着此时没有线程获取到资源, CAS 将其设置为 1 ,
设置成功则代表获取到排他锁了;
2. 若 state 大于 0 ,肯定有线程已经抢占到资源了,此时再去判断是否就是自己
抢占的,是的话, state 累加,返回 true ,重入成功, state 的值即是线程重入的
次数;
3. 其他情况,则获取锁失败。
FairSync (公平可重入锁)
可以看到,公平锁的大致逻辑与非公平锁是一致的,不同的地方在于有
了 !hasQueuedPredecessors() 这个判断逻辑,即便 state 为 0 ,也不能贸然直接去 获取,要先去看有没有还在排队的线程,若没有,才能尝试去获取,做后面的处
理。反之,返回 false ,获取失败。
最后,说下 ReentrantLock 的 tryRelease() 方法实现原理:
若 state 值为 0 ,表示当前线程已完全释放干净,返回 true ,上层的 AQS 会意识
到资源已空出。若不为 0 ,则表示线程还占有资源,只不过将此次重入的资源的
释放了而已,返回 false 。
ReentrantLock 是一种可重入的,可实现公平性的互斥锁,它的设计基于 AQS
框架,可重入和公平性的实现逻辑都不难理解,每重入一次, state 就加 1 ,当
然在释放的时候,也得一层一层释放。至于公平性,在尝试获取锁的时候多了一
个判断:是否有比自己申请早的线程在同步队列中等待,若有,去等待;若没有,
才允许去抢占。
9、ReentrantLock 、synchronized 和 volatile 比较?
synchronized 是互斥同步的一种实现。
synchronized :当某个线程访问被 synchronized 标记的方法或代码块时,这个
线程便获得了该对象的锁,其他线暂时无法访问这个方法,只有等待这个方法执
行完毕或代码块执行完毕,这个线程才会释放该对象的锁,其他线程才能执行这
个方法代码块。
前面我们已经说了 volatile 关键字,这里我们举个例子来综合分析 volatile 与
synchronized 关键字的使用。
举个例子:
public class Singleton {
// volatile 保证了: 1 instance 在多线程并发的可见性 2 禁止 instance 在操作是的
指令重排序 private volatile static Singleton instance;
private Singleton(){}
public static Singleton getInstance() {
// 第一次判空,保证不必要的同步
if (instance == null) {
// synchronized 对 Singleton 加全局锁,保证每次只要一个线程创建实例
synchronized (Singleton.class) {
// 第二次判空时为了在 null 的情况下创建实例
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
这是一个经典的 DCL 单例。
它的字节码如下: 可以看到被 synchronized 同步的代码块,会在前后分别加上 monitorenter 和
monitorexit ,这两个字节码都需要指定加锁和解锁的对象。
关于加锁和解锁的对象:
synchronized 代码块 :同步代码块,作用范围是整个代码块,作用对象是调用
这个代码块的对象。
synchronized 方法 :同步方法,作用范围是整个方法,作用对象是调用这个方
法的对象。
synchronized 静态方法 :同步静态方法,作用范围是整个静态方法,作用对象
是调用这个类的所有对象。 synchronized(this) :作用范围是该对象中所有被 synchronized 标记的变量、方
法或代码块,作用对象是对象本身。
synchronized(ClassName.class) :作用范围是静态的方法或者静态变量,作用对
象是 Class 对象。
synchronized(this) 添加的是对象锁, synchronized(ClassName.class) 添加的是类
锁,它们的区别如下:
对象锁: Java 的所有对象都含有 1 个互斥锁,这个锁由 JVM 自动获取和
释放。线程进入 synchronized 方法的时候获取该对象的锁,当然如果已
经有线程获取了这个对象的锁那么当前线程会等待;
synchronized 方法正
常返回或者抛异常而终止, JVM 会自动释放对象锁。这里也体现了用
synchronized 来加锁的好处,方法抛异常的时候,锁仍然可以由 JVM 来
自动释放。
类锁:对象锁是用来控制实例方法之间的同步,类锁是来控制静态方法(或
静态变量互斥体)之间的同步。其实类锁只是一个概念上的东西,并不是
真实存在的,它只用来帮助我们理解锁定实例方法和静态方法的区别的。
我们都知道, java 类可能会有很多个对象,但是只有 1 个 Class 对象,也
就说类的不同实例之间共享该类的 Class 对象。 Class 对象其实也仅仅是 1
个 java 对象,只不过有点特殊而已。由于每个 java 对象都有个互斥锁,
而类的静态方法是需要 Class 对象。所以所谓类锁,不过是 Class 对象的
锁而已。获取类的 Class 对象有好几种,最简单的就是 MyClass.class 的方
式。类锁和对象锁不是同一个东西,一个是类的 Class 对象的锁,一个是
类的实例的锁。也就是说:一个线程访问静态 sychronized 的时候,允许
另一个线程访问对象的实例 synchronized 方法。反过来也是成立的,为
他们需要的锁是不同的。
三、其它 ( ⭐⭐⭐ )
1、多线程的使用场景?
使用多线程就一定效率高吗?有时候使用多线程并不是为了提高效率,而是使得
CPU 能同时处理多个事件。
为了不阻塞主线程 , 启动其他线程来做事情 , 比如 APP 中的耗时操作都不在
UI 线程中做。
实现更快的应用程序 , 即主线程专门监听用户请求 , 子线程用来处理用户请
求 , 以获得大的吞吐量 . 感觉这种情况,多线程的效率未必高。这种情况下
的多线程是为了不必等待,可以并行处理多条数据。比如 JavaWeb 的就
是主线程专门监听用户的 HTTP 请求,然启动子线程去处理用户的 HTTP
请求。
某种虽然优先级很低的服务,但是却要不定时去做。比如 Jvm 的垃圾回
收。
某种任务,虽然耗时,但是不消耗 CPU 的操作时间,开启个线程,效率
会有显著提高。比如读取文件,然后处理。磁盘 IO 是个很耗费时间,但
是不耗 CPU 计算的工作。所以可以一个线程读取数据,一个线程处理数
据。肯定比一个线程读取数据,然后处理效率高。因为两个线程的时候充
分利用了 CPU 等待磁盘 IO 的空闲时间。
2、CopyOnWriteArrayList 的了解。
Copy-On-Write 是什么?
在计算机中就是当你想要对一块内存进行修改时,我们不在原有内存块中进行写
操作,而是将内存拷贝一份,在新的内存中进行写操作,写完之后呢,就将指向
原来内存指针指向新的内存,原来的内存就可以被回收掉。 原理:
CopyOnWriteArrayList 这是一个 ArrayList 的线程安全的变体,
CopyOnWriteArrayList 底层实现添加的原理是先 copy 出一个容器 ( 可以简称副
本 ) ,再往新的容器里添加这个新的数据,最后把新的容器的引用地址赋值给了
之前那个旧的的容器地址,但是在添加这个数据的期间,其他线程如果要去读取
数据,仍然是读取到旧的容器里的数据。
优点和缺点 :
优点 :
1. 据一致性完整,为什么?因为加锁了,并发数据不会乱。
2. 解决了像 ArrayList 、 Vector 这种集合多线程遍历迭代问题,记住, Vector 虽然
线程安全,只不过是加了 synchronized 关键字,迭代问题完全没有解决!
缺点 :
1. 内存占有问题 : 很明显,两个数组同时驻扎在内存中,如果实际应用中,数据比
较多,而且比较大的情况下,占用内存会比较大,针对这个其实可以用
ConcurrentHashMap 来代替。
2. 数据一致性 :CopyOnWrite 容器只能保证数据的最终一致性,不能保证数据的
实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用
CopyOnWrite 容器。
使用场景:
1 、读多写少(白名单,黑名单,商品类目的访问和更新场景),为什么?因为
写的时候会复制新集合。 2 、集合不大,为什么?因为写的时候会复制新集合。
3 、实时性要求不高,为什么,因为有可能会读取到旧的集合数据。
3、ConcurrentHashMap 加锁机制是什么,详细说一下?
Java7 ConcurrentHashMap
ConcurrentHashMap 作为一种线程安全且高效的哈希表的解决方案,尤其其中
的 " 分段锁 " 的方案,相比 HashTable 的表锁在性能上的提升非常之大。 HashTable
容器在竞争激烈的并发环境下表现出效率低下的原因,是因为所有访问
HashTable 的线程都必须竞争同一把锁,那假如容器里有多把锁,每一把锁用于
锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间
就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是
ConcurrentHashMap 所使用的锁分段技术,首先将数据分成一段一段的存储,
然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其
他段的数据也能被其他线程访问。
ConcurrentHashMap 是一个 Segment 数组, Segment 通过继承
ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment ,
这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。
concurrencyLevel :并行级别、并发数、 Segment 数。默认是 16 ,也就是说
ConcurrentHashMap 有 16 个 Segments ,所以理论上,这个时候,最多可以
同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。
这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩
容的。其中的每个 Segment 很像 HashMap ,不过它要保证线程安全,所以处
理起来要麻烦些。
初始化槽 : ensureSegment ConcurrentHashMap 初始化的时候会初始化第一个槽 segment[0] ,对于其他槽
来说,在插入第一个值的时候进行初始化。对于并发操作使用 CAS 进行控制。
Java8 ConcurrentHashMap
抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized 来保证并发安
全性。结构上和 Java8 的 HashMap (数组 + 链表 + 红黑树) 基本上一样,不过
它要保证线程安全性,所以在源码上确实要复杂一些。 1.8 在 1.7 的数据结构上
做了大的改动,采用红黑树之后可以保证查询效率( O(logn) ),甚至取消了
ReentrantLock 改为了 synchronized ,这样可以看出在新版的 JDK 中对
synchronized 优化是很到位的。
4、线程死锁的 4 个条件?
死锁是如何发生的,如何避免死锁?
当线程 A 持有独占锁 a ,并尝试去获取独占锁 b 的同时,线程 B 持有独占锁 b ,
并尝试获取独占锁 a 的情况下,就会发生 AB 两个线程由于互相持有对方需要的
锁,而发生的阻塞现象,我们称为死锁。
public class DeadLockDemo {
public static void main(String[] args) {
// 线程 a
Thread td1 = new Thread(new Runnable() {
public void run() {
DeadLockDemo.method1();
}
}); // 线程 b
Thread td2 = new Thread(new Runnable() {
public void run() {
DeadLockDemo.method2();
}
});
td1.start();
td2.start();
}
public static void method1() {
synchronized (String.class) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(" 线程 a 尝试获取 integer.class");
synchronized (Integer.class) {
}
}
} public static void method2() {
synchronized (Integer.class) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(" 线程 b 尝试获取 String.class");
synchronized (String.class) {
}
}
}
}
造成死锁的四个条件:
互斥条件:一个资源每次只能被一个线程使用。
请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不
放。
不剥夺条件:线程已获得的资源,在未使用完之前,不能强行剥夺。
循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
在并发程序中,避免了逻辑中出现数个线程互相持有对方线程所需要的独占锁的
的情况,就可以避免死锁,如下所示:
public class BreakDeadLockDemo {
public static void main(String[] args) {
// 线程 a
Thread td1 = new Thread(new Runnable() { public void run() {
DeadLockDemo2.method1();
}
});
// 线程 b
Thread td2 = new Thread(new Runnable() {
public void run() {
DeadLockDemo2.method2();
}
});
td1.start();
td2.start();
}
public static void method1() {
synchronized (String.class) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(" 线程 a 尝试获取 integer.class");
synchronized (Integer.class) {
System.out.println(" 线程 a 获取到 integer.class"); }
}
}
public static void method2() {
// 不再获取线程 a 需要的 Integer.class 锁。
synchronized (String.class) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(" 线程 b 尝试获取 Integer.class");
synchronized (Integer.class) {
System.out.println(" 线程 b 获取到 Integer.class");
}
}
}
}
5、CAS 介绍?
Unsafe Unsafe 是 CAS 的核心类。因为 Java 无法直接访问底层操作系统,而是通过本地
( native )方法来访问。不过尽管如此, JVM 还是开了一个后门, JDK 中有一个
类 Unsafe ,它提供了硬件级别的原子操作。
CAS
CAS , Compare and Swap 即比较并交换,设计并发算法时常用到的一种技术,
java.util.concurrent 包全完建立在 CAS 之上,没有 CAS 也就没有此包,可见 CAS
的重要性。当前的处理器基本都支持 CAS ,只不过不同的厂家的实现不一样罢了。
并且 CAS 也是通过 Unsafe 实现的,由于 CAS 都是硬件级别的操作,因此效率
会比普通加锁高一些。
CAS 的缺点
CAS 看起来很美,但这种操作显然无法涵盖并发下的所有场景,并且 CAS 从语
义上来说也不是完美的,存在这样一个逻辑漏洞:如果一个变量 V 初次读取的
时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它
的值没有被其他线程修改过了吗?如果在这段期间它的值曾经被改成了 B ,然后
又改回 A ,那 CAS 操作就会误认为它从来没有被修改过。这个漏洞称为 CAS 操
作的 "ABA" 问题。 java.util.concurrent 包为了解决这个问题,提供了一个带有标
记的原子引用类 "AtomicStampedReference" ,它可以通过控制变量值的版本来
保证 CAS 的正确性。不过目前来说这个类比较 " 鸡肋 " ,大部分情况下 ABA 问题
并不会影响程序并发的正确性,如果需要解决 ABA 问题,使用传统的互斥同步
可能回避原子类更加高效。
6、进程和线程的区别?
简而言之 , 一个程序至少有一个进程 , 一个进程至少有一个线程。 1 、线程的划分尺度小于进程,使得多线程程序的并发性高。
2 、进程在执行过程中拥有独立的内存单元,而多个线程共享内存,从而
极大地提高了程序的运行效率。
3 、线程在执行过程中与进程还是有区别的。每个独立的线程有一个程序
运行的入口、顺序执行序列和程序的出口。但是线程不能够独立执行,必
须依存在应用程序中,由应用程序提供多个线程执行控制。
4 、从逻辑角度来看,多线程的意义在于一个应用程序中,有多个执行部
分可以同时执行。但操作系统并没有将多个线程看做多个独立的应用,来
实现进程的调度和管理以及资源分配。这就是进程和线程的重要区别。
5 、进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动 ,
进程是系统进行资源分配和调度的一个独立单位。线程是进程的一个实体 ,
是 CPU 调度和分派的基本单位 , 它是比进程更小的能独立运行的基本单位 .
线程自己基本上不拥有系统资源 , 只拥有一点在运行中必不可少的资源 ( 如
程序计数器 , 一组寄存器和栈 ), 但是它可与同属一个进程的其他的线程共
享进程所拥有的全部资源。
6 、一个线程可以创建和撤销另一个线程 ; 同一个进程中的多个线程之间可
以并发执行。
7 、进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它
进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的堆
栈和局部变量,但线程之间没有单独的地址空间,一个线程死掉就等于整
个进程死掉,所以多进程的程序要比多线程的程序健壮,但在进程切换时,
耗费资源较大,效率要差一些。
7、什么导致线程阻塞?
线程的阻塞 为了解决对共享存储区的访问冲突, Java 引入了同步机制,现在让我们来考察
多个线程对共享资源的访问,显然同步机制已经不够了,因为在任意时刻所要求
的资源不一定已经准备好了被访问,反过来,同一时刻准备好了的资源也可能不
止一个。为了解决这种情况下的访问控制问题, Java 引入了对阻塞机制的支持 .
阻塞指的是暂停一个线程的执行以等待某个条件发生(如某资源就绪),学过操
作系统的同学对它一定已经很熟悉了。 Java 提供了大量方法来支持阻塞,下面
让我们逐一分析。
sleep() 方法: sleep() 允许 指定以毫秒为单位的一段时间作为参数,它使得线
程在指定的时间内进入阻塞状态,不能得到 CPU 时间,指定的时间一过,线程
重新进入可执行状态。 典型地, sleep() 被用在等待某个资源就绪的情形:测试
发现条件不满足后,让线程阻塞一段时间后重新测试,直到条件满足为止。
suspend() 和 resume() 方法:两个方法配套使用, suspend() 使得线程进入阻塞
状态,并且不会自动恢复,必须其对应的 resume() 被调用,才