并发编程手册(基础)
- 并发编程(基础)
- 么是进程?是什么线程?
- 进程和线程的关系?(区别)
- 并行和并发的区别?
- 什么是线程安全和线程不安全?
- 多线程的优缺点(为什么使用多线程、多线程会引发什么问题)
- 什么是多线程的上下文切换?
- Java中守护线程和用户线程的区别?
- 线程死锁是如何产生的,如何避免
- 用Java实现死锁,并给出避免死锁的解决方案
- Java中的死锁、活锁、饥饿有什么区别?
- 线程的生命周期和状态
- 创建线程一共有哪几种方法?
- runnable 和 callable 有什么区别?
- 线程的run()和start()有什么区别?
- 为什么调用start()方法时会执行run()方法,而不直接执行run()方法?
- 什么是自旋锁?
- 什么是CAS?
- 什么是乐观锁和悲观锁?
- 什么是AQS
- 什么是阻塞队列
- 什么是Callable和Future?
- 什么是FutureTask?
- 什么是同步容器和并发容器的实现?
- synchronized和ReentrantLock的区别?
- Lock接口(Lock interface)是什么?对比同步它有什么优势?
- ConcurrentHashMap的并发度是什么?
- ReentrantReadWriteLock读写锁的使用?
- LockSupport工具?
- wait()和sleep()的区别?
- 如何保证多线程下 i++ 结果正确?
- 生产者消费者模型的作用是什么?
- 怎么唤醒一个阻塞的线程?
- Java中用到的线程调度算法是什么
- 单例模式的线程安全性?
- 同步方法和同步块,哪个是更更好的选择?
- 如何检测死锁?怎么预防死锁?
- HashMap在多线程环境下使用需要注意什么?
- 什么是守护线程?有什么用?
- 线程池的原理
- stop() 和 suspend() 方法为何不推荐使用?
- sleep() 和 wait() 有什么区别?
- 当一个线程进入一个对象的一个 synchronized 方法后,其它线程是否可进入此对象的其它方法?
- 乐观锁和悲观锁
- 线程同步和线程调度
- 为什么wait()、notify()、notifyAll()被定义在Object类中而不是在Thread类中?
- 为什么wait(),notify()和notifyAll()必须在同步方法或者同步块中被调用?
- 为什么Thread类的sleep()和yield()方法是静态的?
- 如何停止一个正在运行的线程?
- 如何唤醒一个阻塞的线程
- Java如何实现两个线程之间的通信和协作
- 同步方法和同步方法块哪个效果更好?
- 什么是线程同步?什么是线程互斥?他们是如何实现的?
- 在Java程序中如何保证线程的运行安全?
- 线程类的构造方法、静态块是被哪个线程调用的?
- 一个线程运行时异常会发生什么?
- 线程数量过多会造成什么异常?
- 三个线程T1、T2、T3,如何让他们按顺序执行?
- synchronized关键字
- Java内存的可见性问题
- synchronized关键字三大特性是什么?
- Jdk1.6为什么要对synchronized进行优化?
- 为什么说 Synchronized 是非公平锁?
- 什么是锁消除和锁粗化?
- 为什么说 Synchronized 是一个悲观锁?乐观锁的实现原理又是什么?什么是 CAS,它有什么特性?
- 乐观锁一定就是好的吗?
- Java中都有哪几种锁
- 重入锁 ReentrantLock 及其他显式锁相关问题
- 请尽可能详尽地对比下 Synchronized 和 ReentrantLock 的异同。
- ReentrantLock 是如何实现可重入性的?
- 除了 ReetrantLock,你还接触过 JUC 中的哪些并发工具?
- 请谈谈 ReadWriteLock 和 StampedLock。
- 如何让 Java 的线程彼此同步?你了解过哪些同步器?请分别介绍下
- CyclicBarrier 和 CountDownLatch 看起来很相似,请对比下
- volatile
- 请谈谈 ThreadLocal 是怎么解决并发安全的?
- 很多人都说要慎用 ThreadLocal,谈谈你的理解,使用 ThreadLocal 需要注意些什么?
- volatile的作用是什么?
- volatile的特性有哪些?
- Java内存的可见性问题
- 为什么代码会重排序?
- 重排序会引发什么问题?
- as-if-serial规则和happens-before规则的区别
- voliatile的实现原理?
- volatile实现内存可见性原理
- volatile实现有序性原理
- Java虚拟机插入内存屏障的策略
- 编译器对内存屏障插入策略的优化
- volatile能使一个非原子操作变成一个原子操作吗?
- volatile、synchronized的区别?
- ConcurrentHashMap
- 什么是ConcurrentHashMap?相比于HashMap和HashTable有什么优势
- java中ConcurrentHashMap是如何实现的?
- ConcurrentHashMap结构中变量使用volatile和final修饰有什么作用?
- ConcurrentHashMap有什么缺点?
- ConcurrentHashMap默认初始容量是多少?每次扩容为原来的几倍?
- ConCurrentHashMap 的key,value是否可以为null?为什么?HashMap中的key、value是否可以为null?
- ConCurrentHashmap在JDK1.8中,什么情况下链表会转化为红黑树?
- ConcurrentHashMap在JDK1.7和JDK1.8版本中的区别?
- ConcurrentHashMap迭代器是强一致性还是弱一致性?
- ThreadLocal
- 线程池
并发编程(基础)
么是进程?是什么线程?
线程是处理器任务调度和执行的基本单位,进程是操作系统资源分配的基本单位。 进程是程序的一次执行过程,是系统运行的基本单位。线程是一个比进程更小的执行单位,一个进程可以包含多个线程。
进程和线程的关系?(区别)
定义:
- 线程是CPU独立运行和独立调度的基本单位,没有单独地址空间,有独立的栈,局部变量,寄存器器, 程序计数器等。
- 进程是操作系统资源分配的基本单位,有独立的内存地址空间。
- 创建进程的开销大,包括创建虚拟地址空间等需要大量系统资源
- 创建线程开销⼩小,基本上只有⼀一个内核对象和一个堆栈
- 一个进程无法直接访问另一个进程的资源;同一进程内的多个线程共享进程的资源。
- 进程切换开销大,线程切换开销小;进程间通信开销大,线程间通信开销小。
- 线程属于进程,不能独立执行。每个进程至少要有一个线程,成为主线程
包含关系:一个进程可以包含多个线程。
讲解线程和进程的时候可以从jvm角度回答
- Java虚拟机的角度来理解:Java虚拟机的运行时数据区包含堆、方法区、虚拟机栈、本地方法栈、程 序计数器。各个进程之间是相互独立的,每个进程会包含多个线程,每个进程所包含的多个线程并不是 相互独立的,这个线程会共享进程的堆和方法区,但这些线程不会共享虚拟机栈、本地方法栈、程序计 数器。即每个进程所包含的多个线程共享进程的堆和方法区,并且具备私有的虚拟机栈、本地方法栈、 程序计数器,如图所示,假设某个进程包含三个线程。
由上面可知以下进程和线程在以下几个方面的区别:
- 内存分配:进程之间的地址空间和资源是相互独立的,同一个进程之间的线程会共享线程的地址空间和 资源(堆和方法区)。
- 资源开销:每个进程具备各自的数据空间,进程之间的切换会有较大的开销。属于同一进程的线程会共 享堆和方法区,同时具备私有的虚拟机栈、本地方法栈、程序计数器,线程之间的切换资源开销较小。
并行和并发的区别?
- 并行:单位时间多个处理器同时处理多个任务。
- 并发:一个处理器处理多个任务,按时间片轮流处理多个任务。
什么是线程安全和线程不安全?
线程安全
- 线程安全: 就是多线程访问时,采用了加锁机制,当一个线程访问该类的某个数据时,进行保护,其他线程不能进行 访问,直到该线程读取完,其他线程才可使用。不会出现数据不一致或者数据污染。
- Vector 是⽤用同步方法来实现线程安全的, ⽽和它相似的ArrayList不是线程安全的。
线程不安全
- 线程不不安全:就是不提供数据访问保护,有可能出现多个线程先后更改数据造成所得到的数据是脏数据,线程安全问题都是由全局变量及静态变量引起的。
- 若每个线程中对全局变量、静态变量只有读操作,⽽而⽆无写操作,⼀般来说,这个全局变量是线程安全的;若有多个 线程同时执⾏行写操作,⼀般都需要考虑线程同步,否则的话就可能影响线程安全。
多线程的优缺点(为什么使用多线程、多线程会引发什么问题)
优点:当一个线程进入等待状态或者阻塞时,CPU可以先去执行其他线程,提高CPU的利用率
缺点:
- 上下文切换:频繁的上下文切换会影响多线程的执行速度。
- 多个线程抢占某一个资源会引起死锁
- 资源限制:在进行并发编程时,程序的执行速度受限于计算机的硬件或软件资源。在并发编程中, 程序执行变快的原因是将程序中串行执行的部分变成并发执行,如果因为资源限制,并发执行的部 分仍在串行执行,程序执行将会变得更慢,因为程序并发需要上下文切换和资源调度。
什么是多线程的上下文切换?
多线程:是指从软件或者硬件上实现多个线程的并发技术。 多线程的好处:
- 使用多线程可以把程序中占据时间长的任务放到后台去处理,如图片、视屏的下载
- 发挥多核处理器的优势,并发执行让系统运行的更快、更流畅,⽤户体验更好
多线程的缺点:
- ⼤量的线程降低代码的可读性;
- 更多的线程需要更多的内存空间
- 当多个线程对同一个资源出现争夺时候要注意线程安全的问题。
多线程的上下文切换:
- 即便是单核的处理器也会支持多线程,处理器会给每个线程分配CPU时间片来实现这个机制。时间片是 CPU分配给每个线程的执行时间,一般来说时间片非常的短,所以处理器会不停地切换线程。
- CPU会通过时间片分配算法来循环执行任务,当前任务执行完一个时间片后会切换到下一个任务,但切 换前会保存上一个任务的状态,因为下次切换回这个任务时还要加载这个任务的状态继续执行,从任务 保存到在加载的过程就是一次上下文切换。
Java中守护线程和用户线程的区别?
任何线程都可以设置为守护线程和用户线程,通过方法 Thread.setDaemon(bool on) 设置, true 则 是将该线程设置为守护线程, false 则是将该线程设置为用户线程。同时, Thread.setDaemon() 必须 在 Thread.start() 之前调用,否则运行时会抛出异常。
- 用户线程:平时使用到的线程均为用户线程。
- 守护线程:用来服务用户线程的线程,例如垃圾回收线程。
- 守护线程和用户线程的区别主要在于Java虚拟机是否存活
- 用户线程:当任何一个用户线程未结束,Java虚拟机是不会结束的。
- 守护线程:如果只剩守护线程未结束,Java虚拟机结束
线程死锁是如何产生的,如何避免
产生死锁的原因
死锁:由于两个或两个以上的线程相互竞争对方的资源,而同时不释放自己的资源,导致所有线程同时 被阻塞
死锁产生的条件:
- 互斥条件:一个资源在同一时刻只由一个线程占用。
- 请求与保持条件:一个线程在请求被占资源时发生阻塞,并对已获得的资源保持不放。
- 循环等待条件:发生死锁时,所有的线程会形成一个死循环,一直阻塞。
- 不剥夺条件:线程已获得的资源在未使用完不能被其他线程剥夺,只能由自己使用完释放资源。
避免死锁的方法主要是破坏死锁产生的条件。
- 破坏互斥条件:这个条件无法进行破坏,锁的作用就是使他们互斥。
- 破坏请求与保持条件:一次性申请所有的资源。
- 破坏循环等待条件:按顺序来申请资源。
- 破坏不剥夺条件:线程在申请不到所需资源时,主动放弃所持有的资源。
用Java实现死锁,并给出避免死锁的解决方案
代码演示
class DeadLockDemo {
private static Object resource1 = new Object();
private static Object resource2 = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
try {
ead.sleep(1000); //线程休眠,保证线程2先获得资源2
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting getresource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "getresource2");
}
}
}, "线程 1").start();
new Thread(() -> {
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
try {
Thread.sleep(1000); //线程休眠,保证线程1先获得资源1
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting getresource1");
synchronized (resource1) {
System.out.println(Thread.currentThread() + "getresource1");
}
}
}, "线程 2").start();
}
}
//结果
Thread[线程 1,5,main]get resource1
Thread[线程 2,5,main]waiting get resource1
Thread[线程 1,5,main]waiting get resource2
上面代码产生死锁的原因主要是线程1获取到了资源1,线程2获取到了资源2,线程1继续获取资源2而产 生阻塞,线程2继续获取资源1而产生阻塞。解决该问题最简单的方式就是两个线程按顺序获取资源,线 程1和线程2都先获取资源1再获取资源2,无论哪个线程先获取到资源1,另一个线程都会因无法获取线 程1产生阻塞,等到先获取到资源1的线程释放资源1,另一个线程获取资源1,这样两个线程可以轮流获 取资源1和资源2。代码如下:
private static Object resource1 = new Object();
private static Object resource2 = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting getresource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "getresource2");
}
}
}, "线程 1").start();
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting getresource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "getresource2");
}
}
}, "线程 2").start();
}
}
Java中的死锁、活锁、饥饿有什么区别?
活锁:任务或者执行者没有被阻塞,由于某些条件没有被满足,导致线程一直重复尝试、失败、尝试、 失败。例如,线程1和线程2都需要获取一个资源,但他们同时让其他线程先获取该资源,两个线程一直 谦让,最后都无法获取
活锁和死锁的区别:
- 活锁是在不断地尝试、死锁是在一直等待。
- 活锁有可能自行解开、死锁无法自行解开。
饥饿:一个或者多个线程因为种种原因无法获得所需要的资源, 导致一直无法执行的状态。以打印机打 印文件为例,当有多个线程需要打印文件,系统按照短文件优先的策略进行打印,但当短文件的打印任 务一直不间断地出现,那长文件的打印任务会被一直推迟,导致饥饿。活锁就是在忙式等待条件下发生 的饥饿,忙式等待就是不进入等待状态的等待。 产生饥饿的原因:
- 高优先级的线程占用了低优先级线程的CPU时间
- 线程被永久堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前持续地对该同步块进 行访问。
- 线程在等待一个本身也处于永久等待完成的对象(比如调用这个对象的 wait() 方法),因为其他线程 总是被持续地获得唤醒。
死锁、饥饿的区别:饥饿可自行解开,死锁不行。
线程的生命周期和状态
线程状态的划分并不唯一,但是都大同小异,这里参考《Java并发编程的艺术》,主要有以下几种状态:
- 新建( new ):新创建了一个线程对象。
- 可运行( runnable ):线程对象创建后,其他线程(比如 main 线程)调用了该对象 的 start () 方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获 取 cpu 的使用权 。
- 运行( running ):可运行状态( runnable )的线程获得了 cpu 时间片( timeslice ) ,执行 程序代码。
- 阻塞( block ):阻塞状态是指线程因为某种原因放弃了 cpu 使用权,也即让出了 cpu timeslice ,暂时停止运行。直到线程进入可运行( runnable )状态,才有 机会再次获得 cpu timeslice 转到运行( running )状态。阻塞的情况分三种:
- 等待阻塞:运行( running )的线程执行 o . wait ()方法, JVM 会把该线程放 入等待队列( waitting queue )中。
- 同步阻塞:运行( running )的线程在获取对象的同步锁时,若该同步锁 被别的线程占 用,则 JVM 会把该线程放入锁池( lock pool )中。
- 其他阻塞: 运行( running )的线程执行 Thread . sleep ( long ms )或 t . join ()方法,或者发出了 I / O 请求时, JVM 会把该线程置为阻塞状态。 当 sleep ()状态超时、 join () 等待线程终止或者超时、或者 I / O 处理完毕时,线程重新转入可运行( runnable )状态。
- 死亡( dead ):线程 run ()、 main () 方法执行结束,或者因异常退出了 run ()方法,则该 线程结束生命周期。死亡的线程不可再次复生。
线程转化过程如下:
创建线程一共有哪几种方法?
- 继承 Thread 类创建线程
- 实现 Runnable 接口创建线程
- 使用 Callable 和 Future 创建线程
- 使用线程池例如用 Executor 框架
实现Runnable接口这种方式更受欢迎,因为这不需要继承Thread类。在应用设计中已经继承了别的对象的情况下,这需要多继承(而Java不支持多继承),只能实现接口。同时, 线程池也是非常高效的,很容易实现和使用。
- 继承Thread类创建线程,首先继承Thread类,重写 run() 方法,在 main() 函数中调用子类实实例的** start() 方法。*
public class ThreadDemo extends Thread {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " run()方法正在执行");
}
}
public class TheadTest {
public static void main(String[] args) {
ThreadDemo threadDemo = new ThreadDemo();
threadDemo.start();
System.out.println(Thread.currentThread().getName() + " main()方法执行结束");
}
}
//结果
main main()方法执行结束
Thread-0 run()方法正在执行
- 实现Runnable接口创建线程:首先创建实现 Runnable 接口的类 RunnableDemo ,重写 run() 方法; 创建类 RunnableDemo 的实例对象 runnableDemo ,以 runnableDemo 作为参数创建 Thread 对象,调用 Thread 对象的 start() 方法。
public class RunnableDemo implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " run()方法执行中");
}
}
public class RunnableTest {
public static void main(String[] args) {
RunnableDemo runnableDemo = new RunnableDemo ();
Thread thread = new Thread(runnableDemo);
thread.start();
System.out.println(Thread.currentThread().getName() + " main()方法执行完成");
}
}
//结果
main main()方法执行完成
Thread-0 run()方法执行中
- 使用Callable和Future创建线程:
- 创建Callable接口的实现类 CallableDemo ,重写 call() 方法。
- 以类 CallableDemo 的实例化对象作为参数创建 FutureTask 对象。
- 以 FutureTask 对象作为参数创建 Thread 对象。
- 调用 Thread 对象的 start() 方法。
class CallableDemo implements Callable<Integer> {
@Override
public Integer call() {
System.out.println(Thread.currentThread().getName() + " call()方法执行中");
return 0;
}
}
class CallableTest {
public static void main(String[] args) throws ExecutionException,InterruptedException {
FutureTask<Integer> futureTask = new FutureTask<Integer>(newCallableDemo());
Thread thread = new Thread(futureTask);
thread.start();
System.out.println("返回结果 " + futureTask.get());
System.out.println(Thread.currentThread().getName() + " main()方法执行完成");
}
}
- 使用线程池例如用Executor框架: Executors 可提供四种线程池,分别为:
- newCachedThreadPool 创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程
- newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
- newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。
- newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序执行。
下面以创建一个定长线程池为例进行说明:
class ThreadDemo extends Thread {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "正在执行");
}
}
class TestFixedThreadPool {
public static void main(String[] args) {
//创建一个可重用固定线程数的线程池
ExecutorService pool = Executors.newFixedThreadPool(2);
//创建实现了Runnable接口对象,Thread对象当然也实现了Runnable接口
Thread t1 = new ThreadDemo();
Thread t2 = new ThreadDemo();
Thread t3 = new ThreadDemo();
Thread t4 = new ThreadDemo();
Thread t5 = new ThreadDemo();
//将线程放入池中进行执行
pool.execute(t1);
pool.execute(t2);
pool.execute(t3);
pool.execute(t4);
pool.execute(t5);
//关闭线程池
pool.shutdown();
}
}
//结果
pool-1-thread-2正在执行
pool-1-thread-1正在执行
pool-1-thread-1正在执行
pool-1-thread-2正在执行
pool-1-thread-1正在执行
runnable 和 callable 有什么区别?
相同点:
- 两者都是接口
- 两者都需要调用 Thread.start 启动线程
不同点:
- callable的核心是 call() 方法,允许返回值, runnable 的核心是 run() 方法,没有返回值
- call() 方法可以抛出异常,但是 run() 方法不行
- callable 和 runnable 都可以应用于 executors , thread 类只支持 runnable
线程的run()和start()有什么区别?
- 线程是通过 Thread 对象所对应的方法 run() 来完成其操作的,而线程的启动是通过 start() 方法执行的。
- run() 方法可以重复调用, start() 方法只能调用一次
为什么调用start()方法时会执行run()方法,而不直接执行run()方法?
- start() 方法来启动线程,真正实现了多线程运行,这时无需等待 run() 方法体代码执行完毕而直接继续执行下面的代码。通过调用Thread类的 start() 方法来启动一个线程,这时此线程处于就绪(可运行)状态,并没有运行,一旦得到cpu时间片,就开始执行 run() 方法,这里方法 run() 称为线程体,它包含了要执行的这个线程的内容, run() 方法运行结束,此线程随即终止。
- run() 方法只是类的一个普通方法而已,如果直接调用 run 方法,程序中依然只有主线程这一个线程,其程序执行路径还是只有一条,还是要顺序执行,还是要等待 run() 方法体执行完毕后才可继续执行下面的代码,这样就没有达到写线程的目的。
- 调用 start() 方法可以开启一个线程,而 run() 方法只是thread类中的一个普通方法,直接调用run() 方法还是在主线程中执行的。
什么是自旋锁?
- 当线程A想要获取一把自旋锁而该锁又被其它线程锁持有时,线程A会在⼀个循环中自旋以检测锁是不是已经可用了。
- 自选锁需要注意:
- 由于自旋时不不释放CPU,因而持有⾃旋锁的线程应该尽快释放⾃旋锁,否则等待该自旋锁的线程会⼀直在那里自 旋,这就会浪费CPU时间。
- 持有自旋锁的线程在sleep之前应该释放⾃旋锁以便其它线程可以获得自旋锁。
- ⽬前的JVM实现自旋会消耗CPU,如果长时间不不调用doNotify⽅方法,doWait⽅法会⼀直自旋,CPU会消耗太大。
- ⾃旋锁比较适用于锁使用者保持锁时间比较短的情况,这种情况自旋锁的效率比较高。
- ⾃旋锁是一种对多处理器相当有效的机制,而在单处理器非抢占式的系统中基本上没有作用。
什么是CAS?
- CAS(compare and swap)的缩写,中文翻译成比较并交换。
- CAS 不通过JVM,直接利用java本地方 法JNI(Java Native Interface为JAVA本地调⽤),直接调用CPU 的cmpxchg(是 汇编指令)指令。
- 利⽤CPU的CAS指令,同时借助JNI来完成Java的非阻塞算法,实现原子操作。其它原⼦操作都是利用类似的特性完成 的。
- 整个java.util.concurrent都是建立在CAS之上的,因此对于synchronized阻塞算法,J.U.C在性能上有了了很大的提升。
- CAS是乐观锁技术,当多个线程尝试使用CAS同时更新同⼀个变量时,只有其中⼀个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。
- 使⽤CAS在线程冲突严重时,会⼤幅降低程序性能;CAS只适合于线程冲突较少的情况使⽤。
- synchronized在jdk1.6之后,已经改进优化。synchronized的底层实现主要依靠Lock-Free的队列,基本思路是⾃旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了⾼吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;⽽线程冲突严重的情况下,性能远⾼于CAS。
什么是乐观锁和悲观锁?
悲观锁
- Java在JDK1.5之前都是靠synchronized关键字保证同步的,这种通过使⽤一致的锁定协议来协调对共享状态的访问,可以确保⽆论哪个线程持有共享变量的锁,都采用独占的方式来访问这些变量。独占锁其实就是⼀一种悲观锁,所以可以说synchronized是悲观锁。
乐观锁
- 乐观锁( Optimistic Locking)其实是一种思想。相对悲观锁而言,乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进⾏行提交更新的时候,才会正式对数据的冲突与否进⾏行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。
什么是AQS
- AbstractQueuedSynchronizer简称AQS,是一个用于构建锁和同步容器的框架。事实上concurrent包内许多类都是基于AQS构建,例如ReentrantLock,Semaphore,CountDownLatch,ReentrantReadWriteLock,FutureTask等。AQS解决了在实现同步容器时设计的大量细节问题。
- AQS使用⼀个FIFO的队列表示排队等待锁的线程,队列头节点称作“哨兵节点”或者“哑节点”,它不与任何线程关联。其他的节点与等待线程关联,每个节点维护一个等待状态waitStatus。
什么是阻塞队列
JDK7提供了了7个阻塞队列列。(也属于并发容器)
- ArrayBlockingQueue :⼀个由数组结构组成的有界阻塞队列。
- LinkedBlockingQueue :一个由链表结构组成的有界阻塞队列。
- PriorityBlockingQueue :一个⽀持优先级排序的无界阻塞队列。
- DelayQueue:一个使用优先级队列实现的无界阻塞队列。
- SynchronousQueue:⼀个不存储元素的阻塞队列。
- LinkedTransferQueue:⼀个由链表结构组成的无界阻塞队列。
- LinkedBlockingDeque:⼀个由链表结构组成的双向阻塞队列。
阻塞队列是⼀个在队列基础上又支持了了两个附加操作的队列。
- ⽀持阻塞的插入⽅法:队列满时,队列会阻塞插入元素的线程,直到队列不满。
- 支持阻塞的移除方法:队列空时,获取元素的线程会等待队列变为非空。
什么是Callable和Future?
- Callable 和 Future 是比较有趣的一对组合。当我们需要获取线程的执行结果时,就需要⽤到它们。Callable用于产生结果,Future⽤于获取结果。
- Callable接口使用泛型去定义它的返回类型。Executors类提供了⼀些有用的方法在线程池中执行Callable内的任务。由于Callable任务是并行的,必须等待它返回的结果。java.util.concurrent.Future对象解决了了这个问题。
- 在线程池提交Callable任务后返回了一个Future对象,使用它可以知道Callable任务的状态和得到Callable返回的执行结果。Future提供了了get()⽅法,等待Callable结束并获取它的执行结果。
什么是FutureTask?
- FutureTask可⽤于异步获取执行结果或取消执行任务的场景。通过传⼊Runnable或者Callable的任务给FutureTask,直接调⽤用其run⽅法或者放入线程池执行,之后可以在外部通过FutureTask的get⽅法异步获取执行结果,因此,FutureTask⾮常适合用于耗时的计算,主线程可以在完成自⼰的任务后,再去获取结果。另外,FutureTask还可以确保即使调⽤了多次run⽅方法,它都只会执行⼀次Runnable或者Callable任务,或者通过cancel取消FutureTask的执行等。
- futuretask可用于执行多任务、以及避免高并发情况下多次创建数据死锁的出现。
什么是同步容器和并发容器的实现?
同步容器
- 主要代表有Vector和Hashtable,以及Collections.synchronizedXxx等。
- 锁的粒度为当前对象整体。
- 迭代器是快速失败的,即在迭代的过程中发现被修改,就会抛出ConcurrentModificationException。
并发容器
- 主要代表有ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentSkipListMap、ConcurrentSkipListSet。
- 锁的粒度是分散的、细粒度的,即读和写是使⽤不同的锁。
- 迭代器具有弱一致性,即可以容忍并发修改,不会抛出ConcurrentModificationException。
ConcurrentHashMap采⽤分段锁技术,同步容器中,是⼀个容器⼀个锁,但在ConcurrentHashMap中,会将hash表的数组部分成若⼲段,每段维护⼀个锁,以达到⾼效的并发访问;
synchronized和ReentrantLock的区别?
锁的特点
- 可重入锁:可重入锁是指同一个线程可以多次获取同⼀把锁。ReentrantLock和synchronized都是可重入锁。
- 可中断锁。可中断锁是指线程尝试获取锁的过程中,是否可以响应中断。synchronized是不可中断锁,而ReentrantLock则提供了中断功能。
- 公平锁与非公平锁。公平锁是指多个线程同时尝试获取同⼀把锁时,获取锁的顺序按照线程达到的顺序,⽽非公平锁则允许线程“插队”。synchronized是非公平锁,而ReentrantLock的默认实现是非公平锁,但是也可以设置为公平锁。
- CAS操作(CompareAndSwap):CAS操作简单的说就是⽐较并交换。CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”
Synchronized
synchronized是java内置的关键字,它提供了了一种独占的加锁方式。synchronized的获取和释放锁由JVM实现,用户不需要显示的释放锁,非常方便。然而synchronized也有一定的局限性:
- 当线程尝试获取锁的时候,如果获取不到锁会一直阻塞。
- 如果获取锁的线程进入休眠或者阻塞,除非当前线程异常,否则其他线程尝试获取锁必须一直等待。
ReentrantLock
- ReentrantLock它是JDK 1.5之后提供的API层面的互斥锁,需要lock()和unlock()方法配合try/finally语句块来完成。
- 等待可中断避免,出现死锁的情况(如果别的线程正持有锁,会等待参数给定的时间,在等待的过程中,如果获取了锁定,就返回true,如果等待超时,返回false)
- 公平锁与非公平锁多个线程等待同一个锁时,必须按照申请锁的时间顺序获得锁,Synchronized锁非公平锁,ReentrantLock默认的构造函数是创建的非公平锁,可以通过参数true设为公平锁,但公平锁表现的性能不是很好。
Semaphore就是一个信号量,它的作用是限制某段代码块的并发数
Lock接口(Lock interface)是什么?对比同步它有什么优势?
Lock接口比同步方法和同步块提供了更具扩展性的锁操作。他们允许更灵活的结构,可以具有完全不同的性质,并且可以支持多个相关类的条件对象。
优势
- 可以创建公平锁
- 可以使线程在等待锁的时候响应中断
- 可以让线程尝试获取锁,并在无法获取锁的时候立即返回或者等待一段时间
- 可以在不同的范围,以不同的顺序获取和释放锁,更加的灵活。
ConcurrentHashMap的并发度是什么?
在JDK1.7之前:
工作机制(分片思想):它引入了一个“分段锁”的概念,具体可以理解为把⼀个大的Map拆分成N个小的segment,根据key.hashCode()来决定把key放到哪个HashTable中。可以提供相同的线程安全,但是效率提升N倍,默认提升16倍。
应用
当读>写时使用,适合做缓存,在程序启动时初始化,之后可以被多个线程访问;
hash冲突
简介:HashMap中调⽤用hashCode()方法来计算hashCode。由于在Java中两个不同的对象可能有一样的hashCode,所以不同的键可能有一样hashCode,从而导致冲突的产生。
hash冲突解决:使用红黑树来代替链表,当同一hash中的元素数量超过特定的值便会由链表切换到红黑树。
无锁读
无锁读:ConcurrentHashMap之所以有较好的并发性是因为ConcurrentHashMap是无锁读和加锁写,并且利用了分段锁(不是在所有的entry上加锁,而是在一部分entry上加锁);
并发度
ConcurrentHashMap的并发度就是segment的大小,默认为16,这意味着最多同时可以有16条线程操作ConcurrentHashMap,这也是ConcurrentHashMap对Hashtable的最大优势。
ReentrantReadWriteLock读写锁的使用?
- 读写锁:分为读锁和写锁,多个读锁不互斥,读锁与写锁互斥,这是由jvm⾃己控制的,你只要上好相应的锁即可。
- 如果你的代码只读数据,可以很多人同时读,但不能同时写,那就上读锁;
- 如果你的代码修改数据,只能有一个人在写,且不能同时读取,那就上写锁。总之,读的时候上读锁,写的时候上写锁!
LockSupport工具?
LockSupport是JDK中比较底层的类,用来创建锁和其他同步工具类的基本线程阻塞。java锁和同步器框架的核心 AQS: AbstractQueuedSynchronizer,就是通过调用 LockSupport .park()和 LockSupport .unpark()实现线程的阻塞和唤醒的。
wait()和sleep()的区别?
sleep()
- 方法是线程类(Thread)的静态⽅方法,让调用线程进入睡眠状态,让出执行机会给其他线程,等到休眠时间结束后,线程进入就绪状态和其他线程一起竞争cpu的执行时间。
- 因为sleep() 是static静态的方法,他不能改变对象的锁,当一个synchronized块中调用了了sleep() 方法,线程虽然进入休眠,但是对象的机锁没有被释放,其他线程依然无法访问这个对象。
wait()
- wait()是Object类的方法,当一个线程执行到wait方法时,它就进入到一个和该对象相关的等待池,同时释放对象的锁,使得其他线程能够访问,可以通过notify,notifyAll方法来唤醒等待的线程
如何保证多线程下 i++ 结果正确?
- volatile只能保证你数据的可见性,获取到的是最新的数据,不能保证原子性;
- 用AtomicInteger保证原⼦子性。
- synchronized既能保证共享变量可见性,也可以保证锁内操作的原子性。
生产者消费者模型的作用是什么?
- 通过平衡生产者的生产能力和消费者的消费能力来提升整个系统的运行效率,这是生产者消费者模型最重要的作用。
- 解耦,这是生产者消费者模型附带的作用,解耦意味着生产者和消费者之间的联系少,联系越少越可以独自发展而不需要受到相互的制约。
怎么唤醒一个阻塞的线程?
如果线程是因为调用了wait()、sleep()或者join()方法而导致的阻塞;
- suspend与resume Java废弃 suspend() 去挂起线程的原因,是因为 suspend() 在导致线程暂停的同时,并不会去释放任何锁资源。其他线程都无法访问被它占用的锁。直到对应的线程执行 resume() 方法后,被挂起的线程才能继续,从而其它被阻塞在这个锁的线程才可以继续执行。但是,如果 resume() 操作出现在 suspend() 之前执行,那么线程将⼀直处于挂起状态,同时一直占用锁,这就产生了死锁。而且,对于被挂起的线程,它的线程状态居然还是 Runnable。
- wait与notify wait与notify必须配合synchronized使用,因为调用之前必须持有锁,wait会立即释放锁,notify则是同步块执行完了才释放。
- await与singal Condition类提供,而Condition对象由new ReentLock().newCondition()获得,与wait和notify相同,因为使用Lock锁后无法使用wait方法。
- park与unpark LockSupport是一个非常方便实用的线程阻塞工具,它可以在线程任意位置让线程阻塞。和Thread.suspenf()相比,它弥补了由于resume()在前发生,导致线程无法继续执行的情况。和Object.wait()相⽐比,它不需要先获得某个对象的锁,也不会抛出IException异常。可以唤醒指定线程。
如果线程遇到了IO阻塞,无能为力,因为IO是操作系统实现的,Java代码并没有办法直接触到操作系统。
Java中用到的线程调度算法是什么
抢占式。一个线程用完CPU之后,操作系统会根据线程优先级、线程饥饿情况等数据算出一个总的优先级并分配下一个时间片给某个线程执行。
单例模式的线程安全性?
老生常谈的问题了,首先要说的是单例模式的线程安全意味着:某个类的实例在多线程环境下只会被创建一次出来。单 例模式有很多种的写法:
- 饿汉式单例模式的写法:线程安全
- 懒汉式单例模式的写法:非线程安全
- 双检锁单例模式的写法:线程安全
同步方法和同步块,哪个是更更好的选择?
- 同步块是更好的选择,因为它不会锁住整个对象(当然也可以让它锁住整个对象)。同步方法会锁住整个对象,哪怕这个类中有多个不相关联的同步块,这通常会导致他们停止执行并需要等待获得这个对象上的锁。
- synchronized(this)以及非static的synchronized方法(至于static synchronized方法请往下看),只能防止多个线程同时执行同一个对象的同步代码段。
- 如果要锁住多个对象方法,可以锁住一个固定的对象,或者锁住这个类的Class对象。
- synchronized锁住的是括号里的对象,而不是代码。对于非static的synchronized方法,锁的就是对象本身也就是this。
如何检测死锁?怎么预防死锁?
什么是死锁
是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁;
死锁的四个必要条件:
- 互斥条件:进程对所分配到的资源不允许其他进程进行访问,若其他进程访问该资源,只能等待,直至占有该资源的进程使用完成后释放该资源
- 请求和保持条件:进程获得一定的资源之后,又对其他资源发出请求,但是该资源可能被其他进程占有,此时请求阻塞,但又对自己获得的资源保持不放。
- 不可剥夺条件:是指进程已获得的资源,在未完成使用之前,不可被剥夺,只能在使用完后自己释放
- 环路等待条件:是指进程发生死锁后,若干进程之间形成一种头尾相接的循环等待资源关系
死锁产生的原因
- 因竞争资源发生死锁现象:系统中供多个进程共享的资源的数目不足以满足全部进程的需要时,就会引起对诸资源的竞争而发生死锁现象
- 进程推进顺序不当发生死锁
检查死锁
- 有两个容器,一个用于保存线程正在请求的锁,一个用于保存线程已经持有的锁。每次加锁之前都会做如下检测:
- 检测当前正在请求的锁是否已经被其它线程持有,如果有,则把那些线程找出来
- 遍历第一步中返回的线程,检查自己持有的锁是否正被其中任何一个线程请求,如果第二步返回真,表示出现了死锁
死锁的解除与预防:控制不要让四个必要条件成立。
HashMap在多线程环境下使用需要注意什么?
要注意死循环的问题,HashMap的put操作引发扩容,这个动作在多线程并发下会发生线程死循环的问题。
- HashMap不是线程安全的;Hashtable线程安全,但效率低,因为是Hashtable是使用synchronized的,所有线程竞争同一 把锁;而ConcurrentHashMap不仅线程安全而且效率高,因为它包含一个segment数组,将数据分段存储,给每一段数据配一把锁,也就是所谓的锁分段技术。
- HashMap为何线程不安全:
- put时key相同导致其中一个线程的value被覆盖,也就是不能存储相同的key。
- 多个线程同时扩容,造成数据丢失;
- 多线程扩容时导致Node链表形成环形结构造成.next()死循环,导致CPU利利用率接近100%;
什么是守护线程?有什么用?
守护线程(即daemon thread),是个服务线程,准确地来说就是服务其他的线程,这是它的作用—而其他的线程只有一种,那就是用户线程。所以java里线程分2种,
- 守护线程,比如垃圾回收线程,就是最典型的守护线程。
- 用户线程,就是应用程序里的自定义线程。
线程池的原理
使用场景:假设一个服务器完成一项任务所需时间为:T1-创建线程时间,T2-在线程中执行任务的时间,T3-销毁线程时间。 如果T1+T3远大于T2,则可以使用线程池,以提高服务器性能;
组成
- 线程池管理器(ThreadPool):用于创建并管理线程池,包括创建线程池,销毁线程池,添加新任务;
- 工作线程(PoolWorker):线程池中线程,在没有任务时处于等待状态,可以循环的执行任务;
- 任务接口(Task):每个任务必须实现的接口,以供工作线程调度任务的执行,它主要规定了任务的入口,任务执行完后 的收尾工作,任务的执行状态等;
- 任务队列(taskQueue):用于存放没有处理的任务。提供一种缓冲机制。
原理
线程池技术正是关注如何缩短或调整T1,T3时间的技术,从而提高服务器程序性能的。它把T1,T3分别安排在服务器程序的启动和结束的时间段或者一些空闲的时间段,这样在服务器程序处理客户请求时,不会有T1,T3的开销了。
工作流程
- 线程池刚创建时,里面没有一个线程(也可以设置参数prestartAllCoreThreads启动预期数量主线程)。任务队列是作为参数传进来的。不过,就算队列里面有任务,线程池也不会马上执行它们。
- 当调用 execute() 方法添加一个任务时,线程池会做如下判断:
- 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
- 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列;
- 如果这时候队列满了,而且正在运行的线程数量小于 maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务;
- 如果队列满了了,而且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会抛出异常 RejectExecutionException。
- 当一个线程完成任务时,它会从队列中取下一个任务来执行。
- 当一个线程无事可做,超过一定的时间(keepAliveTime)时,线程池会判断,如果当前运行的线程数大于corePoolSize,那么这个线程就被停掉。所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。
stop() 和 suspend() 方法为何不推荐使用?
- 反对使用 stop(),是因为它不安全。它会解除由线程获取的所有锁定,而且如果对象处于一种不连贯状态,那么其他线程能在那种状态下检查和修改它们。结果很难检查出真正的问题所在。
- suspend() 方法容易发生死锁。调用 suspend() 的时候,目标线程会停下来,但却仍然持有在这之前获得的锁定。此时,其他任何线程都不能访问锁定的资源,除非被 "挂起" 的线程恢复运行。对任何线程来说,如果它们想恢复目标线程,同时又试图使用任何一个锁定的资源,就会造成死锁。所以不应该使用 suspend(),而应在自己的 Thread 类中置入一个标志,指出线程应该活动还是挂起。若标志指出线程应该挂起,便用 wait() 命其进入等待状态。若标志指出线程应当恢复,则用一个 notify() 重新启动线程。
sleep() 和 wait() 有什么区别?
- sleep 就是正在执行的线程主动让出 cpu,cpu 去执行其他线程,在 sleep 指定的时间过后,cpu 才会回到这个线程上继续往下执行,如果当前线程进入了同步锁,sleep 方法并不会释放锁,即使当前线程使用 sleep 方法让出了 cpu,但其他被同步锁挡住了的线程也无法得到执行。
- wait 是指在一个已经进入了同步锁的线程内,让自己暂时让出同步锁,以便其他正在等待此锁的线程可以得到同步锁并运行,只有其他线程调用了 notify 方法(notify 并不释放锁,只是告诉调用过 wait 方法的线程可以去参与获得锁的竞争了,但不是马上得到锁,因为锁还在别人手里,别人还没释放。如果 notify方法后面的代码还有很多,需要这些代码执行完后才会释放锁,可以在 notfiy 方法后增加一个等待和一些代码,看看效果),调用 wait 方法的线程就会解除 wait 状态和程序可以再次得到锁后继续向下运行。
当一个线程进入一个对象的一个 synchronized 方法后,其它线程是否可进入此对象的其它方法?
- 其他方法前是否加了 synchronized 关键字,如果没加,则能。
- 如果这个方法内部调用了 wait,则可以进入其他 synchronized 方法。
- 如果其他方法都加了 synchronized 关键字,并且内部没有调用 wait,则不能。
- 如果其他方法是 static,它用的同步锁是当前类的字节码,与非静态的方法不能同步,因为非静态的方法用的是 this。
乐观锁和悲观锁
乐观锁对应于生活中乐观的人总是想着事情往好的方向发展,悲观锁对应于生活中悲观的人总是想着事情往坏的方向发展。这两种人各有优缺点,不能不以场景而定说一种人好于另外一种人。
悲观锁
- 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
乐观锁
- 总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
两种锁应用场景
从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。
乐观锁常见的两种实现方式
乐观锁一般会使用版本号机制或CAS算法实现。
- 版本号机制
一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
- CAS算法
即compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数
- 需要读写的内存值 V
- 进行比较的值 A
- 拟写入的新值 B
当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。
乐观锁存在问题
- ABA 问题是乐观锁一个常见的问题:
如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 "ABA"问题。JDK 1.5 以后的 AtomicStampedReference 类就提供了此种能力,其中的 compareAndSet 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
- 循环开销时间开销大
自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。 如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
- 只能保证一个共享变量的原子操作
CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。
CAS与synchronized的使用情景
简单的来说CAS适用于写比较少的情况下(多读场景,冲突一般较少),synchronized适用于写比较多的情况下(多写场景,冲突一般较多)
- 对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
- 对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。
补充: Java并发编程这个领域中synchronized关键字一直都是元老级的角色,很久之前很多人都会称它为 “重量级锁” 。但是,在JavaSE 1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的 偏向锁 和 轻量级锁 以及其它各种优化之后变得在某些情况下并不是那么重了。synchronized的底层实现主要依靠 Lock-Free 的队列,基本思路是 自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;而线程冲突严重的情况下,性能远高于CAS。
线程同步和线程调度
同步方法和同步代码块的区别是什么?
区别:
- 同步方法默认用this或者当前类class对象作为锁;
- 同步代码块可以选择以什么来加锁,比同步方法要更细颗粒度,我们可以选择只同步会发生 同步问题的部分代码而不是整个方法;
线程同步以及线程调度相关的方法有哪些?
- wait() :使一个线程处于等待(阻塞)状态,并且释放所持有的对象的锁;
- sleep() :使当前线程进入指定毫秒数的休眠,暂停执行,需要处理 InterruptedException 。
- notify() :唤醒一个处于等待状态的线程,当然在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由 JVM 确定唤醒哪个线程,而且与优先级无关。
- notifyAll() :唤醒所有处于等待状态的线程,该方法并不是将对象的锁给所有线程,而是让它们竞争,只有获得锁的线程才能进入就绪状态。
- join() :与 sleep() 方法一样,是一个可中断的方法,在一个线程中调用另一个线程的 join()方法,会使得当前的线程挂起,直到执行 join() 方法的线程结束。例如在B线程中调用A线程的join() 方法,B线程进入阻塞状态,直到A线程结束或者到达指定的时间。
- yield() :提醒调度器愿意放弃当前的CPU资源,使得当前线程从 RUNNING 状态切换到 RUNABLE状态
线程的sleep()方法和yield()方法有什么不同?
- sleep() 方法会使得当前线程暂停指定的时间,没有消耗CPU时间片。
- sleep() 使得线程进入到阻塞状态, yield() 只是对CPU进行提示,如果CPU没有忽略这个提示,会使得线程上下文的切换,进入到就绪状态。
- sleep() 一定会完成给定的休眠时间, yield() 不一定能完成。
- sleep() 需要抛出InterruptedException,而 yield() 方法无需抛出异常
sleep()方法和wait()方法的区别?
相同点:
- wait() 方法和 sleep() 方法都可以使得线程进入到阻塞状态。
- wait() 和 sleep() 方法都是可中断方法,被中断后都会收到中断异常。
不同点:
- wait() 是Object的方法, sleep() 是Thread的方法。
- wait() 必须在同步方法中进行, sleep() 方法不需要。
- 线程在同步方法中执行 sleep() 方法,不会释放monitor的锁,而 wait() 方法会释放monitor的锁。
- sleep() 方法在短暂的休眠之后会主动退出阻塞,而 wait() 方法在没有指定wait时间的情况下需要被其他线程中断才可以退出阻塞。
wait()方法一般在循环块中使用还是if块中使用?
在JDK官方文档中明确要求了要在循环中使用,否则可能出现虚假唤醒的可能。官方文档中给出的代码示例如下:
synchronized(obj){
while(<condition does not hold>){
obj.wait();
}
//满足while中的条件后执行业务逻辑
}
如果讲 while 换成 if
synchronized(obj){
if(<condition does not hold>){
obj.wait();
}
//满足if中的条件后执行业务逻辑
}
当线程被唤醒后,可能 if() 中的条件已经不满足了,出现虚假唤醒。
线程通信的方法有哪些?
- 锁与同步
- wait() / notify() 或 notifyAll()
- 信号量
- 管道
为什么wait()、notify()、notifyAll()被定义在Object类中而不是在Thread类中?
因为这些方法在操作同步线程时,都必须要标识他们操作线程的锁,只有同一个锁上的被等待线程,可以被同一个锁上的 notify() 或 notifyAll() 唤
醒,不可以对不同锁中的线程进行唤醒,也就是说等待和唤醒必须是同一锁。而锁可以是任意对象,所以可以被任意对象调用的方法是定义在 Object 类中。
如果把 wait() 、 notify() 、 notifyAll() 定义在Thread类中,则会出现一些难以解决的问题,例如如何让一个线程可以持有多把锁?如何确定线程等
待的是哪把锁?既然是当前线程去等待某个对象的锁,则应通过操作对象来实现而不是操作线程,而Object类是所有对象的父类,所以将这三种方法定义在Object类中最合适。
为什么wait(),notify()和notifyAll()必须在同步方法或者同步块中被调用?
因为 wait() 暂停的是持有锁的对象, notify() 或 notifyAll() 唤醒的是等待锁的对象。所以wait() 、 notify() 、 notifyAll() 都需要线程持有锁的对象,进而需要在同步方法或者同步块中被调用 。
为什么Thread类的sleep()和yield()方法是静态的?
sleep() 和 yield() 都是需要正在执行的线程调用的,那些本来就阻塞或者等待的线程调用这个方法是无意义的,所以这两个方法是静态的
如何停止一个正在运行的线程?
- 中断: Interrupt 方法中断线程
- 使用 volatile boolean 标志位停止线程:在线程中设置一个 boolean 标志位,同时用 volatile修饰保证可见性,在线程里不断地读取这个值,其他地方可以修改这个 boolean 值。
- 使用 stop() 方法停止线程,但该方法已经被废弃。因为这样线程不能在停止前保存数据,会出现数据完整性问题。
如何唤醒一个阻塞的线程
如果线程是由于 wait() 、 sleep() 、 join() 、 yield() 等方法进入阻塞状态的,是可以进行唤醒的。如果线程是IO阻塞是无法进行唤醒的,因为IO是操作系统层面的,Java代码无法直接接触操作系统。
- wait() :可用 notify() 或 notifyAll() 方法唤醒。
- sleep() :调用该方法使得线程在指定时间内进入阻塞状态,等到指定时间过去,线程再次获取到CPU时间片进而被唤醒。
- join() :当前线程A调用另一个线程B的 join() 方法,当前线程转A入阻塞状态,直到线程B运行结束,线程A才由阻塞状态转为可执行状态。
- yield() :使得当前线程放弃CPU时间片,但随时可能再次得到CPU时间片进而激活
Java如何实现两个线程之间的通信和协作
- syncrhoized 加锁的线程的 Object 类的 wait() / notify() / notifyAll()
- ReentrantLock 类加锁的线程的 Condition 类的 await() / signal() / signalAll()
- 通过管道进行线程间通信:1)字节流;2)字符流 ,就是一个线程发送数据到输出管道,另一个线程从输入管道读数据
同步方法和同步方法块哪个效果更好?
同步块更好些,因为它锁定的范围更灵活些,只在需要锁住的代码块锁住相应的对象,而同步方法会锁住整个对象
什么是线程同步?什么是线程互斥?他们是如何实现的?
- 线程的互斥是指某一个资源只能被一个访问者访问,具有唯一性和排他性。但访问者对资源访问的顺序是乱序的。
- 线程的同步是指在互斥的基础上使得访问者对资源进行有序访问,防止多个线程之间抢占而发生死锁。
线程同步的实现方法:
- 同步方法
- 同步代码块
- wait() 和 notify()
- 使用volatile实现线程同步
- 使用重入锁实现线程同步
- 使用局部变量实现线程同步
- 使用阻塞队列实现线程同步
在Java程序中如何保证线程的运行安全?
线程安全问题 主要体现在原子性、可见性和有序性。
- 原子性:一个或者多个操作在 CPU 执行的过程中不被中断的特性。线程切换带来的原子性问题。
- 可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。缓存导致的可见性问题。
- 有序性:程序执行的顺序按照代码的先后顺序执行。编译优化带来的有序性问题。
解决方法:
- 原子性问题:可用JDK Atomic 开头的原子类、 synchronized 、 LOCK 来解决
- 可见性问题:可用 synchronized 、 volatile 、 LOCK 来解决
- 有序性问题:可用 Happens-Before 规则来解决 ,使用colatile关键字防止指令排序。
线程类的构造方法、静态块是被哪个线程调用的?
线程类的构造方法、静态块是被 new 这个线程类所在的线程所调用的,而 run() 方法里面的代码才是被线程自身所调用的。
一个很经典的例子:
- 假设 main() 函数中 new 了一个线程Thread1,那么Thread1的构造方法、静态块都是 main 线程调用的,Thread1中的 run() 方法是自己调用的。 假设在Thread1中 new 了一个线程Thread2,那么Thread2的构造方法、静态块都是Thread1线程调用的,Thread2中的 run() 方法是自己调用的。
一个线程运行时异常会发生什么?
Java中的 Throwable 主要分为 Exception 和 Error 。 Exception 分为运行时异常和非运行时异常。运行时异常可以不进行处理,代码也能通过编译,但
运行时会报错。非运行时异常必须处理,否则代码无法通过编译。出现Error代码会直接报错。
线程数量过多会造成什么异常?
- 消耗更多的内存和CPU
- 频繁进行上下文切换
三个线程T1、T2、T3,如何让他们按顺序执行?
这是一道面试中常考的并发编程的代码题,与它相似的问题有:
- 三个线程T1、T2、T3轮流打印ABC,打印n次,如ABCABCABCABC.......
- 两个线程交替打印1-100的奇偶数
- N个线程循环打印1-100
- ......
其实这类问题本质上都是线程通信问题,思路基本上都是一个线程执行完毕,阻塞该线程,唤醒其他线程,按顺序执行下一个线程。下面先来看最简单的,如何按顺序执行三个线程。
方案一:synchronized+wait/notify
基本思路就是线程A、线程B、线程C三个线程同时启动,因为变量 num 的初始值为 0 ,所以线程B或线程C拿到锁后,进入 while() 循环,然后执行 wait() 方法,线程B线程C阻塞,释放锁。只有线程A拿到锁后,不进入 while() 循环,执行 num++ ,打印字符 A ,最后唤醒线程B和线程C。此时 num 值为 1 ,只有线程B拿到锁后,不被阻塞,执行 num++ ,打印字符 B ,最后唤醒线程A和线程C,后面以此类推。
class Wait_Notify_ACB
{
private int num;
private static final Object LOCK = new Object();
private void printABC(String name, int targetNum) {
synchronized (LOCK) {
while (num % 3 != targetNum) { //想想这里为什么不能用if代替while,
try {
LOCK.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
num++;
System.out.print(name);
LOCK.notifyAll();
}
}
public static void main(String[] args) {
Wait_Notify_ACB wait_notify_acb = new Wait_Notify_ACB ();
new Thread(() -> {
wait_notify_acb.printABC("A", 0);
}, "A").start();
new Thread(() -> {
wait_notify_acb.printABC("B", 1);
}, "B").start();
new Thread(() -> {
wait_notify_acb.printABC("C", 2);
}, "C").start();
}
}
//输出结果
ABC
接下来看看第一个问题,三个线程T1、T2、T3轮流打印ABC,打印n次。其实只需要将上述代码加一个循环即可,这里假设n=10。
class Wait_Notify_ACB {
private int num;
private static final Object LOCK = new Object();
private void printABC(String name, int targetNum) {
for (int i = 0; i < 10; i++) {
synchronized (LOCK) {
while (num % 3 != targetNum) { //想想这里为什么不能用if代替,想不起来可
try {
LOCK.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
num++;
System.out.print(name);
LOCK.notifyAll();
}
}
}
public static void main(String[] args) {
Wait_Notify_ACB wait_notify_acb = new Wait_Notify_ACB ();
new Thread(() -> {
wait_notify_acb.printABC("A", 0);
}, "A").start();
new Thread(() -> {
wait_notify_acb.printABC("B", 1);
}, "B").start();
new Thread(() -> {
wait_notify_acb.printABC("C", 2);
}, "C").start();
}
}
//输出结果
ABCABCABCABCABCABCABCABCABCABC
下面看第二个问题,两个线程交替打印1-100的奇偶数,为了减少输入所占篇幅,这里将100 改成了10。基本思路上面类似,线程odd先拿到锁——打印数字——唤醒线程even——阻塞线程odd,以此循环。
class Wait_Notify_Odd_Even{
private Object monitor = new Object();
private volatile int count;
Wait_Notify_Odd_Even(int initCount) {
this.count = initCount;
}
private void printOddEven() {
synchronized (monitor) {
while (count < 10) {
try {
System.out.print( Thread.currentThread().getName() + ":");
System.out.println(++count);
monitor.notifyAll();
monitor.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//防止count=10后,while()循环不再执行,有子线程被阻塞未被唤醒,导致主线程不能退
monitor.notifyAll();
}
}
public static void main(String[] args) throws InterruptedException {
Wait_Notify_Odd_Even waitNotifyOddEven = new Wait_Notify_Odd_Even(0);
new Thread(waitNotifyOddEven::printOddEven, "odd").start();
Thread.sleep(10);
new Thread(waitNotifyOddEven::printOddEven, "even").start();
}
}
大家都是用的synchronized+wait/notify,你能不能换个方法解决该问题?
使用join方法也可以实现
方案二:join()
join() 方法:在A线程中调用了B线程的join()方法时,表示只有当B线程执行完毕时,A线程才能继续执行。基于这个原理,我们使得三个线程按顺序执
行,然后循环多次即可。无论线程1、线程2、线程3哪个先执行,最后执行的顺序都是线程1——>线程2——>线程3。代码如下:
class Join_ABC {
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
Thread t1 = new Thread(new printABC(null),"A");
Thread t2 = new Thread(new printABC(t1),"B");
Thread t3 = new Thread(new printABC(t2),"C");
t0.start();
t1.start();
t2.start();
Thread.sleep(10); //这里是要保证只有t1、t2、t3为一组,进行执行才能保证t1->t2->t3的执行顺序。
}
}
static class printABC implements Runnable{
private Thread beforeThread;
public printABC(Thread beforeThread) {
this.beforeThread = beforeThread;
}
@Override
public void run() {
if(beforeThread!=null) {
try {
beforeThread.join();
System.out.print(Thread.currentThread().getName());
}catch(Exception e){
e.printStackTrace();
}
}else {
System.out.print(Thread.currentThread().getName());
}
}
}
}
//结果
ABCABCABCABCABCABCABCABCABCABC
方案三:Lock
该方法很容易理解,其实现代码和synchronized+wait/notify方法的很像。不管哪个线程拿到锁,只有符合条件的才能打印。代码如下 :
class Lock_ABC {
private int num; // 当前状态值:保证三个线程之间交替打印
private Lock lock = new ReentrantLock();
private void printABC(String name, int targetNum) {
for (int i = 0; i < 10; ) {
lock.lock();
if (num % 3 == targetNum) {
num++;
i++;
System.out.print(name);
}
lock.unlock();
}
}
public static void main(String[] args) {
Lock_ABC lockABC = new Lock_ABC();
new Thread(() -> {
lockABC.printABC("A", 0);
}, "A").start();
new Thread(() -> {
lockABC.printABC("B", 1);
}, "B").start();
new Thread(() -> {
lockABC.printABC("C", 2);
}, "C").start();
}
}
//结果
ABCABCABCABCABCABCABCABCABCABC
该方法还可以使用Lock+Condition实现对线程的精准唤醒,减少对其他线程无意义地唤醒,浪费资源。
方案四:Lock+Condition
该思路和synchronized+wait/notify方法的更像了,synchronized对应lock,await/signal方法对应wait/notify方法。下面的代码为了能精准地唤醒下一个线程,创建了多个Condition对象。
class LockConditionABC {
private int num;
private static Lock lock = new ReentrantLock();
private static Condition c1 = lock.newCondition();
private static Condition c2 = lock.newCondition();
private static Condition c3 = lock.newCondition();
private void printABC(String name, int targetNum, Condition currentThread,Condition nextThread) {
for (int i = 0; i < 10; ) {
lock.lock();
try {
while (num % 3 != targetNum) {
currentThread.await();
}
num++;
i++;
System.out.print(name);
nextThread.signal();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
public static void main(String[] args) {
LockConditionABC print = new LockConditionABC();
new Thread(() -> {
print.printABC("A", 0, c1, c2);
}, "A").start();
new Thread(() -> {
print.printABC("B", 1, c2, c3);
}, "B").start();
new Thread(() -> {
print.printABC("C", 2, c3, c1);
}, "C").start();
}
}
方案五:信号量Semaphore
- Semaphore:用来控制同时访问某个特定资源的操作数量,或者同时执行某个制定操作的数量。
- Semaphore内部维护了一个计数器,其值为可以访问的共享资源的个数。
- 一个线程要访问共享资源,先使用 acquire() 方法获得信号量,如果信号量的计数器值大于等于1,意味着有共享资源可以访问,则使其计数器值减去1,再访问共享资源。如果计数器值为0,线程进入休眠。当某个线程使用完共享资源后,使用 release() 释放信号量,并将信号量内部的计数器加1,之前进入休眠的线程将被唤醒并再次试图获得信号量。
class SemaphoreABC {
private static Semaphore s1 = new Semaphore(1); //先打印A,所以设s1中的计数器值为1
private static Semaphore s2 = new Semaphore(0);
private static Semaphore s3 = new Semaphore(0);
private void printABC(String name, Semaphore currentThread, Semaphore nextThread) {
for (int i = 0; i < 10; i++) {
try {
currentThread.acquire(); //阻塞当前线程,即调用当前线程acquire(),计数器减1为0
System.out.print(name);
nextThread.release(); //唤醒下一个线程,即调用下一个线程线程
release()//计数器加1
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) throws InterruptedException {
SemaphoreABC printer = new SemaphoreABC();
new Thread(() -> {
printer.printABC("A", s1, s2);
}, "A").start();
Thread.sleep(10);
new Thread(() -> {
printer.printABC("B", s2, s3);
}, "B").start();
Thread.sleep(10);
new Thread(() -> {
printer.printABC("C", s3, s1);
}, "C").start();
}
}
除了上面的方法,还有LockSupport、CountDownLatch、AtomicInteger等等也可以实现。
synchronized关键字
什么是synchronized关键字 ?
在多线程的环境下,多个线程同时访问共享资源会出现一些问题,而synchronized关键字则是用来保证线程同步的 。
Synchronized 是由 JVM 实现的一种实现互斥同步的一种方式,如果你查看被 Synchronized 修饰过的程序块编译后的字节码,会发现,被 Synchronized 修饰过的程序块,在编译前后被编译器生成了 monitorenter 和 monitorexit 两个字节码指令。
这两个指令是什么意思呢?
- 在虚拟机执行到 monitorenter 指令时,首先要尝试获取对象的锁:如果这个对象没有锁定,或者当前线程已经拥有了这个对象的锁,把锁的计数器 +1;当执行 monitorexit 指令时将锁计数器 -1;当计数器为 0 时,锁就被释放了。如果获取对象失败了,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。Java 中 Synchronize 通过在对象头设置标记,达到了获取锁和释放锁的目的。
这个“锁”到底是什么?如何确定对象的锁?
“锁”的本质其实是 monitorenter 和 monitorexit 字节码指令的一个 Reference 类型的参数,即要锁定和解锁的对象。我们知道,使用Synchronized 可以修饰不同的对象,因此,对应的对象锁可以这么确定。
- 如果 Synchronized 明确指定了锁对象,比如 Synchronized(变量名)、Synchronized(this) 等,说明加解锁对象为该对象。
- 如果没有明确指定:
- 若 Synchronized 修饰的方法为非静态方法,表示此方法对应的对象为锁对象;
- 若 Synchronized 修饰的方法为静态方法,则表示此方法对应的类对象为锁对象。
注意,当一个对象被锁住时,对象里面所有用 Synchronized 修饰的方法都将产生堵塞,而对象里非 Synchronized 修饰的方法可正常被调用,不受锁影响。
如果同步块内的线程抛出异常会发生什么?
synchronized方法正常返回或者抛异常而终止,JVM会自动释放对象锁
Java内存的可见性问题
在了解synchronized关键字的底层原理前,需要先简单了解下Java的内存模型,看看synchronized关键字是如何起作用的。
这里的本地内存并不是真实存在的,只是Java内存模型的一个抽象概念,它包含了控制器、运算器、缓存等。同时Java内存模型规定,线程对共享变量
的操作必须在自己的本地内存中进行,不能直接在主内存中操作共享变量。这种内存模型会出现什么问题呢?
线程A获取到共享变量X的值,此时本地内存A中没有X的值,所以加载主内存中的X值并缓存到本地内存A中,线程A修改X的值为1,并将X的值刷
新到主内存中,这时主内存及本地内存中的X的值都为1。
线程B需要获取共享变量X的值,此时本地内存B中没有X的值,加载主内存中的X值并缓存到本地内存B中,此时X的值为1。线程B修改X的值为2,
并刷新到主内存中,此时主内存及本地内存B中的X值为2,本地内存A中的X值为1。
- 线程A再次获取共享变量X的值,此时本地内存中存在X的值,所以直接从本地内存中A获取到了X为1的值,但此时主内存中X的值为2,到此出现了
所谓内存不可见的问题。
该问题Java内存模型是通过synchronized关键字和volatile关键字就可以解决,那么synchronized关键字是如何解决的呢,其实进入synchronized块就是
把在synchronized块内使用到的变量从线程的本地内存中擦除,这样在synchronized块中再次使用到该变量就不能从本地内存中获取了,需要从主内存
中获取,解决了内存不可见问题 。
synchronized关键字三大特性是什么?
面试时经常拿synchronized关键字和volatile关键字的特性进行对比,synchronized关键字可以保证并发编程的三大特性:原子性、可见性、有序性,而volatile关键字只能保证可见性和有序性,不能保证原子性,也称为是轻量级的synchronized
- 原子性:一个或多个操作要么全部执行成功,要么全部执行失败。synchronized关键字可以保证只有一个线程拿到锁,访问共享资源。
- 可见性:当一个线程对共享变量进行修改后,其他线程可以立刻看到。执行synchronized时,会对应执行 lock 、unlock原子操作,保证可见性。
- 有序性:程序的执行顺序会按照代码的先后顺序执行。
synchronized关键字可以实现什么类型的锁?
- 悲观锁:synchronized关键字实现的是悲观锁,每次访问共享资源时都会上锁。
- 非公平锁:synchronized关键字实现的是非公平锁,即线程获取锁的顺序并不一定是按照线程阻塞的顺序。
- 可重入锁:synchronized关键字实现的是可重入锁,即已经获取锁的线程可以再次获取锁。
- 独占锁或者排他锁:synchronized关键字实现的是独占锁,即该锁只能被一个线程所持有,其他线程均被阻塞。
synchronized关键字的使用方式
synchronized主要有三种使用方式:修饰普通同步方法、修饰静态同步方法、修饰同步方法块。
修饰普通同步方法(实例方法)
class syncTest implements Runnable {
private static int i = 0; //共享资源
private synchronized void add() {
i++;
}
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
add();
}
}
public static void main(String[] args) throws Exception {
syncTest syncTest = new syncTest();
Thread t1 = new Thread(syncTest);
Thread t2 = new Thread(syncTest);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
这是一个非常经典的例子,多个线程操作 i++ 会出现线程不安全问题,这段代码的结果很容易得到 ,结果是2000。
大家可以再看看这段代码,猜一猜它的运行结果 :
class syncTest implements Runnable {
private static int i = 0; //共享资源
private synchronized void add() {
i++;
}
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
add();
}
}
public static void main(String[] args) throws Exception {
// syncTest syncTest = new syncTest();
Thread t1 = new Thread(new syncTest());
Thread t2 = new Thread(new syncTest());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
//结果不确定
第二个示例中的 add() 方法虽然也使用synchronized关键字修饰了,但是因为两次 new syncTest() 操作建立的是两个不同的对象,也就是说存在两个不
同的对象锁,线程t1和t2使用的是不同的对象锁,所以不能保证线程安全。那这种情况应该如何解决呢?因为每次创建的实例对象都是不同的,而类对
象却只有一个,如果synchronized关键字作用于类对象,即用synchronized修饰静态方法,问题则迎刃而解。
修饰静态方法
只需要在 add() 方法前用static修饰即可,即当synchronized作用于静态方法,锁就是当前的class对象。
class syncTest implements Runnable {
private static int i = 0; //共享资源
private static synchronized void add() {//锁住的是类的方法
i++;
}
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
add();
}
}
public static void main(String[] args) throws Exception {
// syncTest syncTest = new syncTest();
Thread t1 = new Thread(new syncTest());
Thread t2 = new Thread(new syncTest());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
//结果是2000
修饰代码块
如果某些情况下,整个方法体比较大,需要同步的代码只是一小部分,如果直接对整个方法体进行同步,会使得代码性能变差,这时只需要对一小部分代码进行同步即可。代码如下:
class syncTest implements Runnable {
static int i = 0; //共享资源
@Override
public void run() {
//其他操作.......
synchronized (this){ //this表示当前对象实例,这里还可以使用syncTest.class,表示class对象锁
for (int j = 0; j < 10000; j++) {
i++;
}
}
}
public static void main(String[] args) throws Exception {
syncTest syncTest = new syncTest();
Thread t1 = new Thread(syncTest);
Thread t2 = new Thread(syncTest);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
//结果
2000
synchronized底层原理
这个问题也是面试比较高频的一个问题,也是比较难理解的,理解synchronized需要一定的Java虚拟机的知识
在jdk1.6之前,synchronized被称为重量锁,在jdk1.6中,为了减少获得锁和释放锁带来的性能开销,引入了偏向锁和轻量级锁。下面先介绍jdk1.6之前
的synchronized原理。
对象头
在HotSpot虚拟机中,Java对象在内存中的布局大致可以分为三部分:对象头、实例数据和填充对齐。因为synchronized用的锁是存在对象头里的,所
以我们需要重点了解对象头。如果对象头是数组类型,则对象头由Mark Word、Class MetadataAddress和Array length组成,如果对象头非数组类型,
对象头则由Mark Word和Class MetadataAddress组成。在32位虚拟机中,数组类型的Java对象头的组成如下表:
这里我们需要重点掌握的是Mark Word。
Mark Word
在运行期间,Mark Word中存储的数据会随着锁标志位的变化而变化,在32位虚拟机中,不同状态下的组成如下:
其中线程ID表示持有偏向锁线程的ID,Epoch表示偏向锁的时间戳,偏向锁和轻量级锁是在jdk1.6中引入的
重量级锁的底部实现原理:Monitor
在jdk1.6之前,synchronized只能实现重量级锁,Java虚拟机是基于Monitor对象来实现重量级锁的,所以首先来了解下Monitor,在Hotspot虚拟机中,
Monitor是由ObjectMonitor实现的,其源码是用C++语言编写的 ,首先我们先下载Hotspot的源码,源码下载链接:http://hg.openjdk.java.net/jdk8/jd
k8/hotspot,找到ObjectMonitor.hpp文件,路径是 src/share/vm/runtime/objectMonitor.hpp ,这里只是简单介绍下其数据结构 :
其中 _owner
、_WaitSet
和_EntryList
字段比较重要,它们之间的转换关系如下图
从上图可以总结获取Monitor和释放Monitor的流程如下:
- 当多个线程同时访问同步代码块时,首先会进入到EntryList中,然后通过CAS的方式尝试将Monitor中的owner字段设置为当前线程,同时count加
1,若发现之前的owner的值就是指向当前线程的,recursions也需要加1。如果CAS尝试获取锁失败,则进入到EntryList中。
- 当获取锁的线程调用 wait() 方法,则会将owner设置为null,同时count减1,recursions减1,当前线程加入到WaitSet中,等待被唤醒 。
- 当前线程执行完同步代码块时,则会释放锁,count减1,recursions减1。当recursions的值为0时,说明线程已经释放了锁
之前提到过一个常见面试题,为什么 wait() 、 notify() 等方法要在同步方法或同步代码块中来执行呢,这里就能找到原因,是因为 wait() 、 notify() 方法需要借助ObjectMonitor对象内部方法来完成。
synchronized作用于同步代码块的实现原理
前面已经了解Monitor的实现细节,而Java虚拟机则是通过进入和退出Monitor对象来实现方法同步和代码块同步的。这里为了更方便看程序字节码执行
指令,我先在IDEA中安装了一个 jclasslib Bytecodeviewer 插件。我们先来看这个synchronized作用于同步代码块的代码。
public void run() {
//其他操作.......
synchronized (this){ //this表示当前对象实例,这里还可以使用syncTest.class,表
示class对象锁
for (int j = 0; j < 10000; j++) {
i++;
}
}
}
查看字节码如下:
1 dup
2 astore_1
3 monitorenter //进入同步代码块的指令
4 iconst_0
5 istore_2
6 iload_2
7 sipush 10000
10 if_icmpge 27 (+17)
13 getstatic #2 <com/company/syncTest.i>
16 iconst_1
17 iadd
18 putstatic #2 <com/company/syncTest.i>
21 iinc 2 by 1
24 goto 6 (-18)
27 aload_1
28 monitorexit //结束同步代码块的指令
29 goto 37 (+8)
32 astore_3
33 aload_1
34 monitorexit //遇到异常时执行的指令
35 aload_3
36 athrow
37 return
从上述字节码中可以看到同步代码块的实现是由monitorenter 和 monitorexit 指令完成的,其中monitorenter指令所在的位置是同步代码块开始的位置,
第一个monitorexit 指令是用于正常结束同步代码块的指令,第二个monitorexit 指令是用于异常结束时所执行的释放Monitor指令。
synchronized作用于同步方法原理
private synchronized void add() {
i++;
}
查看字节码:
0 getstatic #2 <com/company/syncTest.i>
3 iconst_1
4 iadd
5 putstatic #2 <com/company/syncTest.i>
8 return
发现这个没有monitorenter 和 monitorexit 这两个指令了,而在查看该方法的class文件的结构信息时发现了Access flags后边的synchronized标识,该标
识表明了该方法是一个同步方法。Java虚拟机通过该标识可以来辨别一个方法是否为同步方法,如果有该标识,线程将持有Monitor,在执行方法,最
后释放Monitor。
原理大概就是这样,最后总结一下,面试中应该简洁地如何回答synchroized的底层原理这个问题。
答:Java虚拟机是通过进入和退出Monitor对象来实现代码块同步和方法同步的,代码块同步使用的是monitorenter 和 monitorexit 指令实现的,而
方法同步是通过Access flags后面的标识来确定该方法是否为同步方法。
Jdk1.6为什么要对synchronized进行优化?
因为Java虚拟机是通过进入和退出Monitor对象来实现代码块同步和方法同步的,而Monitor是依靠底层操作系统的Mutex Lock来实现的,操作系统实现
线程之间的切换需要从用户态转换到内核态,这个切换成本比较高,对性能影响较大。
jDK1.6对synchronized做了哪些优化?
在 Java 6 之前,Monitor 的实现完全依赖底层操作系统的互斥锁来实现,也就是我们刚才在问题二中所阐述的获取/释放锁的逻辑。由于 Java 层面的线程与操作系统的原生线程有映射关系,如果要将一个线程进行阻塞或唤起都需要操作系统的协助,这就需要从用户态切换到内核态来执行,这种切换代价十分昂贵,很耗处理器时间,现代 JDK 中做了大量的优化。
锁的升级
在JDK1.6中,为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁,锁的状态变成了四种,无锁状态,偏向锁状态、轻量级锁状态和重量级锁状态。锁的状态会随着竞争激烈逐渐升级,但通常情况下,锁的状态只能升级不能降级 。
另一种优化是使用自旋锁,即在把线程进行阻塞操作之前先让线程自旋等待一段时间,可能在等待期间其他线程已经解锁,这时就无需再让线程执行阻塞操作,避免了用户态到内核态的切换。
现代 JDK 中还提供了四种不同的 Monitor 实现,也就是四种不同的锁:
- 无锁状态
- 偏向锁(Biased Locking)
- 轻量级锁
- 重量级锁
这四种锁使得 JDK 得以优化 Synchronized 的运行,当 JVM 检测到不同的竞争状况时,会自动切换到适合的锁实现,这就是锁的升级、降级。
- 当没有竞争出现时,默认会使用偏向锁。JVM 会利用 CAS 操作,在对象头上的 Mark Word 部分设置线程 ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁,因为在很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏斜锁可以降低无竞争开销。
- 如果有另一线程试图锁定某个被偏斜过的对象,JVM 就撤销偏斜锁,切换到轻量级锁实现。
- 轻量级锁依赖 CAS 操作 Mark Word 来试图获取锁,如果重试成功,就使用普通的轻量级锁;否则,进一步升级为重量级锁。
为什么说 Synchronized 是非公平锁?
非公平主要表现在获取锁的行为上,并非是按照申请锁的时间前后给等待线程分配锁的,每当锁被释放后,任何一个线程都有机会竞争到锁,这样做的目的是为了提高执行性能,缺点是可能会产生线程饥饿现象。
什么是锁消除和锁粗化?
- 锁消除:指虚拟机即时编译器在运行时,对一些代码上要求同步,但被检测到不可能存在共享数据竞争的锁进行消除。主要根据逃逸分析。程序员怎么会在明知道不存在数据竞争的情况下使用同步呢?很多不是程序员自己加入的。
- 锁粗化:原则上,同步块的作用范围要尽量小。但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作在循环体内,频繁地进行互斥同步操作也会导致不必要的性能损耗。
锁粗化就是增大锁的作用域。
为什么说 Synchronized 是一个悲观锁?乐观锁的实现原理又是什么?什么是 CAS,它有什么特性?
Synchronized 显然是一个悲观锁,因为它的并发策略是悲观的:
- 不管是否会产生竞争,任何的数据操作都必须要加锁、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等操作。
随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略。先进行操作,如果没有其他线程征用数据,那操作就成功了;如果共享数据有征用,产生了冲突,那就再进行其他的补偿措施。这种乐观的并发策略的许多实现不需要线程挂起,所以被称为非阻塞同步。
- 乐观锁的核心算法是 CAS(Compareand Swap,比较并交换),它涉及到三个操作数:内存值、预期值、新值。当且仅当预期值和内存值相等时才将内存值修改为新值。
- 这样处理的逻辑是,首先检查某块内存的值是否跟之前我读取时的一样,如不一样则表示期间此内存值已经被别的线程更改过,舍弃本次操作,否则说明期间没有其他线程对此内存值操作,可以把新值设置给此块内存。
CAS 具有原子性,它的原子性由 CPU 硬件指令实现保证,即使用 JNI 调用 Native 方法调用由 C++ 编写的硬件级别指令,JDK 中提供了 Unsafe 类执行这些操作。
乐观锁一定就是好的吗?
乐观锁避免了悲观锁独占对象的现象,同时也提高了并发性能,但它也有缺点:
- 乐观锁只能保证一个共享变量的原子操作。如果多一个或几个变量,乐观锁将变得力不从心,但互斥锁能轻易解决,不管对象数量多少及对象颗粒度大小。
- 长时间自旋可能导致开销大。假如 CAS 长时间不成功而一直自旋,会给 CPU 带来很大的开销。
- ABA 问题。CAS 的核心思想是通过比对内存值与预期值是否一样而判断内存值是否被改过,但这个判断逻辑不严谨,假如内存值原来是 A,后来被一条线程改为 B,最后又被改成了 A,则 CAS 认为此内存值并没有发生改变,但实际上是有被其他线程改过的,这种情况对依赖过程值的情景的运算结果影响很大。解决的思路是引入版本号,每次变量更新都把版本号加一。
Java中都有哪几种锁
- 乐观锁和悲观锁
- 独占锁和共享锁
- 互斥锁和读写锁
- 公平锁和非公平锁
- 可重入锁
- 自旋锁
- 分段锁
- 锁升级(无锁|偏向锁|轻量级锁|重量级锁)
- 锁优化技术(锁粗化、锁消除)
乐观锁和悲观锁
悲观锁
悲观锁
对应于生活中悲观的人,悲观的人总是想着事情往坏的方向发展。
举个生活中的例子,假设厕所只有一个坑位了,悲观锁上厕所会第一时间把门反锁上,这样其他人上厕所只能在门外等候,这种状态就是「阻塞」了。
回到代码世界中,一个共享数据加了悲观锁,那线程每次想操作这个数据前都会假设其他线程也可能会操作这个数据,所以每次操作前都会上锁,这样其他线程想操作这个数据拿不到锁只能阻塞了。
在 Java 语言中 synchronized
和 ReentrantLock
等就是典型的悲观锁,还有一些使用了 synchronized 关键字的容器类如 HashTable
等也是悲观锁的应用。
乐观锁
乐观锁
对应于生活中乐观的人,乐观的人总是想着事情往好的方向发展。
举个生活中的例子,假设厕所只有一个坑位了,乐观锁认为:这荒郊野外的,又没有什么人,不会有人抢我坑位的,每次关门上锁多浪费时间,还是不加锁好了。你看乐观锁就是天生乐观!
回到代码世界中,乐观锁操作数据时不会上锁,在更新的时候会判断一下在此期间是否有其他线程去更新这个数据。
乐观锁可以使用版本号机制
和CAS算法
实现。在 Java 语言中 java.util.concurrent.atomic
包下的原子类就是使用CAS 乐观锁实现的。
两种锁的使用场景
- 悲观锁和乐观锁没有孰优孰劣,有其各自适应的场景。
- 乐观锁适用于写比较少(冲突比较小)的场景,因为不用上锁、释放锁,省去了锁的开销,从而提升了吞吐量。
- 如果是写多读少的场景,即冲突比较严重,线程间竞争激励,使用乐观锁就是导致线程不断进行重试,这样可能还降低了性能,这种场景下使用悲观锁就比较合适。
独占锁和共享锁
独占锁
独占锁
是指锁一次只能被一个线程所持有。如果一个线程对数据加上排他锁后,那么其他线程不能再对该数据加任何类型的锁。获得独占锁的线程即能读数据又能修改数据。
JDK中的synchronized
和java.util.concurrent(JUC)
包中Lock的实现类就是独占锁。
共享锁
共享锁
是指锁可被多个线程所持有。如果一个线程对数据加上共享锁后,那么其他线程只能对数据再加共享锁,不能加独占锁。获得共享锁的线程只能读数据,不能修改数据。
在 JDK 中 ReentrantReadWriteLock
就是一种共享锁。
互斥锁和读写锁
互斥锁
互斥锁
是独占锁的一种常规实现,是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。
互斥锁一次只能一个线程拥有互斥锁,其他线程只有等待。
读写锁
读写锁
是共享锁的一种具体实现。读写锁管理一组锁,一个是只读的锁,一个是写锁。
读锁可以在没有写锁的时候被多个线程同时持有,而写锁是独占的。写锁的优先级要高于读锁,一个获得了读锁的线程必须能看到前一个释放的写锁所更新的内容。
读写锁相比于互斥锁并发程度更高,每次只有一个写线程,但是同时可以有多个线程并发读。
在 JDK 中定义了一个读写锁的接口:ReadWriteLock
,ReentrantReadWriteLock
实现了ReadWriteLock
接口
公平锁和非公平锁
公平锁
公平锁
是指多个线程按照申请锁的顺序来获取锁,这里类似排队买票,先来的人先买,后来的人在队尾排着,这是公平的。
在 java 中可以通过构造函数初始化公平锁
/**
* 创建一个可重入锁,true 表示公平锁,false 表示非公平锁。默认非公平锁
*/
Lock lock = new ReentrantLock(true);
非公平锁
非公平锁
是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁,在高并发环境下,有可能造成优先级翻转,或者饥饿的状态(某个线程一直得不到锁)。
在 java 中 synchronized 关键字是非公平锁,ReentrantLock默认也是非公平锁。
/**
* 创建一个可重入锁,true 表示公平锁,false 表示非公平锁。默认非公平锁
*/
Lock lock = new ReentrantLock(false);
可重入锁
可重入锁
又称之为递归锁
,是指同一个线程在外层方法获取了锁,在进入内层方法会自动获取锁。
对于Java ReentrantLock而言, 他的名字就可以看出是一个可重入锁。对于Synchronized而言,也是一个可重入锁。
可重入锁的一个好处是可一定程度避免死锁。
以 synchronized 为例,看一下下面的代码:
public synchronized void mehtodA() throws Exception{
// Do some magic tings
mehtodB();
}
public synchronized void mehtodB() throws Exception{
// Do some magic tings
}
上面的代码中 methodA 调用 methodB,如果一个线程调用methodA 已经获取了锁再去调用 methodB 就不需要再次获取锁了,这就是可重入锁的特性。如果不是可重入锁的话,mehtodB 可能不会被当前线程执行,可能造成死锁。
自旋锁
自旋锁
是指线程在没有获得锁时不是被直接挂起,而是执行一个忙循环,这个忙循环就是所谓的自旋。
自旋锁的目的是为了减少线程被挂起的几率,因为线程的挂起和唤醒也都是耗资源的操作。
如果锁被另一个线程占用的时间比较长,即使自旋了之后当前线程还是会被挂起,忙循环就会变成浪费系统资源的操作,反而降低了整体性能。因此自旋锁是不适应锁占用时间长的并发情况的。
在 Java 中,AtomicInteger
类有自旋的操作,我们看一下代码:
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!compareAndSwapInt(o, offset, v, v + delta));
return v;
}
CAS 操作如果失败就会一直循环获取当前 value 值然后重试。
另外自适应自旋锁也需要了解一下。
在JDK1.6又引入了自适应自旋,这个就比较智能了,自旋时间不再固定,由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。如果虚拟机认为这次自旋也很有可能再次成功那就会次序较多的时间,如果自旋很少成功,那以后可能就直接省略掉自旋过程,避免浪费处理器资源。
分段锁
分段锁
是一种锁的设计,并不是具体的一种锁。分段锁设计目的是将锁的粒度进一步细化,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。
在 Java 语言中 CurrentHashMap 底层就用了分段锁,使用Segment,就可以进行并发使用了。
锁升级(无锁|偏向锁|轻量级锁|重量级锁)
JDK1.6 为了提升性能减少获得锁和释放锁所带来的消耗,引入了4种锁的状态:无锁
、偏向锁
、轻量级锁
和重量级锁
,它会随着多线程的竞争情况逐渐升级,但不能降级。
无锁
无锁
状态其实就是上面讲的乐观锁,这里不再赘述。
偏向锁
Java偏向锁(Biased Locking)是指它会偏向于第一个访问锁的线程,如果在运行过程中,只有一个线程访问加锁的资源,不存在多线程竞争的情况,那么线程是不需要重复获取锁的,这种情况下,就会给线程加一个偏向锁。
偏向锁的实现是通过控制对象Mark Word
的标志位来实现的,如果当前是可偏向状态
,需要进一步判断对象头存储的线程 ID 是否与当前线程 ID 一致,如果一致直接进入。
轻量级锁
当线程竞争变得比较激烈时,偏向锁就会升级为轻量级锁
,轻量级锁认为虽然竞争是存在的,但是理想情况下竞争的程度很低,通过自旋方式
等待上一个线程释放锁。
重量级锁
如果线程并发进一步加剧,线程的自旋超过了一定次数,或者一个线程持有锁,一个线程在自旋,又来了第三个线程访问时(反正就是竞争继续加大了),轻量级锁就会膨胀为重量级锁
,重量级锁会使除了此时拥有锁的线程以外的线程都阻塞。
升级到重量级锁其实就是互斥锁了,一个线程拿到锁,其余线程都会处于阻塞等待状态。
在 Java 中,synchronized 关键字内部实现原理就是锁升级的过程:无锁 --> 偏向锁 --> 轻量级锁 --> 重量级锁。这一过程在后续讲解 synchronized 关键字的原理时会详细介绍。
锁优化技术(锁粗化、锁消除)
锁粗化
锁粗化
就是将多个同步块的数量减少,并将单个同步块的作用范围扩大,本质上就是将多次上锁、解锁的请求合并为一次同步请求。
举个例子,一个循环体中有一个代码同步块,每次循环都会执行加锁解锁操作。
private static final Object LOCK = new Object();
for(int i = 0;i < 100; i++) {
synchronized(LOCK){
// do some magic things
}
}
经过锁粗化
后就变成下面这个样子了:
synchronized(LOCK){
for(int i = 0;i < 100; i++) {
// do some magic things
}
}
锁消除
锁消除
是指虚拟机编译器在运行时检测到了共享数据没有竞争的锁,从而将这些锁进行消除。
举个例子让大家更好理解。
public String test(String s1, String s2){
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append(s1);
stringBuffer.append(s2);
return stringBuffer.toString();
}
上面代码中有一个 test 方法,主要作用是将字符串 s1 和字符串 s2 串联起来。
test 方法中三个变量s1, s2, stringBuffer, 它们都是局部变量,局部变量是在栈上的,栈是线程私有的,所以就算有多个线程访问 test 方法也是线程安全的。
我们都知道 StringBuffer 是线程安全的类,append 方法是同步方法,但是 test 方法本来就是线程安全的,为了提升效率,虚拟机帮我们消除了这些同步锁,这个过程就被称为锁消除
。
StringBuffer.class
// append 是同步方法
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
总结
重入锁 ReentrantLock 及其他显式锁相关问题
跟 Synchronized 相比,可重入锁 ReentrantLock 其实现原理有什么不同?
其实,锁的实现原理基本是为了达到一个目的:让所有的线程都能看到某种标记。
- Synchronized 通过在对象头中设置标记实现了这一目的,是一种 JVM 原生的锁实现方式,而 ReentrantLock 以及所有的基于 Lock 接口的实现类,都是通过用一个 volitile 修饰的 int 型变量,并保证每个线程都能拥有对该 int 的可见性和原子修改,其本质是基于所谓的 AQS 框架。
那么请谈谈 AQS 框架是怎么回事儿?
AQS(AbstractQueuedSynchronizer 类)是一个用来构建锁和同步器的框架,各种 Lock 包中的锁(常用的有 ReentrantLock、ReadWriteLock),以及其他如 Semaphore、CountDownLatch,甚至是早期的 FutureTask 等,都是基于 AQS 来构建。
- AQS 在内部定义了一个 volatile int state 变量,表示同步状态:当线程调用 lock 方法时 ,如果 state=0,说明没有任何线程占有共享资源的锁,可以获得锁并将 state=1;如果 state=1,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待。
- AQS 通过 Node 内部类构成的一个双向链表结构的同步队列,来完成线程获取锁的排队工作,当有线程获取锁失败后,就被添加到队列末尾。
- Node 类是对要访问同步代码的线程的封装,包含了线程本身及其状态叫 waitStatus(有五种不同 取值,分别表示是否被阻塞,是否等待唤醒,是否已经被取消等),每个 Node 结点关联其 prev 结点和 next 结点,方便线程释放锁后快速唤醒下一个在等待的线程,是一个 FIFO 的过程。
- Node 类有两个常量,SHARED 和 EXCLUSIVE,分别代表共享模式和独占模式。所谓共享模式是一个锁允许多条线程同时操作(信号量 Semaphore 就是基于 AQS 的共享模式实现的),独占模式是同一个时间段只能有一个线程对共享资源进行操作,多余的请求线程需要排队等待(如 ReentranLock)。
- AQS 通过内部类 ConditionObject 构建等待队列(可有多个),当 Condition 调用 wait() 方法后,线程将会加入等待队列中,而当Condition 调用 signal() 方法后,线程将从等待队列转移动同步队列中进行锁竞争。
- AQS 和 Condition 各自维护了不同的队列,在使用 Lock 和 Condition 的时候,其实就是两个队列的互相移动。
请尽可能详尽地对比下 Synchronized 和 ReentrantLock 的异同。
ReentrantLock 是 Lock 的实现类,是一个互斥的同步锁。从功能角度,ReentrantLock 比 Synchronized 的同步操作更精细(因为可以像普通对象一样使用),甚至实现 Synchronized 没有的高级功能,如:
- 等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,对处理执行时间非常长的同步块很有用。
- 带超时的获取锁尝试:在指定的时间范围内获取锁,如果时间到了仍然无法获取则返回。
- 可以判断是否有线程在排队等待获取锁。
- 可以响应中断请求:与 Synchronized 不同,当获取到锁的线程被中断时,能够响应中断,中断异常将会被抛出,同时锁会被释放。
- 可以实现公平锁。从锁释放角度,Synchronized 在 JVM 层面上实现的,不但可以通过一些监控工具监控 Synchronized 的锁定,而且在代码执行出现异常时,JVM 会自动释放锁定;但是使用 Lock 则不行,Lock 是通过代码实现的,要保证锁一定会被释放,就必须将 unLock() 放到 finally{} 中。从性能角度,Synchronized 早期实现比较低效,对比 ReentrantLock,大多数场景性能都相差较大。
- 但是在 Java 6 中对其进行了非常多的改进,在竞争不激烈时,Synchronized 的性能要优于 ReetrantLock;在高竞争情况下,Synchronized 的性能会下降几十倍,但是 ReetrantLock 的性能能维持常态。
ReentrantLock 是如何实现可重入性的?
ReentrantLock 内部自定义了同步器 Sync(Sync 既实现了 AQS,又实现了 AOS,而 AOS 提供了一种互斥锁持有的方式),其实就是加锁的时候通过 CAS 算法,将线程对象放到一个双向链表中,每次获取锁的时候,看下当前维护的那个线程 ID 和当前请求的线程 ID 是否一样,一样就可重入了。
除了 ReetrantLock,你还接触过 JUC 中的哪些并发工具?
通常所说的并发包(JUC)也就是 java.util.concurrent 及其子包,集中了 Java 并发的各种基础工具类,具体主要包括几个方面:
- 提供了 CountDownLatch、CyclicBarrier、Semaphore 等,比 Synchronized 更加高级,可以实现更加丰富多线程操作的同步结构。
- 提供了 ConcurrentHashMap、有序的 ConcunrrentSkipListMap,或者通过类似快照机制实现线程安全的动态数组 CopyOnWriteArrayList 等,各种线程安全的容器。
- 提供了 ArrayBlockingQueue、SynchorousQueue 或针对特定场景的 PriorityBlockingQueue 等,各种并发队列实现。
- 强大的 Executor 框架,可以创建各种不同类型的线程池,调度任务运行等。
请谈谈 ReadWriteLock 和 StampedLock。
虽然 ReentrantLock 和 Synchronized 简单实用,但是行为上有一定局限性,要么不占,要么独占。实际应用场景中,有时候不需要大量竞争的写操作,而是以并发读取为主,为了进一步优化并发操作的粒度,Java 提供了读写锁。
读写锁基于的原理是多个读操作不需要互斥,如果读锁试图锁定时,写锁是被某个线程持有,读锁将无法获得,而只好等待对方操作结束,这样就可以自动保证不会读取到有争议的数据。
读写锁看起来比 Synchronized 的粒度似乎细一些,但在实际应用中,其表现也并不尽如人意,主要还是因为相对比较大的开销。所以,JDK 在后期引入了 StampedLock,在提供类似读写锁的同时,还支持优化读模式。优化读基于假设,大多数情况下读操作并不会和写操作冲突,其逻辑是先试着修改,然后通过 validate 方法确认是否进入了写模式,如果没有进入,就成功避免了开销;如果进入,则尝试获取读锁。
如何让 Java 的线程彼此同步?你了解过哪些同步器?请分别介绍下
JUC 中的同步器三个主要的成员:CountDownLatch、CyclicBarrier 和 Semaphore,通过它们可以方便地实现很多线程之间协作的功能。
CountDownLatch 叫倒计数,允许一个或多个线程等待某些操作完成。看几个场景:
- 跑步比赛,裁判需要等到所有的运动员(“其他线程”)都跑到终点(达到目标),才能去算排名和颁奖。
- 模拟并发,我需要启动 100 个线程去同时访问某一个地址,我希望它们能同时并发,而不是一个一个的去执行。
用法:CountDownLatch 构造方法指明计数数量,被等待线程调用 countDown 将计数器减 1,等待线程使用 await 进行线程等待。
CyclicBarrier 叫循环栅栏,它实现让一组线程等待至某个状态之后再全部同时执行,而且当所有等待线程被释放后,CyclicBarrier 可以被重复使用。CyclicBarrier 的典型应用场景是用来等待并发线程结束。
- CyclicBarrier 的主要方法是 await(),await() 每被调用一次,计数便会减少 1,并阻塞住当前线程。当计数减至 0 时,阻塞解除,所有在此 CyclicBarrier 上面阻塞的线程开始运行。
- 在这之后,如果再次调用 await(),计数就又会变成 N-1,新一轮重新开始,这便是 Cyclic 的含义所在。CyclicBarrier.await() 带有返回值,用来表示当前线程是第几个到达这个 Barrier 的线程。
Semaphore,Java 版本的信号量实现,用于控制同时访问的线程个数,来达到限制通用资源访问的目的,其原理是通过 acquire() 获取一个许可,如果没有就等待,而 release() 释放一个许可。
- 如果 Semaphore 的数值被初始化为 1,那么一个线程就可以通过 acquire 进入互斥状态,本质上和互斥锁是非常相似的。但是区别也非常明显,比如互斥锁是有持有者的,而对于 Semaphore 这种计数器结构,虽然有类似功能,但其实不存在真正意义的持有者,除非我们进行扩展包装。
CyclicBarrier 和 CountDownLatch 看起来很相似,请对比下
它们的行为有一定相似度,区别主要在于:
- CountDownLatch 是不可以重置的,所以无法重用,CyclicBarrier 没有这种限制,可以重用。
- CountDownLatch 的基本操作组合是 countDown/await,调用 await 的线程阻塞等待 countDown 足够的次数,不管你是在一个线程还是多个线程里 countDown,只要次数足够即可。 CyclicBarrier 的基本操作组合就是 await,当所有的伙伴都调用了 await,才会继续进行任务,并自动进行重置。
- CountDownLatch 目的是让一个线程等待其他 N 个线程达到某个条件后,自己再去做某个事(通过 CyclicBarrier 的第二个构造方法 public CyclicBarrier(int parties, Runnable barrierAction),在新线程里做事可以达到同样的效果)。而 CyclicBarrier 的目的是让 N 多线程互相等待直到所有的都达到某个状态,然后这 N 个线程再继续执行各自后续(通过 CountDownLatch 在某些场合也能完成类似的效果)。
volatile
什么是 Java 的内存模型,Java 中各个线程是怎么彼此看到对方的变量的?
Java 的内存模型定义了程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出这样的底层细节。 此处的变量包括实例字段、静态字段和构成数组对象的元素,但是不包括局部变量和方法参数,因为这些是线程私有的,不会被共享,所以不存在竞争问题。
Java 中各个线程是怎么彼此看到对方的变量的呢?Java 中定义了主内存与工作内存的概念:
- 所有的变量都存储在主内存,每条线程还有自己的工作内存,保存了被该线程使用到的变量的主内存副本拷贝。
- 线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,不能直接读写主内存的变量。不同的线程之间也无法直接访问对方工作内存的变量,线程间变量值的传递需要通过主内存。
请谈谈 volatile 有什么特点,为什么它能保证变量对所有线程的可见性?
关键字 volatile 是 Java 虚拟机提供的最轻量级的同步机制。当一个变量被定义成 volatile 之后,具备两种特性:
- 保证此变量对所有线程的可见性。当一条线程修改了这个变量的值,新值对于其他线程是可以立即得知的。而普通变量做不到这一点。
- 禁止指令重排序优化。普通变量仅仅能保证在该方法执行过程中,得到正确结果,但是不保证程序代码的执行顺序。
Java 的内存模型定义了 8 种内存间操作:
lock 和 unlock
- 把一个变量标识为一条线程独占的状态。
- 把一个处于锁定状态的变量释放出来,释放之后的变量才能被其他线程锁定。
read 和 write
- 把一个变量值从主内存传输到线程的工作内存,以便 load。
- 把 store 操作从工作内存得到的变量的值,放入主内存的变量中。
load 和 store
- 把 read 操作从主内存得到的变量值放入工作内存的变量副本中。
- 把工作内存的变量值传送到主内存,以便 write。
use 和 assgin
- 把工作内存变量值传递给执行引擎。
- 将执行引擎值传递给工作内存变量值。
volatile 的实现基于这 8 种内存间操作,保证了一个线程对某个 volatile 变量的修改,一定会被另一个线程看见,即保证了可见性。
既然 volatile 能够保证线程间的变量可见性,是不是就意味着基于 volatile 变量的运算就是并发安全的?
显然不是的。基于 volatile 变量的运算在并发下不一定是安全的。volatile 变量在各个线程的工作内存,不存在一致性问题(各个线程的工作内存中 volatile 变量,每次使用前都要刷新到主内存)。但是 Java 里面的运算并非原子操作,导致 volatile 变量的运算在并发下一样是不安全的。
请对比下 volatile 对比 Synchronized 的异同。
- Synchronized 既能保证可见性,又能保证原子性,而 volatile 只能保证可见性,无法保证原子性。
- ThreadLocal 和 Synchonized 都用于解决多线程并发访问,防止任务在共享资源上产生冲突。但是 ThreadLocal 与 Synchronized 有本质的区别。
- Synchronized 用于实现同步机制,是利用锁的机制使变量或代码块在某一时该只能被一个线程访问,是一种 “以时间换空间” 的方式。而 ThreadLocal 为每一个线程都提供了变量的副本,使得每个线程在某一时间访问到的并不是同一个对象,根除了对变量的共享,是一种 “以空间换时间” 的方式。
请谈谈 ThreadLocal 是怎么解决并发安全的?
ThreadLocal 这是 Java 提供的一种保存线程私有信息的机制,因为其在整个线程生命周期内有效,所以可以方便地在一个线程关联的不同业务模块之间传递信息,比如事务 ID、Cookie 等上下文相关信息。
ThreadLocal 为每一个线程维护变量的副本,把共享数据的可见范围限制在同一个线程之内,其实现原理是,在 ThreadLocal 类中有一个 Map,用于存储每一个线程的变量的副本。
很多人都说要慎用 ThreadLocal,谈谈你的理解,使用 ThreadLocal 需要注意些什么?
使用 ThreadLocal 要注意 remove!
ThreadLocal 的实现是基于一个所谓的 ThreadLocalMap,在 ThreadLocalMap 中,它的 key 是一个弱引用。通常弱引用都会和引用队列配合清理机制使用,但是 ThreadLocal 是个例外,它并没有这么做。这意味着,废弃项目的回收依赖于显式地触发,否则就要等待线程结束,进而回收相应 ThreadLocalMap!这就是很多 OOM 的来源,所以通常都会建议,应用一定要自己负责 remove,并且不要和线程池配合,因为 worker 线程往往是不会退出的。
volatile的作用是什么?
volatile 是一个轻量级的 synchronized ,一般作用与变量,在多处理器开发的过程中保证了内存的可见性。相比于 synchronized 关键字, volatile 关键字的执行成本更低,效率更高 。
volatile的特性有哪些?
并发编程的三大特性为可见性、有序性和原子性。通常来讲 volatile 可以保证可见性和有序性
- 可见性: volatile 可以保证不同线程对共享变量进行操作时的可见性。即当一个线程修改了共享变量时,另一个线程可以读取到共享变量被修改后的值。
- 有序性: volatile 会通过禁止指令重排序进而保证有序性。
- 原子性:对于单个的 volatile 修饰的变量的读写是可以保证原子性的,但对于 i++ 这种复合操作并不能保证原子性。这句话的意思基本上就是说 volatile 不具备原子性了。
Java内存的可见性问题
Java的内存模型如下图所示。
这里的本地内存并不是真实存在的,只是Java内存模型的一个抽象概念,它包含了控制器、运算器、缓 存等。同时Java内存模型规定,线程对共享变量的操作必须在自己的本地内存中进行,不能直接在主内 存中操作共享变量。这种内存模型会出现什么问题呢?
- 线程A获取到共享变量X的值,此时本地内存A中没有X的值,所以加载主内存中的X值并缓存到本地内存A中,线程A修改X的值为1,并将X的值刷新到主内存中,这时主内存及本地内存A中的X的值都为1。
- 线程B需要获取共享变量X的值,此时本地内存B中没有X的值,加载主内存中的X值并缓存到本地内存B中,此时X的值为1。线程B修改X的值为2,并刷新到主内存中,此时主内存及本地内存B中的X值为2,本地内存A中的X值为1。
- 线程A再次获取共享变量X的值,此时本地内存中存在X的值,所以直接从本地内存中A获取到了X为1的值,但此时主内存中X的值为2,到此出现了所谓内存不可见的问题。该问题Java内存模型是通过 synchronized 关键字和 volatile 关键字就可以解决。
为什么代码会重排序?
计算机在执行程序的过程中,编译器和处理器通常会对指令进行重排序,这样做的目的是为了提高性能。具体可以看下面这个例子。
int a = 1;
int b = 2;
int a1 = a;
int b1 = b;
int a2 = a + a;
int b2 = b + b;
像这段代码,不断地交替读取a和b,会导致寄存器频繁交替存储a和b,使得代码性能下降,可对其进入如下重排序:
int a = 1;
int b = 2;
int a1 = a;
int a2 = a + a;
int b1 = b;
int b2 = b + b;
按照这样地顺序执行代码便可以避免交替读取a和b,这就是重排序地意义。
指令重排序一般分为编译器优化重排、指令并行重拍和内存系统重排三种。
- 编译器优化重排:编译器在不改变单线程程序语义的情况下,可以对语句的执行顺序进行重新排序。
- 指令并行重排:现代处理器多采用指令级并行技术来将多条指令重叠执行。对于不存在数据依赖的程序,处理器可以对机器指令的执行顺序进行重新排列。
- 内存系统重排:因为处理器使用缓存和读/写缓冲区,使得加载(load)和存储(store)看上去像是在乱序执行。
注:简单解释下数据依赖性:如果两个操作访问了同一个变量,并且这两个操作有一个是写操作,这两个操作之间就会存在数据依赖性,例如:
a = 1;
b = a;
如果对这两个操作的执行顺序进行重排序的话,那么结果就会出现问题。
其实,这三种指令重排说明了一个问题,就是指令重排在单线程下可以提高代码的性能,但在多线程下可以会出现一些问题 。
重排序会引发什么问题?
前面已经说过了,在单线程程序中,重排序并不会影响程序的运行结果,而在多线程场景下就不一定了。可以看下面这个经典的例子,该示例出自《Java并发编程的艺术》
class ReorderExample{
int a = 0;
boolean flag = false;
public void writer(){
a = 1; // 操作1
flag = true; // 操作2
}
public void reader(){
if(flag){ // 操作3
int i = a + a; // 操作4
}
}
}
假设线程1先执行 writer() 方法,随后线程2执行 reader() 方法,最后程序一定会得到正确的结果吗?
答案是不一定的,如果代码按照下图的执行顺序执行代码则会出现问题。
操作1和操作2进行了重排序,线程1先执行 flag=true ,然后线程2执行操作3和操作4,线程2执行操作4时不能正确读取到 a 的
值,导致最终程序运行结果出问题。这也说明了在多线程代码中,重排序会破坏多线程程序的语义
as-if-serial规则和happens-before规则的区别
区别:
as-if-serial定义:无论编译器和处理器如何进行重排序,单线程程序的执行结果不会改变。
happens-before定义:一个操作happens-before另一个操作,表示第一个的操作结果对第二个操作可见,并且第一个操作的
执行顺序也在第二个操作之前。但这并不意味着Java虚拟机必须按照这个顺序来执行程序。如果重排序的后的执行结果与按
happens-before关系执行的结果一致,Java虚拟机也会允许重排序的发生。happens-before关系保证了同步的多线程程序的
执行结果不被改变,as-if-serial保证了单线程内程序的执行结果不被改变。
相同点:happens-before和as-if-serial的作用都是在不改变程序执行结果的前提下,提高程序执行的并行度。
voliatile的实现原理?
前面已经讲述 volatile 具备可见性和有序性两大特性,所以 volatile 的实现原理也是围绕如何实现可见性和有序性展开的
volatile实现内存可见性原理
导致内存不可见的主要原因就是Java内存模型中的本地内存和主内存之间的值不一致所导致,例如上面所说线程A访问自己本地内存A的X值时,但此时主内存的X值已经被线程B所修改,所以线程A所访问到的值是一个脏数据。那如何解决这种问题呢?
volatile 可以保证内存可见性的关键是 volatile 的读/写实现了缓存一致性,缓存一致性的主要内容为:
- 每个处理器会通过嗅探总线上的数据来查看自己的数据是否过期,一旦处理器发现自己缓存对应的内存地址被修改,就会将当前处理器的缓存设为无效状态。此时,如果处理器需要获取这个数据需重新从主内存将其读取到本地内存。
- 当处理器写数据时,如果发现操作的是共享变量,会通知其他处理器将该变量的缓存设为无效状态。
那缓存一致性是如何实现的呢?可以发现通过 volatile 修饰的变量,生成汇编指令时会比普通的变量多出一个 Lock 指令,这个 Lock 指令就是 volatile 关键字可以保证内存可见性的关键,它主要有两个作用:
- 将当前处理器缓存的数据刷新到主内存。
- 刷新到主内存时会使得其他处理器缓存的该内存地址的数据无效
volatile实现有序性原理
前面提到重排序可以提高代码的执行效率,但在多线程程序中可以导致程序的运行结果不正确,那 volatile 是如何解决这一问题的呢?
- 为了实现 volatile 的内存语义,编译器在生成字节码时会通过插入内存屏障来禁止指令重排序。
- 内存屏障:内存屏障是一种CPU指令,它的作用是对该指令前和指令后的一些操作产生一定的约束,保证一些操作按顺序执行 ,防止发生指令重排列。
Java虚拟机插入内存屏障的策略
Java内存模型把内存屏障分为4类,如下表所示:
注:StoreLoad Barriers同时具备其他三个屏障的作用,它会使得该屏障之前的所有内存访问指令完成之后,才会执行该屏障之后的内存访问命令。
Java内存模型对编译器指定的 volatile 重排序规则为:
- 当第一个操作是 volatile 读时,无论第二个操作是什么都不能进行重排序。
- 当第二个操作是 volatile 写时,无论第一个操作是什么都不能进行重排序。
- 当第一个操作是 volatile 写,第二个操作为 volatile 读时,不能进行重排序。
根据 volatile 重排序规则,Java内存模型采取的是保守的屏障插入策略, volatile 写是在前面和后面分别插入内存屏障, volatile 读是在后面插入两个内存屏障,具体如下:
- volatile 读:在每个 volatile 读后面分别插入LoadLoad屏障及LoadStore屏障(根据volatile 重排序规则第一条),如下图所示 :
LoadLoad屏障的作用:禁止上面的所有普通读操作和上面的 volatile 读操作进行重排序。
LoadStore屏障的作用:禁止下面的普通写和上面的 volatile 读进行重排序。
- volatile 写:在每个 volatile 写前面插入一个StoreStore屏障(为满足 volatile 重排序规则第二条),在每个 volatile 写后面插入一个StoreLoad屏障(为满足 volatile 重排序规则第三条),如下图所示
StoreStore屏障的作用:禁止上面的普通写和下面的 volatile 写重排序
StoreLoad屏障的作用:防止上面的 volatile 写与下面可能出现的 volatile 读/写重排序。
编译器对内存屏障插入策略的优化
因为Java内存模型所采用的屏障插入策略比较保守,所以在实际的执行过程中,只要不改变volatile 读/写的内存语义,编译器通常会省略一些不必要的内存屏障。
代码演示
public class volatileBarrierDemo{
int a;
volatile int b = 1;
volatile int c = 2;
public void test(){
int i = b; //`volatile`读
int j = c; //`volatile`读
a = i + j; //普通写
}
}
指令序列示意图如下:
从上图可以看出,通过指令优化一共省略了两个内存屏障(虚线表示),省略第一个内存屏障LoadStore的原因是最后的普通写
不可能越过第二个 volatile 读,省略第二个内存屏障LoadLoad的原因是下面没有涉及到普通读的操作。
volatile能使一个非原子操作变成一个原子操作吗?
volatile 只能保证可见性和有序性,但可以保证64位的 long 型和 double 型变量的原子性。
对于32位的虚拟机来说,每次原子读写都是32位的,会将 long 和 double 型变量拆分成两个32位的操作来执行,这样 long 和 double 型变量的读写就不能保证原子性了,而通过 volatile 修饰的long和double型变量则可以保证其原子性。
volatile、synchronized的区别?
- volatile 主要是保证内存的可见性,即变量在寄存器中的内存是不确定的,需要从主存中读取。
- synchronized 主要是解决多个线程访问资源的同步性。
- volatile 作用于变量, synchronized 作用于代码块或者方法。
- volatile 仅可以保证数据的可见性,不能保证数据的原子性。 synchronized 可以保证数据的可见性和原子性。
- volatile 不会造成线程的阻塞, synchronized 会造成线程的阻塞
ConcurrentHashMap
什么是ConcurrentHashMap?相比于HashMap和HashTable有什么优势
CocurrentHashMap 可以看作线程安全且高效的 HashMap ,相比于 HashMap 具有线程安全的优势,相比于 HashTable 具有效率高的优势。
java中ConcurrentHashMap是如何实现的?
这里经常会将jdk1.7中的 ConcurrentHashMap 和jdk1.8中的 ConcurrentHashMap 的实现方式进行对比。
JDK1.7
在JDK1.7版本中, ConcurrentHashMap 的数据结构是由一个 Segment 数组和多个 HashEntry 数组组成, Segment 存储的是链表数组的形式,如图所示。
从上图可以看出, ConcurrentHashMap 定位一个元素的过程需要两次Hash的过程,第一次Hash的目的是定位到Segment,第二次Hash的目的是定位到链表的头部。第二次Hash所使用的时间比一次Hash的时间要长,但这样做可以在写操作时,只对元素所在的segment枷锁,不会影响到其他segment,这样可以大大提高并发能力。
JDK1.8
JDK1.8不在采用segment的结构,而是使用Node数组+链表/红黑树的数据结构来实现的(和 HashMap一样,链表节点个数大于8,链表会转换为红黑树) 如下图所示 :
从上图可以看出,对于 ConcurrentHashMap 的实现,JDK1.8的实现方式可以降低锁的粒度,因为JDLK1.7所实现的 ConcurrentHashMap 的锁的粒度是基于Segment,而一个Segment包含多个HashEntry
ConcurrentHashMap结构中变量使用volatile和final修饰有什么作用?
final 修饰变量可以保证变量不需要同步就可以被访问和共享, volatile 可以保证内存的可见性,配合CAS操作可以在不加锁的前提支持并发。
ConcurrentHashMap有什么缺点?
因为 ConcurrentHashMap 在更新数据时只会锁住部分数据,并不会将整个表锁住,读取的时候也并不能保证读取到最近的更新,只能保证读取到已经顺利插入的数据
ConcurrentHashMap默认初始容量是多少?每次扩容为原来的几倍?
默认的初始容量为16,每次扩容为之前的两倍。
ConCurrentHashMap 的key,value是否可以为null?为什么?HashMap中的key、value是否可以为null?
ConCurrentHashMap 中的 key 和 value 为 null 会出现空指针异常,而 HashMap 中的 key 和 value 值是可以为 null 的。
原因如下: ConCurrentHashMap 是在多线程场景下使用的,如果 ConcurrentHashMap.get(key) 的值为 null ,那么无法判断到底是 key 对应的 value 的值为 null 还是不存在对应的 key 值。而在单线程场景下的 HashMap 中,可以使用 containsKey(key) 来判断到底是不存在这个 key 还是 key 对应的value 的值为 null 。在多线程的情况下使用 containsKey(key) 来做这个判断是存在问题的,因为在containsKey(key) 和 ConcurrentHashMap.get(key) 两次调用的过程中, key 的值已经发生了改变。
ConCurrentHashmap在JDK1.8中,什么情况下链表会转化为红黑树?
当链表长度大于8,Node数组数大于64时 。
ConcurrentHashMap在JDK1.7和JDK1.8版本中的区别?
实现结构上的不同,JDK1.7是基于Segment实现的,JDK1.8是基于Node数组+链表/红黑树实现的。
保证线程安全方面:JDK1.7采用了分段锁的机制,当一个线程占用锁时,会锁住一个Segment对象,不会影响其他Segment对
象。JDK1.8则是采用了CAS和 synchronize 的方式来保证线程安全。
在存取数据方面:
JDK1.7中的 put() 方法:
- 先计算出 key 的 hash 值,利用 hash 值对segment数组取余找到对应的segment对象。
- 尝试获取锁,失败则自旋直至成功,获取到锁,通过计算的 hash 值对hashentry数组进行取余,找到对应的entry对象。
- 遍历链表,查找对应的 key 值,如果找到则将旧的value直接覆盖,如果没有找到,则添加到链表中。(JDK1.7是插入到链表头部,JDK1.8是插入到链表尾部,这里可以思考一下为什么这样)
JDK1.8中的 put() 方法:
- 计算 key 值的 hash 值,找到对应的 Node ,如果当前位置为空则可以直接写入数据。
- 利用CAS尝试写入,如果失败则自旋直至成功,如果都不满足,则利用 synchronized 锁写入数据
ConcurrentHashMap迭代器是强一致性还是弱一致性?
与HashMap不同的是, ConcurrentHashMap 迭代器是弱一致性。
这里解释一下弱一致性是什么意思,当 ConcurrentHashMap 的迭代器创建后,会遍历哈希表中的元素,在遍历的过程中,哈希表中的元素可能发生变化,如果这部分变化发生在已经遍历过的地方,迭代器则不会反映出来,如果这部分变化发生在未遍历过的地方,迭代器则会反映出来。换种说法就是put() 方法将一个元素加入到底层数据结构后, get() 可能在某段时间内还看不到这个元素。这样的设计主要是为 ConcurrenthashMap 的性能考虑,如果想做到强一致性,就要到处加锁,性能会下降很多。所以 ConcurrentHashMap 是支持在迭代过程中,向map中添加元素的,而 HashMap 这样操作则会抛出异常
ThreadLocal
什么是ThreadLocal?有哪些应用场景?
ThreadLocal 是 JDK java.lang 包下的一个类, ThreadLocal 为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量,并且不会和其他线程的局部变量冲突,实现了线程间的数据隔离。
ThreadLocal 的应用场景主要有以下几个方面:
- 保存线程上下文信息,在需要的地方可以获取。
- 线程间数据隔离
- 数据库连接
ThreadLocal原理和内存泄露?
ThreadLocal 的原理可以概括为下图:
从上图可以看出每个线程都有一个 ThreadLocalMap , ThreadLocalMap 中保存着所有的ThreadLocal ,而 ThreadLocal 本身只是一个引用本身并不保存值,值都是保存在 ThreadLocalMap中的,其中 ThreadLocal 为 ThreadLocalMap 中的 key 。其中图中的虚线表示弱引用。
这里简单说下Java中的引用类型,Java的引用类型主要分为强引用、软引用、弱引用和虚引用。
- 强引用:发生 gc 的时候不会被回收。
- 软引用:有用但不是必须的对象,在发生内存溢出之前会被回收。
- 弱引用:有用但不是必须的对象,在下一次GC时会被回收。
- 虚引用:无法通过虚引用获得对象,虚引用的用途是在 gc 时返回一个通知。
为什么ThreadLocal会发生内存泄漏呢?
- 因为 ThreadLocal 中的 key 是弱引用,而 value 是强引用。当 ThreadLocal 没有被强引用时,在进行垃圾回收时, key 会被清理掉,而 value 不会被清理掉,这时如果不做任何处理, value 将永远不会被回收,产生内存泄漏。
如何解决ThreadLocal的内存泄漏?
- 其实在 ThreadLocal 在设计的时候已经考虑到了这种情况,在调用 set() 、 get() 、 remove() 等方法时就会清理掉 key 为 null 的记录,所以在使用完 ThreadLocal 后最好手动调用 remove() 方法。
为什么要将key设计成ThreadLocal的弱引用?
- 如果 ThreadLocal 的 key 是强引用,同样会发生内存泄漏的。如果 ThreadLocal 的 key 是强引用,引用的 ThreadLocal 的对象被回收了,但是 ThreadLocalMap 还持有 ThreadLocal 的强引用,如果没有手动删除, ThreadLocal 不会被回收,发生内存泄漏。
- 如果是弱引用的话,引用的 ThreadLocal 的对象被回收了,即使没有手动删除, ThreadLocal 也会被回收。 value 也会在 ThreadLocalMap 调用 set() 、 get() 、 remove() 的时候会被清除。
- 所以两种方案比较下来,还是 ThreadLoacl 的 key 为弱引用好一些
线程池
什么是线程池?为什么使用线程池
线程池是一种多线程处理形式,处理过程中将任务提交到线程池,任务的执行交给线程池来管理。
为什么使用线程池?
- 降低资源消耗,通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度,当任务到达时,任务可以不需要等到线程创建就立即执行。
- 提高线程的可管理性,线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以统一分配。
Java 中的线程池是如何实现的?
在 Java中,所谓的线程池中的“线程”,其实是被抽象为了一个静态内部类 Worker,它基于 AQS 实现,存放在线程池的 HashSet<Worker> workers
成员变量中;
而需要执行的任务则存放在成员变量 workQueue(BlockingQueue<Runnable> workQueue)
中。
这样,整个线程池实现的基本思想就是:从 workQueue 中不断取出需要执行的任务,放在 Workers 中进行处理。
创建线程池的方式
线程池的常用创建方式主要有两种,通过Executors工厂方法创建和通过new ThreadPoolExecutor方法创建。
Executors工厂方法创建,在工具类 Executors 提供了一些静态的工厂方法:
- newSingleThreadExecutor :创建一个单线程的线程池。
- newFixedThreadPool :创建固定大小的线程池。
- newCachedThreadPool :创建一个可缓存的线程池。
- newScheduledThreadPool :创建一个大小无限的线程池。
new ThreadPoolExecutor 方法创建:
new ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
ThreadPoolExecutor构造函数的重要参数分析
三个比较重要的参数:
- corePoolSize :核心线程数,定义了最小可以同时运行的线程数量。
- maximumPoolSize :线程中允许存在的最大工作线程数量
- workQueue :存放任务的阻塞队列。新来的任务会先判断当前运行的线程数是否到达核心线程数,如果到达的话,任务就会先放到阻塞队列。
其他参数:
- keepAliveTime :当线程池中的数量大于核心线程数时,如果没有新的任务提交,核心线程外的线程不会立即销毁,而是会等到时间超过 keepAliveTime 时才会被销毁。
- unit : keepAliveTime 参数的时间单位。
- threadFactory :为线程池提供创建新线程的线程工厂。
- handler :线程池任务队列超过 maxinumPoolSize 之后的拒绝策略
线程池中的线程是怎么创建的?是一开始就随着线程池的启动创建好的吗?
显然不是的。线程池默认初始化后不启动 Worker,等待有请求时才启动。
每当我们调用 execute() 方法添加一个任务时,线程池会做如下判断:
- 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
- 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列;
- 如果这时候队列满了,而且正在运行的线程数量小于 maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务;
- 如果队列满了,而且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会抛出异常 RejectExecutionException。
- 当一个线程完成任务时,它会从队列中取下一个任务来执行。
- 如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。
ThreadPoolExecutor的饱和策略(拒绝策略 )
当同时运行的线程数量达到最大线程数量并且阻塞队列也已经放满了任务时, ThreadPoolExecutor 会指定一些饱和策略。主要有以下四种类型:
- AbortPolicy 策略:该策略会直接抛出异常拒绝新任务
- CallerRunsPolicy 策略:当线程池无法处理当前任务时,会将该任务交由提交任务的线程来执行。
- DiscardPolicy 策略:直接丢弃新任务。
- DiscardOleddestPolicy 策略:丢弃最早的未处理的任务请求。
线程池的执行流程
创建线程池创建后提交任务的流程如下图所示:
如何在 Java 线程池中提交线程?
线程池最常用的提交任务的方法有两种:
- execute():ExecutorService.execute 方法接收一个 Runable 实例,它用来执行一个任务:
- submit():ExecutorService.submit() 方法返回的是 Future 对象。可以用 isDone() 来查询 Future 是否已经完成,当任务完成时,它具有一个结果,可以调用 get() 来获取结果。也可以不用 isDone() 进行检查就直接调用 get(),在这种情况下,get() 将阻塞,直至结果准备就绪。
如果你提交任务时,线程池队列已满,这时会发生什么?
- 如果你使用的LinkedBlockingQueue,也就是无界队列列的话,没关系,继续添加任务到阻塞队列中等待执行,因为 LinkedBlockingQueue可以近乎认为是一个无穷大的队列,可以无限存放任务;
- 如果你使用的是有界队列比方说ArrayBlockingQueue的话,任务首先会被添加到ArrayBlockingQueue中,ArrayBlockingQueue满了了,则会使用拒绝策略RejectedExecutionHandler处理理满了的任务,默认是AbortPolicy。
execute()方法和submit()方法的区别
这个地方首先要知道Runnable接口和Callable接口的区别,之前有写到过
execute() 和 submit() 的区别主要有两点:
- execute() 方法只能执行 Runnable 类型的任务。 submit() 方法可以执行 Runnable 和Callable 类型的任务。
- submit() 方法可以返回持有计算结果的 Future 对象,同时还可以抛出异常,而 execute() 方法不可以。
- 换句话说就是, execute() 方法用于提交不需要返回值的任务, submit() 方法用于需要提交返回值的 任务。
既然提到可以通过配置不同参数创建出不同的线程池,那么 Java 中默认实现好的线程池又有哪些呢?请比较它们的异同。
SingleThreadExecutor 线程池
这个线程池只有一个核心线程在工作,也就是相当于单线程串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行。
- `corePoolSize:1`,只有一个核心线程在工作。
- `maximumPoolSize`:1。
- `keepAliveTime`:0L。
- `workQueue:new LinkedBlockingQueue<Runnable>()`,其缓冲队列是无界的。
FixedThreadPool 线程池
FixedThreadPool 是固定大小的线程池,只有核心线程。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。
FixedThreadPool 多数针对一些很稳定很固定的正规并发线程,多用于服务器。
- corePoolSize:nThreads
- maximumPoolSize:nThreads
- keepAliveTime:0L
- workQueue:new LinkedBlockingQueue<Runnable>(),其缓冲队列是无界的。
CachedThreadPool 线程池
CachedThreadPool 是无界线程池,如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60 秒不执行任务)线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务。
线程池大小完全依赖于操作系统(或者说 JVM)能够创建的最大线程大小。SynchronousQueue 是一个是缓冲区为 1 的阻塞队列。 缓存型池子通常用于执行一些生存期很短的异步型任务,因此在一些面向连接的 daemon 型 SERVER 中用得不多。但对于生存期短的异步任务,它是 Executor 的首选。
- corePoolSize:0
- maximumPoolSize:Integer.MAX_VALUE
- keepAliveTime:60L
- workQueue:new SynchronousQueue<Runnable>(),一个是缓冲区为 1 的阻塞队列。
ScheduledThreadPool 线程池
ScheduledThreadPool:核心线程池固定,大小无限的线程池。此线程池支持定时以及周期性执行任务的需求。创建一个周期性执行任务的线程池。如果闲置,非核心线程池会在 DEFAULT_KEEPALIVEMILLIS 时间内回收。
- corePoolSize:corePoolSize
- maximumPoolSize:Integer.MAX_VALUE
- keepAliveTime:DEFAULT_KEEPALIVE_MILLIS
- workQueue:new DelayedWorkQueue()