一、线程基础
1.1 Callable接口实现线程
Callable接口的作用
Callable是类似于Runnable的接口,实现Callable接口的类和实现Runnable的类都是可被其它线程执行的任务;
Callable和Runnable的区别
1、Callable的任务执行后可返回值,而Runnable的任务是不能返回值的
2、call()方法可抛出异常,而run()方法是不能抛出异常的
3、运行Callable任务可拿到一个Future对象
什么是Future
Future表示一个任务的生命周期,并提供方法判断是否已经完成或取消,以及获取任务的结果和取消任务等。是JUC中的一个接口,它代表着一个异步执行结果。
public interface Future<V> {
/** 取消,mayInterruptIfRunning-false:不允许在线程运行时中断 **/
boolean cancel(boolean mayInterruptIfRunning);
/** 是否取消**/
boolean isCancelled();
/** 是否完成 **/
boolean isDone();
/** 同步获取结果 **/
V get() throws InterruptedException, ExecutionException;
/** 同步获取结果,响应超时 **/
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException;
}
注意:
1、cancel方法,参数为true表示立即中断任务的执行,参数为false表示允许正在运行的任务运行完成
2、get方法,
1)如果任务已完成,那么get会立即返回或抛出一个Exception;
2)如果任务没有完成,那么get将阻塞并直到任务完成;
3)如果任务抛出了异常那么get将该异常封装为ExecutionException并重新抛出;
4)如果任务被取消那么get将抛出CancellationException;
5)如果get抛出了ExecutionExecption那么可以通过getCause获得被封装的初始异常
Callable接口的使用案例
/**
* 主测试类
*/
public class Test1 {
public static void main(String[] args) {
//创建MyCallable对象,并且封装到FutureTask对象中
FutureTask<String> futureTask = new FutureTask<>(new MyCallable());
//开启子线程
new Thread(futureTask).start();
//获取子线程的返回值
try {
String result = futureTask.get();
System.out.println("获得子线程的返回值:" + result);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}
/**
* 创建一个类实现Callable接口,泛型为线程返回值类型
*/
class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
System.out.println("子线程开始执行" + Thread.currentThread().getName());
Thread.sleep(5000);
System.out.println("子线程执行结束" + Thread.currentThread().getName());
return "Hello";
}
}
1.2 线程状态
Java中的线程状态
public enum State {
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED;
}
NEW - 新建状态,还未调用start方法开启线程
RUNNABLE - 可运行状态,包括就绪和运行中
BLOCKED - 阻塞状态,等待获取锁时的一个状态,当抢到锁之后,才会从blocked状态恢复到runnable状态
WAITING - 等待状态,通过wait方法进入的状态
TIMED_WAITING - 限期等待状态,通过sleep或者wait-timeout方法进入的状态,当时间达到时,线程回到工作状态Runnable
TERMINATED - 终止状态,线程执行完成后的状态
1.3 Thread.sleep
sleep就是正在执行的线程主动让出CPU,CPU去执行其他线程,在sleep指定的时间过后,CPU才会回到这个线程上继续往下执行,如果当前线程进入了同步锁,sleep方法并不会释放锁,即使当前线程使用sleep方法让出了CPU,但其他被同步锁挡住了的线程也无法得到执行
常见问题
Thread.sleep(1000),1000ms后是否立即执行?
不一定,在未来的1000毫秒内,线程不想再参与到CPU竞争。那么1000毫秒过去之后,这时候也许另外一个线程正在使用CPU,那么这时候操作系统是不会重新分配CPU的,直到那个线程挂起或结束;况且,即使这个时候恰巧轮到操作系统进行CPU 分配,那么当前线程也不一定就是总优先级最高的那个,CPU还是可能被其他线程抢占去
Thread.sleep(0),是否有用?
Thread.Sleep(0)的作用,就是“触发操作系统立刻重新进行一次CPU竞争,重新计算优先级”。竞争的结果也许是当前线程仍然获得CPU控制权,也许会换成别的线程获得CPU控制权。
wait和sleep区别
- sleep是Thread的方法,wait是Object的方法
- wait会释放锁,如果在同步锁类sleep内不释放锁
- wait方法需要在synchronize块或者synchronize方法里调用,然而sleep不需要
- 如果需要线程停顿,使用sleep;使用wait进行线程间的通信
1.4 线程中断 - Interruption
什么是线程中断?
Java没有提供一种安全直接的方法来停止某个线程,但是Java提供了中断机制(Interruption),这是一种协作机制,能够使一个线程终止另一个线程的当前工作;
注意:Java中断机制是一种协作机制,也就是说通过中断并不能直接终止另一个线程,而需要被中断的线程自己处理中断。
Interruption机制相关的API
//测试当前线程是否已经中断。线程的中断状态由该方法清除。换句话说,如果连续两次调用该方法,则第二次调用将返回 false(在第一次调用已清除了其中断状态之后,且第二次调用检验完中断状态前,当前线程再次中断的情况除外)
public static boolean interrupted();
//测试线程是否已经中断。线程的中断状态不受该方法的影响
public boolean isInterrupted();
//中断线程,interrupt方法是唯一能将中断状态设置为true的方法
public void interrupt();
InterruptedException - 中断异常
当可能阻塞的方法声明中有抛出InterruptedException则暗示该方法是可中断的,如 Thread.sleep() 、Thread.wait()方法等。
这些方法都会检查线程合适中断,并且在发现中断时,提前返回,并且抛出InterruptedException异常。
响应中断
如果程序抛出中断异常时,通常的处理方式:
1、传递异常,使当前方法也成为可中断的
2、恢复中断状态,使得调用栈中的上层代码能够对其进行处理
注意
1、如果线程由于执行同步的Socket I/O或者等待获得内置锁而阻塞,那么中断请求只能设置线程的中断状态,除此之外没有其他任何作用;
2、如果线程在等待获取某个锁时,由于线程等待某个内置锁,它会认为自己能等到,所以不会处理中断,通过Lock的lockInterruptibly可以同时实现等待锁并且响应中断
开发者自定义中断逻辑
public void run() {
try {
// 检查程序是否发生中断
while (!Thread.interrupted()) {
//程序的运行逻辑
}
} catch (InterruptedException e) {
}
}
1.5 线程池
1.5.1 线程池顶级接口 - Executor
public interface Executor {
void execute(Runnable command);
}
1.5.2 ExecutorService接口
为了解决执行服务的生命周期问题,Executor扩展了ExecutorService接口;
ExecutorService的生命周期有三种状态:运行、关闭和已终止;
相关api
//相关API
/*
将线程池状态置为SHUTDOWN,并不会立即停止:
停止接收外部submit的任务
内部正在跑的任务和队列里等待的任务,会执行完
等到第二步完成后,才真正停止
*/
shutdown();
/*
将线程池状态置为STOP。企图立即停止,事实上不一定:
跟shutdown()一样,先停止接收外部提交的任务
忽略队列里等待的任务
尝试将正在跑的任务interrupt中断
返回未执行的任务列表
*/
shutdownNow();
/*
当前线程阻塞,直到:
等所有已提交的任务(包括正在跑的和队列中等待的)执行完
或者等超时时间到
或者线程被中断,抛出InterruptedException
然后返回true(shutdown请求后所有任务执行完毕)或false(已超时)
*/
awaitTermination(long timeOut, TimeUnit unit);
1.5.3 Executors线程池工具类
主要方法
/*
重用指定数目(nThreads)的线程,其背后使用的是无界的工作队列,任何时候最多有 nThreads 个工作线程是活动的。这意味着,如果任务数量超过了活动队列数目,将在工作队列中等待空闲线程出现;如果有工作线程退出,将会有新的工作线程被创建,以补足指定的数目 nThreads
*/
newFixedThreadPool(int nThreads);
/*
它的特点在于工作线程数目被限制为 1,操作一个无界的工作队列,所以它保证了所有任务的都是被顺序执行,最多会有一个任务处于活动状态,并且不允许使用者改动线程池实例,因此可以避免其改变线程数目
*/
newSingleThreadExecutor();
/*
它是一种用来处理大量短时间工作任务的线程池,具有几个鲜明特点:它会试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程;如果线程闲置的时间超过 60 秒,则被终止并移出缓存;长时间闲置时,这种线程池,不会消耗什么资源。其内部使用 SynchronousQueue 作为工作队列
*/
newCachedThreadPool();
/*
该方法返回一个ScheduledExecutorService对象,线程大小为1;ScheduledExecutorService接口在ExecutoService接口之上扩展了再给定时间执行某任务的功能,如在固定的延时之后执行,或者周期性执行某个任务;
*/
newSingleThreadScheduledExecutor();
/*
返回一个ScheduledExecutorService对象,但该线程池可以指定线程数量;
*/
newScheduledThreadPool(int corePoolSize);
/*
Java 8 才加入这个创建方法,其内部会构建ForkJoinPool,利用Work-Stealing算法,并行地处理任务,不保证处理顺序;
*/
newWorkStealingPool(int parallelism);
1.5.4 ThreadPoolExecutor参数
/**
* corePoolSize - 核心线程数,当提交一个任务时,线程池创建一个新线程执行任务,直到当前线程数等于corePoolSize;如果当前线 * 程数为corePoolSize,继续提交的任务被保存到阻塞队列中,等待被执行;如果执行了线程池的prestartAllCoreThreads()方法,线* 程池会提前创建并启动所有核心线程;
*
* maximumPoolSize - 最大线程数,线程池中允许的最大线程数。如果当前阻塞队列满了,且继续提交任务,则创建新的线程执行任务,前* 提是当前线程数小于maximumPoolSize;
*
* keepAliveTime - 线程空闲时的存活时间,即当线程没有任务执行时,继续存活的时间;默认情况下,该参数只在线程数大于 * corePoolSize时才有用;
*
* unit - 单位
*
* workQueue - 用来保存等待被执行的任务的阻塞队列,且任务必须实现Runable接口,在JDK中提供了如下阻塞队列:
* - ArrayBlockingQueue:基于数组结构的有界阻塞队列,按FIFO排序任务
* - LinkedBlockingQuene:基于链表结构的阻塞队列,按FIFO排序任务,吞吐量通常要高于ArrayBlockingQuene
* - SynchronousQuene:一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,
* 否则插入操作一直处于阻塞状态
* - PriorityBlockingQuene:具有优先级的无界阻塞队列
*
* threadFactory - 创建线程的工厂,通过自定义的线程工厂可以给每个新建的线程设置一个具有识别度的线程名
*
* handler - 拒绝策略
*/
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler);
1.5.4 拒绝策略
1、AbortPolicy:直接抛出异常,默认策略;
2、CallerRunsPolicy:用调用者所在的线程来执行任务;
3、DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
4、DiscardPolicy:直接丢弃任务;
1.5.5 线程数的大小设定
CPU密集型:线程数 = CPU核心数
IO密集型:线程数 = CPU核心数 * [任务执行时间 / (任务执行时间 - IO等待时间)]
最佳启动线程数和CPU内核数量成正比,和IO阻塞时间成反比;
二、并发编程
2.1 并发编程三要素
2.1.1 可见性
当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
可见性问题案例
public class Test2 {
public static boolean flag = true;
public static void main(String[] args) {
System.out.println("主线程开始执行!");
//开启子线程
new Thread(){
@Override
public void run() {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = false;
System.out.println("子线程重新设置flag变量:" + flag);
}
}.start();
while(flag){
}
System.out.println("主线程执行结束!");
}
}
2.1.2 原子性
一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么全部不执行
public class Test3 {
private static int i = 0;
public static void main(String[] args) {
for (int j = 0; j < 1000; j++) {
new Thread(() -> {
i++;
}).start();
}
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("i的结果:" + i);
}
}
在java中,对基本数据类型的变量的读取和赋值操作是原子性操作。
注意:对于32位系统的来说,long类型数据和double类型数据,它们的读写并非原子性的。对于基本数据类型 byte,short,int,float,boolean,char 读写是原子操作。
2.1.3 有序性
程序执行的顺序按照代码的先后顺序执行
指令重排:cpu为了优化考虑,可能会打乱代码的执行顺序。但是cpu可以保证代码在单线程情况下,指令重拍后,不影响原程序的执行结果。
public class Test3 {
private static boolean flag = true;
private static Object obj;
public static void main(String[] args) {
//线程1
while(flag){
}
int i = obj.hashCode();
//线程2
obj = new Object();
flag = false;
}
}
2.2 volatile关键字
volatile关键字的作用
volatile有两层语义:
1、保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的;
2、禁止进行指令重排序;
特性
1)当一个变量被volatile修饰时,任何线程对它的写操作都会立即刷新到主内存中,并且会强制让缓存了该变量的线程中的数据清空,必须从主内存重新读取最新数据;
2)volatile修饰之后并不是让线程直接从主内存中获取数据,依然需要将变量拷贝到工作内存中;
内存屏障
volatile原理是基于CPU内存屏障(Memory Barrier)指令实现的,内存屏障会提供3个功能:
1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
2)它会强制将对缓存的修改操作立即写入主存;
3)如果是写操作,它会导致其他CPU中对应的缓存行无效。
2.3 synchronized关键词
2.3.1 synchronized语义
- 内存可见性:同步块的可见性是由“如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新从主存中加载变量的值”、“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中”这两条规则获得的;
- 操作原子性:持有同一个锁的两个同步块只能串行地进入;
- 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中;
- 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量
2.3.2 synchronized可重入锁
当线程请求一个未被持有的锁时,JVM记录锁的持有者且将计数器置为1,如果同一个线程再次获取这个锁计数器递增;当线程退出同步代码块时,计数器递减;当计数值为0时锁释放;
2.3.3 Java8锁
1、两个普通的锁方法,new一个对象调用,调用过程中间睡1秒,执行结果是什么
public class Test {
public static void main(String[] args) throws InterruptedException {
Fun fun = new Fun();
new Thread(()->{fun.funOne();},"A线程").start();
TimeUnit.SECONDS.sleep(1);
new Thread(()->{fun.funTwo();},"B线程").start();
}
}
class Fun{
public synchronized void funOne(){
System.out.println(Thread.currentThread().getName()+":调用方法一");
}
public synchronized void funTwo(){
System.out.println(Thread.currentThread().getName()+":调用方法二");
}
}
2、两个普通的锁方法,new一个对象调用,调用过程中间睡1秒,且在funOne方法中睡3秒,执行结果是什么
public class Test {
public static void main(String[] args) throws InterruptedException {
Fun fun = new Fun();
new Thread(()->{
try { fun.funOne(); } catch (InterruptedException e) { e.printStackTrace(); }
},"A线程").start();
TimeUnit.SECONDS.sleep(1);
new Thread(()->{fun.funTwo();},"B线程").start();
}
}
class Fun{
public synchronized void funOne() throws InterruptedException {
TimeUnit.SECONDS.sleep(3);
System.out.println(Thread.currentThread().getName()+":调用方法一");
}
public synchronized void funTwo(){
System.out.println(Thread.currentThread().getName()+":调用方法二");
}
}
3、一个普通的锁方法,一个普通无锁方法,new一个对象调用,在funOne方法中睡3秒,执行结果是什么
public class Test {
public static void main(String[] args) throws InterruptedException {
Fun fun = new Fun();
new Thread(()->{
try { fun.funOne(); } catch (InterruptedException e) { e.printStackTrace(); }
},"A线程").start();
TimeUnit.SECONDS.sleep(1);
new Thread(()->{fun.funTwo();},"B线程").start();
}
}
class Fun{
public synchronized void funOne() throws InterruptedException {
TimeUnit.SECONDS.sleep(3);
System.out.println(Thread.currentThread().getName()+":调用方法一");
}
public void funTwo(){
System.out.println(Thread.currentThread().getName()+":调用方法二");
}
}
4、两个普通的锁方法,new两个对象分别调用,在funOne方法中睡3秒,执行结果是什么
public class Test {
public static void main(String[] args) throws InterruptedException {
Fun fun1 = new Fun();
Fun fun2 = new Fun();
new Thread(()->{
try { fun1.funOne(); } catch (InterruptedException e) { e.printStackTrace(); }
},"A线程").start();
TimeUnit.SECONDS.sleep(1);
new Thread(()->{fun2.funTwo();},"B线程").start();
}
}
class Fun{
public synchronized void funOne() throws InterruptedException {
TimeUnit.SECONDS.sleep(3);
System.out.println(Thread.currentThread().getName()+":调用方法一");
}
public synchronized void funTwo(){
System.out.println(Thread.currentThread().getName()+":调用方法二");
}
}
5、两个静态的锁方法,new一个对象调用,在funOne方法中睡3秒,执行结果是什么
public class Test {
public static void main(String[] args) throws InterruptedException {
Fun fun = new Fun();
new Thread(()->{
try { fun.funOne(); } catch (InterruptedException e) { e.printStackTrace(); }
},"A线程").start();
TimeUnit.SECONDS.sleep(1);
new Thread(()->{fun.funTwo();},"B线程").start();
}
}
class Fun{
public static synchronized void funOne() throws InterruptedException {
TimeUnit.SECONDS.sleep(3);
System.out.println(Thread.currentThread().getName()+":调用方法一");
}
public static synchronized void funTwo(){
System.out.println(Thread.currentThread().getName()+":调用方法二");
}
}
6、两个静态的锁方法,new两个对象分别调用,在funOne方法中睡3秒,执行结果是什么
public class Test {
public static void main(String[] args) throws InterruptedException {
Fun fun1 = new Fun();
Fun fun2 = new Fun();
new Thread(()->{
try { fun1.funOne(); } catch (InterruptedException e) { e.printStackTrace(); }
},"A线程").start();
TimeUnit.SECONDS.sleep(1);
new Thread(()->{fun2.funTwo();},"B线程").start();
}
}
class Fun{
public static synchronized void funOne() throws InterruptedException {
TimeUnit.SECONDS.sleep(3);
System.out.println(Thread.currentThread().getName()+":调用方法一");
}
public static synchronized void funTwo(){
System.out.println(Thread.currentThread().getName()+":调用方法二");
}
}
7、一个静态的锁方法,一个普通锁方法,new一个对象调用,在funOne方法中睡3秒,执行结果是什么
public class Test {
public static void main(String[] args) throws InterruptedException {
Fun fun = new Fun();
new Thread(()->{
try { fun.funOne(); } catch (InterruptedException e) { e.printStackTrace(); }
},"A线程").start();
TimeUnit.SECONDS.sleep(1);
new Thread(()->{fun.funTwo();},"B线程").start();
}
}
class Fun{
public static synchronized void funOne() throws InterruptedException {
TimeUnit.SECONDS.sleep(3);
System.out.println(Thread.currentThread().getName()+":调用方法一");
}
public synchronized void funTwo(){
System.out.println(Thread.currentThread().getName()+":调用方法二");
}
}
8、一个静态的锁方法,一个普通锁方法,new两个对象分别调用,在funOne方法中睡3秒,执行结果是什么
public class Test {
public static void main(String[] args) throws InterruptedException {
Fun fun1 = new Fun();
Fun fun2 = new Fun();
new Thread(()->{
try { fun1.funOne(); } catch (InterruptedException e) { e.printStackTrace(); }
},"A线程").start();
TimeUnit.SECONDS.sleep(1);
new Thread(()->{fun2.funTwo();},"B线程").start();
}
}
class Fun{
public static synchronized void funOne() throws InterruptedException {
TimeUnit.SECONDS.sleep(3);
System.out.println(Thread.currentThread().getName()+":调用方法一");
}
public synchronized void funTwo(){
System.out.println(Thread.currentThread().getName()+":调用方法二");
}
}
2.3.4 synchronized锁原理
对象头
对象大致可以分为三个部分,分别是对象头、实例变量和填充字节。
对象头 - 是由MarkWord和Klass Point(类型指针)组成,其中Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据。如果对象是数组对象,那么对象头占用3个字宽(Word),如果对象是非数组对象,那么对象头占用2个字宽。(1word = 8 Byte = 64 bit)
实例变量 - 存储的是对象的属性信息,包括父类的属性信息,按照4字节对齐
填充字符 - 因为虚拟机要求对象字节必须是8字节的整数倍,填充字符就是用于凑齐这个整数倍的
MarkWord结构
Mark Word:默认存储对象的 HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以 Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间 Mark Word 里存储的数据会随着锁标志位的变化而变化。
2.3.5 锁升级
什么是锁升级?
Java SE1.6为了减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”,所以在Java SE1.6里锁一共有四种状态:
无锁,偏向锁,轻量级锁和重量级锁,它会随着竞争情况逐渐升级。
锁可以升级不可以降级,但是偏向锁状态可以被重置为无锁状态,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率
偏向锁
实际业务中,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。
当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
轻量级锁
轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋等待锁释放。
线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。
重量级锁
重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。
重量级锁通过对象内部的监视器(monitor)实现,而其中 monitor 的本质是依赖于底层操作系统的 Mutex Lock 实现,操作系统实现线程之间的切换需要从用户态切换到内核态,切换成本非常高。
简言之,就是所有的控制权都交给了操作系统,由操作系统来负责线程间的调度和线程的状态变更。而这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资源,导致性能低下。
锁的对比
2.3.6 锁的粗化与消除
锁粗化
按理来说,同步块的作用范围应该尽可能小,仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
但是加锁解锁也需要消耗资源,如果存在一系列的连续加锁解锁操作,可能会导致不必要的性能损耗。
锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。
锁消除
Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间
2.4 Lock锁
2.4.1 ReentrantLock
ReentrantLock介绍(重入锁)
ReentrantLock主要利用CAS+CLH队列来实现。它支持公平锁和非公平锁,两者的实现类似
使用方法
Lock lock = new ReentrantLock();
lock.lock();
try {
....
} catch (Exception e) {
} finally {
lock.unlock();
}
2.4.2 ReadWriteLock
ReadWriteLock介绍(读写锁)
读读共享:读读之间不阻塞;
读写互斥:读阻塞写,写也会阻塞读;
写写互斥:写写阻塞;
使用方法
ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
//获取读锁
Lock readLock = readWriteLock.readLock();
//获取写锁
Lock writeLock = readWriteLock.writeLock();
2.4.3 StampedLock
StampedLock介绍
StampedLock是为了优化可重入读写锁性能的一个锁实现工具,jdk8开始引入,相比于普通的ReentranReadWriteLock主要多了一种乐观读的功能
提供锁的类型
写锁、悲观读锁和乐观读
写锁、悲观读锁的语义和 ReadWriteLock 的写锁、读锁的语义非常类似,允许多个线程同时获取悲观读锁,但是只允许一个线程获取写锁。但是StampedLock 里的写锁和悲观读锁加锁成功之后,都会返回一个 stamp;然后解锁的时候,需要传入这个 stamp。
StampedLock 提供的乐观读,是允许一个线程获取写锁的,也就是说不是所有的写操作都被阻塞。
使用方法
//写锁
StampedLock lock = new StampedLock();
long stamp = lock.writeLock();
try {
//业务代码
} finally {
lock.unlockWrite(stamp);
}
//悲观读锁
StampedLock lock = new StampedLock();
long stamp2 = lock.readLock();
try {
} finally {
lock.unlockRead(stamp2);
}
//乐观读
StampedLock lock = new StampedLock();
long stamp3 = lock.tryOptimisticRead();
//读取数据
//判断是否存在写锁
if (!lock.validate(stamp3)) {
//存在写锁,则获取悲观读锁
stamp3 = lock.readLock();
try {
//再次读取数据
} finally {
lock.unlockRead(stamp3);
}
}
注意:
StampedLock不支持重入( Reentrant)
三、线程间通讯
什么是线程间通讯?
因为线程调度的不确定性,某些业务可能需要多个线程协调一起工作,这时就需要线程间通讯了。
3.1 wait/notify/notifyAll
注意事项
1)必须写在synchronized代码块内
2)wait、notify方法必须由同步锁对象调用
3.2 Condition
Condition是在java 1.5中才出现的,它用来替代传统的Object的wait()、notify()实现线程间的协作,相比使用Object的wait()、notify(),使用Condition的await()、signal()这种方式实现线程间协作更加安全和高效。因此通常来说比较推荐使用Condition,阻塞队列实际上是使用了Condition来模拟线程间协作。
await -> Object.wait
signal -> Object.notify
signalAll -> Object.notifyAll
使用方式
Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();
//等待
try {
condition.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
//唤醒
condition.signal();
condition.signalAll();
3.3 BlockQueue
什么是阻塞队列?
阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作是:在队列为空时,获取元素的线程会等待队列变为非空。当队列满时,存储元素的线程会等待队列可用。
阻塞队列的相关方法
方法\处理方式 | 抛出异常 | 返回特殊值 | 一直阻塞 | 超时退出 |
插入方法 | add(e) | offer(e) | put(e) | offer(e,time,unit) |
移除方法 | remove() | poll() | take() | poll(time,unit) |
检查方法 | element() | peek() | 不可用 | 不可用 |
阻塞队列的API介绍
public interface BlockingQueue<E> extends Queue<E> {
//将给定元素设置到队列中,如果设置成功返回true, 否则抛出异常。如果是往限定了长度的队列中设置值,推荐使用offer()方法。
boolean add(E e);
//将给定的元素设置到队列中,如果设置成功返回true, 否则返回false. e的值不能为空,否则抛出空指针异常。
boolean offer(E e);
//将元素设置到队列中,如果队列中没有多余的空间,该方法会一直阻塞,直到队列中有多余的空间。
void put(E e) throws InterruptedException;
//将给定元素在给定的时间内设置到队列中,如果设置成功返回true, 否则返回false.
boolean offer(E e, long timeout, TimeUnit unit)
throws InterruptedException;
//从队列中获取值,如果队列中没有值,线程会一直阻塞,直到队列中有值,并且该方法取得了该值。
E take() throws InterruptedException;
//在给定的时间里,从队列中获取值,如果没有取到会抛出异常。
E poll(long timeout, TimeUnit unit)
throws InterruptedException;
//获取队列中剩余的空间。
int remainingCapacity();
//从队列中移除指定的值。
boolean remove(Object o);
//判断队列中是否拥有该值。
public boolean contains(Object o);
//将队列中值,全部移除,并发设置到给定的集合中。
int drainTo(Collection<? super E> c);
//指定最多数量限制将队列中值,全部移除,并发设置到给定的集合中。
int drainTo(Collection<? super E> c, int maxElements);
}
阻塞队列的具体实现
- ArrayBlockingQueue:是一个用数组实现的有界阻塞队列,此队列按照先进先出(FIFO)的原则对元素进行排序。支持公平锁和非公平锁。【注:每一个线程在获取锁的时候可能都会排队等待,如果在等待时间上,先获取锁的线程的请求一定先被满足,那么这个锁就是公平的。反之,这个锁就是不公平的。公平的获取锁,也就是当前等待时间最长的线程先获取锁】
- LinkedBlockingQueue:一个由链表结构组成的有界队列,此队列的长度为Integer.MAX_VALUE。此队列按照先进先出的顺序进行排序。
- PriorityBlockingQueue: 一个支持线程优先级排序的无界队列,默认自然序进行排序,也可以自定义实现compareTo()方法来指定元素排序规则,不能保证同优先级元素的顺序。
- DelayQueue: 一个实现PriorityBlockingQueue实现延迟获取的无界队列,在创建元素时,可以指定多久才能从队列中获取当前元素。只有延时期满后才能从队列中获取元素。(DelayQueue可以运用在以下应用场景:1.缓存系统的设计:可以用DelayQueue保存缓存元素的有效期,使用一个线程循环查询DelayQueue,一旦能从DelayQueue中获取元素时,表示缓存有效期到了。2.定时任务调度。使用DelayQueue保存当天将会执行的任务和执行时间,一旦从DelayQueue中获取到任务就开始执行,从比如TimerQueue就是使用DelayQueue实现的。)
四、JUC
4.1 线程计数器 - CountDownLatch
这个类能够使一个线程等待其他线程完成各自的工作后再执行
API
- countDown():该方法递减计数器,表示有一个事件已经发生;
- await():该方法等待计时器达到零,达到零后表示需要等待的所有事件都已发生;
使用方式
CountDownLatch countDownLatch = new CountDownLatch(10);
//子线程调用该方法
countDownLatch.countDown();
//主线程等待其他线程完成
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
4.2 线程屏障 - CyclicBarrier
CyclicBarrier初始化的时候,设置一个屏障数。线程调用await()方法的时候,这个线程就会被阻塞,当调用await()的线程数量到达屏障数的时候,主线程就会取消所有被阻塞线程的状态
使用方式
//设置屏障数
CyclicBarrier cyclicBarrier = new CyclicBarrier(10);
//子线程调用该方法会阻塞,当有指定数量的线程调用该方法时,10个线程同时往后执行
try {
cyclicBarrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
4.3 信号量 - Semaphore
计数信号量(Counting Semaphore)用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量;
使用方式
acquire()
获取一个令牌,在获取到令牌、或者被其他线程调用中断之前线程一直处于阻塞状态。
acquire(int permits)
获取一个令牌,在获取到令牌、或者被其他线程调用中断、或超时之前线程一直处于阻塞状态。
acquireUninterruptibly()
获取一个令牌,在获取到令牌之前线程一直处于阻塞状态(忽略中断)。
tryAcquire()
尝试获得令牌,返回获取令牌成功或失败,不阻塞线程。
tryAcquire(long timeout, TimeUnit unit)
尝试获得令牌,在超时时间内循环尝试获取,直到尝试获取成功或超时返回,不阻塞线程。
release()
释放一个令牌,唤醒一个获取令牌不成功的阻塞线程。
hasQueuedThreads()
等待队列里是否还存在等待线程。
getQueueLength()
获取等待队列里阻塞的线程数。
drainPermits()
清空令牌把可用令牌数置为0,返回清空令牌的数量。
availablePermits()
返回可用的令牌数量。
使用方式
//创建一个信号量对象,令牌数为3
Semaphore semaphore = new Semaphore(3);
//获取令牌
try {
semaphore.acquire();
//业务代码
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//释放令牌
semaphore.release();
}
4.4 CAS
CAS(Compare and Swap),即比较并替换,实现并发算法时常用到的一种技术;
CAS的思想很简单:三个参数,一个当前内存值V、旧的预期值A、即将更新的值B,当且仅当预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false
实现类
- AtomicInteger
- AtomicReference
- AtomicStampedReference:它可以通过控制变量值的版本来保证CAS的正确性
CAS的常见问题
1) ABA问题
如果变量V初次读取的时候是A,并且在准备赋值的时候检查到它仍然是A,那能说明它的值没有被其他线程修改过了吗?
如果在这段期间曾经被改成B,然后又改回A,那CAS操作就会误认为它从来没有被修改过。针对这种情况,java并发包中提供了一个带有标记的原子引用类AtomicStampedReference
,它可以通过控制变量值的版本来保证CAS的正确性;
ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。从Java1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值
2)循环时间长开销大
自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。
3)只能保证一个共享变量的原子操作
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始,JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作。
五、锁的分类
5.1 悲观锁 VS 乐观锁
悲观锁
对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁
乐观锁
乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作
乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的
5.2 自旋锁vs适应性自旋锁
自旋锁
阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。
在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。
而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁
自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程
适应性自旋锁
自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
5.3 公平锁vs非公平锁
公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
非公平锁
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
5.4 可重入锁vs非可重入锁
可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁
非可重入锁
非可重入锁就是同一个线程,没办法获得一个锁两次,容易造成死锁
5.5 独享锁vs共享锁
独占锁/排他锁/写锁
独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是排他锁。
共享锁/读锁
共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据