一、进程与线程
- 程序:程序是指令和数据的有序集合,本身没有任何运行的含义,是一个静态概念
- 进程:是执行程序的一次执行过程,是一个动态概念。是系统资源分配的单位。(程序执行起来就变成了进程)
- 线程:通常一个进程中可以包含多个线程。线程是CPU调度和执行的单位。
二、线程如何创建
有三种创建模式
2.1 继承Thread类
本质上Thread类实现了Runnable接口。
使用调用run方法与调用start方法的不同,run方法是被当做普通方法,按照程序执行顺序在主线程(main)中正常执行,但是start方法是作为另一条线程与主线程同步执行。
例:start启动线程
//继承Thread类 重写run方法 使用start启动线程
public class Test1 extends Thread{
@Override
public void run() {
for (int i = 0; i < 2000; i++) {
System.out.println("AAAAA");
}
}
public static void main(String[] args) {
Test1 test1 = new Test1();
test1.start();
for (int i = 0; i < 2000; i++) {
System.out.println("B");
}
}
}
结果:可以看到两个方法是交替执行的
例:run启动线程
//继承Thread类 重写run方法 使用start启动线程
public class Test1 extends Thread{
@Override
public void run() {
for (int i = 0; i < 2000; i++) {
System.out.println("AAAAA");
}
}
public static void main(String[] args) {
Test1 test1 = new Test1();
test1.run();
for (int i = 0; i < 2000; i++) {
System.out.println("B");
}
}
}
结果:在主程序中顺序执行
为什么需要通过start方法才能启动多线程:
进入start源码可以看到:
- 首先我们看到在start()方法中抛出IllegalThreadStateException异常,按照原有的处理方式,应当在调用处进行异常处理,而此处没有处理也不会报错,因此是一个RuntimeException,这个异常的产生只是因为你重复启动了线程才会产生。所以,每一个线程对象只能够启动一次。
- 在start()方法中调用了start0()方法,而这个方法是一个只声明而未实现的方法,同时使用native关键字进行定义,native关键字指的是调用本机的原生系统函数。
在Thread 类有个 registerNatives本地方法,该方法主要的作用就是注册一些本地方法供 Thread 类使用,如start0(),stop0()等等,可以说,所有操作本地线程的本地方法都是由它注册的。
可以看到Java 线程调用 start->start0 的方法,实际上会调用到 JVM_StartThread 方法。
而JVM_StartThread方法最终实现了多线程调用程序中的run方法。
总结:
调用start中的start0方法,调用start0方法就相当于调用JVM_StartThread,在JVM_StartThread 方法中实现了多线程调用run方法。
2.2 实现Runnable接口
例:
//实现Runnable接口 重写run方法 将run所在类对象(Runnable实现类对象)传入Thread类 使用start方法启动
public class Test2 implements Runnable{
@Override
public void run() {
for (int i = 0; i < 2000; i++) {
System.out.println("AAAAA");
}
}
public static void main(String[] args) {
//创建Runnable接口的实现类对象
Test2 test2 = new Test2();
//创建Thread对象,通过线程对象来开启线程,代理模式。
Thread thread = new Thread(test2);
thread.start();
new Thread(test2).start();
for (int i = 0; i < 2000; i++) {
System.out.println("B");
}
}
}
2.3 Thread类与Runnable接口比较
如果一个对象被多个线程使用则会有线程安全问题:
//多个线程同时操作一个对象,线程不安全,数据紊乱.
public class Test3 implements Runnable{
int tickets = 10;
@Override
public void run() {
while (tickets > 0) {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "购买第" + tickets + "张");
tickets--;
}
}
public static void main(String[] args) {
Test3 test3 = new Test3();
new Thread(test3,"A").start();
new Thread(test3,"B").start();
new Thread(test3,"C").start();
}
}
结果:
2.4 实现Callable接口(JDK5)
1.实现Callable接口,需要返回值类型
2.重写调用方法,需要抛出异常
3.创建目标对象
4.创建执行服务:ExecutorServiceser=Executors.newFixedThreadPool(1);
5.提交执行: Future result1 = ser. submit(t1);
6.获取结果: boolean r1 = result1.get()
7.关闭服务: ser.shutdownNow();
例:
// 1. 创建一个实现Callable的实现类
public class CallableTest implements Callable<Object> {
int a = 10;
int sum;
// 2. 实现call方法,将此线程需要执行的操作声明在call()中,可返回值,如不需要,返回null即可
//(9-2加法)
@Override
public Object call() {
while (a >= 0) {
System.out.println(Thread.currentThread().getName() + "--->" + a--);
sum += a;
}
//此处实现自动装箱和向上转型int ————> Integer ————> Object
return sum;
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
//3. 创建Callable接口实现类的对象
CallableTest callableTest = new CallableTest();
//4. 将次Callable接口实现类的对象作为传递到FutureTask构造器中,创建FutureTask对象
FutureTask futureTask = new FutureTask(callableTest);
//5. 将FutureTask对象作为参数传入Thread类的构造器中,创建Thread对象,并start
new Thread(futureTask).start();
//get返回值即为FutureTask构造器参数的Callale实现类重写的call()的返回值
Object o = futureTask.get();
System.out.println(o);
}
}
结果:
三、线程池(JDK5)
1. 线程池优点
(1)降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
(2)提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
(3)便于线程管理。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
2. 线程池原理
1. 线程池处理流程
1)线程池判断核心线程池里的线程是否都在执行任务。如果不是,则创建一个新的工作线程来执行任务。如果核心 线程池里的线程都在执行任务,则进入下个流程。
2)线程池判断工作队列是否已经满。如果工作队列没有满,则将新提交的任务存储在这个工作队列里。如果工作队 列满了,则进入下个流程。
3)线程池判断线程池的线程是否都处于工作状态。如果没有,则创建一个新的工作线程来执行任务。如果已经满 了,则交给饱和策略来处理这个任务。
2. execute()方法执行流程
1)如果当前运行的线程少于corePoolSize,则创建新线程来执行任务(注意,执行这一步骤需要获取全局锁)。
2)如果运行的线程等于或多于corePoolSize,则将任务加入BlockingQueue。
3)如果无法将任务加入BlockingQueue(队列已满),则创建新的线程来处理任务(注意,执行这一步骤需要获取全局锁)。
4)如果创建新线程将使当前运行的线程超出maximumPoolSize,任务将被拒绝,并调用 RejectedExecutionHandler.rejectedExecution()方法。
ThreadPoolExecutor采取上述步骤的总体设计思路,是为了在执行execute()方法时,尽可能地避免获取全局锁(那将会是一个严重的可伸缩瓶颈)。在ThreadPoolExecutor完成预热之后(当前运行的线程数大于等于 corePoolSize),几乎所有的execute()方法调用都是执行步骤2,而步骤2不需要获取全局锁。
3. 线程池的工作线程
线程池创建线程时,会将线程封装成工作线程Worker,Worker在执行完任务后,还会循环获取工作队列里的任务来 执行。
ThreadPoolExecutor中线程执行任务的示意图如下图所示。
线程池中的线程执行任务分两种情况,如下。
在execute()方法中创建一个线程时,会让这个线程执行当前任务。
这个线程执行完当前任务后,会反复从BlockingQueue获取任务来执行。
3. 线程池的使用
// 创建线程池
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
// 向线程池提交任务
threadPool.execute(new Runnable() {
@Override
public void run() {
... // 线程执行的任务
}
});
// 关闭线程池
threadPool.shutdown(); // 设置线程池的状态为SHUTDOWN,然后中断所有没有正在执行任务的线程
threadPool.shutdownNow(); // 设置线程池的状态为 STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表
1. 线程池的核心属性
corePoolSize(核心线程数):当线程池运行的线程少于 corePoolSize 时,将创建一个新线程来处理请求,即使其他工作线程处于空闲状态。
workQueue(队列):用于保留任务并移交给工作线程的阻塞队列。队列详解
- ArrayBlockingQueue:
是一个基于数组结构的有界阻塞队列,此队列按FIFO(先进先出)原则对元素进行排序。 - LinkedBlockingQueue:
一个基于链表结构的阻塞队列,此队列按FIFO排序元素,吞吐量通常要高于ArrayBlockingQueue。静态工厂方法Executors.newFixedThreadPool()使用了这个队列。 - SynchronousQueue:
一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于Linked-BlockingQueue,静态工厂方法Executors.newCachedThreadPool 使用了这个队列。 - PriorityBlockingQueue:
一个具有优先级的无限阻塞队列。
maximumPoolSize(最大线程数):线程池允许开启的最大线程数。
handler(拒绝策略):往线程池添加任务时,将在下面两种情况触发拒绝策略:1)线程池运行状态不是 RUNNING;2)线程池已经达到最大线程数,并且阻塞队列已满时。
策略:
优雅使用线程池最后一段介绍拒绝策略
CallerRunsPolicy()
keepAliveTime(保持存活时间):如果线程池当前线程数超过 corePoolSize,则多余的线程空闲时间超过 keepAliveTime 时会被终止。
**ThreadFactory **:在创建线程的时候,通过工厂模式来生产线程。这个参数就是设置我们自定义的线程创建工厂。
2. 向线程池提交任务——execute
可以使用两个方法向线程池提交任务,分别为execute()和submit()方法。
execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功。
使用execute()方法:
class RunnableThread implements Runnable {
@Override
public void run() {
for (int i = 0; i < 3; i++) {
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}
public class Test2 {
public static void main(String[] args) {
RunnableThread runnableThread = new RunnableThread();
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
3,
3,
2000,
TimeUnit.MILLISECONDS,
new LinkedBlockingDeque<Runnable>());
for (int i = 0; i < 5; i++) {
threadPoolExecutor.execute(runnableThread);
}
}
}
结果:
3. 向线程池提交任务——submit
submit()方法用于提交需要返回值的任务。线程池会返回一个future类型的对象,通过这个future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。
使用submit()方法:
class CallableThread implements Callable<String> {
@Override
public String call() throws Exception {
for (int i = 0; i <3; i++) {
System.out.println(Thread.currentThread().getName() + "、" + i);
}
return Thread.currentThread().getName()+"任务执行完毕";
}
}
public class Test3 {
public static void main(String[] args){
CallableThread callableThread = new CallableThread();
ThreadPoolExecutor threadPoolExecutor =
new ThreadPoolExecutor(
3,
5,
2000,
TimeUnit.MILLISECONDS,
new LinkedBlockingDeque<Runnable>());
for (int i = 0; i < 5; i++) {
Future<String> future = threadPoolExecutor.submit(callableThread);
try {
String str = future.get();
System.out.println(str);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
}
}
4. 关闭线程池
可以通过调用线程池的shutdown或shutdownNow方法来关闭线程池。
它们的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务可能永远无法终止。但是它们存在一定的区别。
shutdownNow首先将线程池的状态设置成STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行
任务的列表。
shutdown只是将线程池的状态设置成SHUTDOWN状态,然后中断所有没有正在执行任务的线程。
只要调用了这两个关闭方法中的任意一个,isShutdown方法就会返回true。当所有的任务都已关闭后,才表示线程池关闭成功,这时调用isTerminaed方法会返回true。至于应该调用哪一种方法来关闭线程池,应该由提交到线程池的任务特性决定,通常调用shutdown方法来关闭线程池,如果任务不一定要执行完,则可以调用shutdownNow方法。
线程池使用完毕后加上:
threadPoolExecutor.shutdown();
5. 合理配置线程池
要想合理地配置线程池,就必须首先分析任务特性,可以从以下几个角度来分析:
任务的性质:CPU密集型任务、IO密集型任务和混合型任务。
性质不同的任务可以用不同规模的线程池分开处理。CPU密集型任务应配置尽可能小的线程,如配置Ncpu+1个线程 的线程池。由于IO密集型任务线程并不是一直在执行任务,则应配置尽可能多的线程,如2*Ncpu。混合型的任务, 如果可以拆分,将其拆分成一个CPU密集型任务和一个IO密集型任务,只要这两个任务执行的时间相差不是太大, 那么分解后执行的吞吐量将高于串行执行的吞吐量。如果这两个任务执行时间相差太大,则没必要进行分解。
可以通过Runtime.getRuntime().availableProcessors()方法获得当前设备的CPU个数。
任务的优先级:高、中和低。 任务的执行时间:长、中和短。
优先级不同的任务可以使用优先级队列PriorityBlockingQueue来处理。它可以让优先级高的任务先执行。 注意:如果一直有优先级高的任务提交到队列里,那么优先级低的任务可能永远不能执行。
任务的依赖性:是否依赖其他系统资源,如数据库连接。
执行时间不同的任务可以交给不同规模的线程池来处理,或者可以使用优先级队列,让执行时间短的任务先执行。 依赖数据库连接池的任务,因为线程提交SQL后需要等待数据库返回结果,等待的时间越长,则CPU空闲时间就越
长,那么线程数应该设置得越大,这样才能更好地利用CPU。
6. 个人思考
线程启动后先使用核心线程池中的线程执行,如果此时核心线程池为空,则会自动创建一个非核心线程去执行任务,其余的任务加入等待队列中等着。
如果等待队列满了则会创建非核心线程去执行任务,当非核心线程池中的线程也使用完后,再遇到新的任务,就会按照设置的策略抛出异常。
如果没有满就在队列里一直等着直到核心线程空闲后从队列中出一个任务去被线程执行。
核心线程池为空:
public class CoreIsZero {
public static void main(String[] args) {
LinkedBlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
//代理模式,便于打印信息
BlockingQueue queue2 = (BlockingQueue)Proxy.newProxyInstance(
queue.getClass().getClassLoader(),
queue.getClass().getInterfaces(),
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("方法名称:" + method.getName() + "," +"参数:" + Arrays.toString(args));
return method.invoke(queue,args);
}
}
);
ExecutorService es = new ThreadPoolExecutor(0, 1000, 1000, TimeUnit.HOURS, queue2);
for (int i = 0; i < 8; i++) {
es.execute(new Runnable() {
@Override
public void run() {
System.out.println("线程ID:" + Thread.currentThread().getId() + "," + "线程名称:" + Thread.currentThread().getName() + " 开始");
try {
Thread.sleep( 1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程ID:" + Thread.currentThread().getId() + "," + "线程名称:" + Thread.currentThread().getName() + " 结束");
System.out.println("------------------------------");
}
});
}
}
}
可以看到首先用一个非核心线程执行任务,其余任务加入(offer)队列。当线程空闲,任务出队(poll)交给线程去执行。
此处的参数是一个等待时间[3600000000000000, NANOSECONDS],当队列为空后,poll方法会阻塞指定的时间,直到有新的元素进入队列。
核心线程池不为空:
public class CoreIsZero {
public static void main(String[] args) {
LinkedBlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
//代理模式,便于打印信息
BlockingQueue queue2 = (BlockingQueue)Proxy.newProxyInstance(
queue.getClass().getClassLoader(),
queue.getClass().getInterfaces(),
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("方法名称:" + method.getName() + "," +"参数:" + Arrays.toString(args));
return method.invoke(queue,args);
}
}
);
ExecutorService es = new ThreadPoolExecutor(1, 1000, 1000, TimeUnit.HOURS, queue2);
for (int i = 0; i < 8; i++) {
es.execute(new Runnable() {
@Override
public void run() {
System.out.println("线程ID:" + Thread.currentThread().getId() + "," + "线程名称:" + Thread.currentThread().getName() + " 开始");
try {
Thread.sleep( 1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程ID:" + Thread.currentThread().getId() + "," + "线程名称:" + Thread.currentThread().getName() + " 结束");
System.out.println("------------------------------");
}
});
}
}
}
线程一开始就使用核心线程去执行任务,其他任务加入消息队列中等待,当核心线程空闲后,任务出队使用核心线程去执行。take会一直阻塞,直到新的元素入队。
核心线程与非核心线程不同的是出队时核心线程使用的是take(一直阻塞),非核心使用的是poll(阻塞一定时间)
4. Executors 的 4 个功能线程池
1. newFixedThreadPool():
- 作用:任务执行开始从线程池中获取可用的线程执行任务,如果线程池中正在执行的任务达到设置的线程最大数(无可用线程),则新得任务会放到阻塞队列里等待,有可用线程时按顺序执行。
- 特点:可指定线程数、线程可重用、队列数量没有限制
- 使用场景:用于负载比较重的服务器,为了资源的合理利用,需要限制当前线程数量
- 注意: 使用完要调用shutdown()来关闭执行器,如果不关闭,则一直等待新任务的到来。
shutdown():调用shutdown()后不再接收新的任务,并按照已经提交的任务的顺序发起一个有序的关闭过程。如果已经关闭执行器了,则调用没有其他作用。 - 可能发生的异常:java.lang.OutOfMemoryError
- 异常原因:任务量太大,任务全部在队列中堆积,超越了内存限制
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue < runnable >());
}
- 使用方式:
ExecutorService executor = Executors.newFixedThreadPool(3);//创建线程池执行器服务
executor.execute(task); //把Runnable交给执行器执行
executor.shutdown(); //申请关闭执行器
public class NewFixedThreadPoolTest {
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(2);
for (int i = 0; i < 10; i++) {
executorService.submit(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
});
}
}
}
可以看到只有两个线程去执行任务,如果没有多余线程则任务去等待队列中等待着,不会创建新的线程。由于没有调用shutdown/shutdownNow方法,所以线程池执行完任务后一直在等待。
2. newSingleThreadExecutor():
创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。
- 可能发生的异常:java.lang.OutOfMemoryError
- 异常原因:任务量太大,任务全部在队列中堆积,超越了内存限制
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService(new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue < runnable > ()));
}
- 使用方式:
public class NewSingleThreadExecutorTest {
public static void main(String[] args) {
ExecutorService executorService = Executors.newSingleThreadExecutor();
for (int i = 0; i < 5; i++) {
executorService.submit(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName());
}
});
}
}
}
结果:使用唯一的线程遵循先进先出原则执行任务。
3. newCachedThreadPool():
- 作用:用来创建一个可以无限增大的线程池。当有任务到来时,会判断当先线程池中是否有已经执行完被回收的空闲线程,有则使用,没有则创建新的线程。(空闲线程:线程如果60秒没有使用就会被任务是空闲线程并移出Cache)
- 特点:无限扩展、自动回收空闲线程、复用空闲线程
- 使用场景:在小任务量,任务时间执行短的场景下提高性能
- 注意:使用完要调用shutdown()来关闭执行器,如果不关闭,则一直等待新任务的到来。
shutdown():调用shutdown()后不再接收新的任务,并按照已经提交的任务的顺序发起一个有序的关闭过程。如果已经关闭执行器了,则调用没有其他作用。 - 可能发生的异常:java.lang.OutOfMemoryError
- 异常原因:任务量太大,线程池不断创建新的线程,超越了内存限制
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue < runnable > ());
}
- 使用方式:
ExecutorService executor = Executors.newCachedThreadPool();//创建线程池执行器服务
executor.execute(task); //把Runnable交给执行器执行
executor.shutdown(); //申请关闭执行器
import java.util.concurrent.*;
public class NewCachedThreadPoolTest {
public static void main(String[] args) {
ExecutorService executor = Executors.newCachedThreadPool();
for(int i = 0; i < 10; i++){
executor.execute(new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName());
}
}));
}
executor.shutdown();
}
}
可以看到如果没有空闲的线程去处理任务,那么会来一个任务建一个线程。
4. newScheduledThreadPool():
创建固定大小的线程池,支持定时及周期性的任务执行
创建一个定长的线程池,支持定时任务、周期性任务的执行
- 作用:线程池能按时间计划来执行任务,允许用户设定计划执行任务的时间。
- 注意:newScheduledThreadPool(int corePoolSize) 参数corePoolSize设定线程池中线程的最小数目。当任务较多时,线程池可能会创建更多的工作线程来执行任务。
- 可能发生的异常:java.lang.OutOfMemoryError
- 异常原因:任务量太大,线程池不断创建新的线程,超越了内存限制
public ScheduledThreadPoolExecutor(int corePoolSize) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue());
}
- 使用方式:
1.定时任务:
public class NewSingleThreadExecutorTest {
//定时任务
public static void main(String[] args) {
ScheduledExecutorService executor = Executors.newScheduledThreadPool(3);
for (int i = 0; i < 8; i++) {
executor.schedule(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "延迟1秒后运行...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, 1, TimeUnit.SECONDS);
}
}
}
2.周期任务
public class NewSingleThreadExecutorTest {
public static void main(String[] args) {
ScheduledExecutorService executor = Executors.newScheduledThreadPool(3);
executor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " >>>" + new Date().toString());
}
}, 1, 3, TimeUnit.SECONDS);
}
}
总结:
优雅使用线程池
ScheduledThreadPool定时任务的使用
四、线程相关方法
4.1 线程停止(stop)
- 不推荐使用JDK提供的stop()、destroy()方法。[ 已废弃]
使用stop我们在不知道线程到底运行到了什么地方时,暴力的中断了线程,如果sleep后的代码是资源释放、重要业务逻辑等比较重要的代码的话,亦或是其他线程依赖此线程的运行结果,那直接中断将可能造成很严重的后果。
destroy()则只是抛出一个异常(NoSuchMethodError()),同样强行中断了线程。 - 推荐线程自己停止下来
- 建议使用一个标志位进行终止变量,当flag=false,则终止线程运行。
例:
package com.fwb.Demo1;
/**
* @author fengweibo
* @version 1.0
* @date 2021/5/21 18:04
*/
public class ThreadStopTest extends Thread{
private boolean tag = true;
@Override
public void run(){
int i = 0;
while (tag){
System.out.println("线程执行ing..." + i++);
if (i == 900){
changeTag();
}
}
}
public void changeTag(){
this.tag = false;
}
public static void main(String[] args) {
ThreadStopTest threadStopTest = new ThreadStopTest();
threadStopTest.start();
for (int i = 0; i < 1000; i++) {
System.out.println("主线程" + i);
}
}
}
4.2 线程休眠(sleep)
- sleep (时间)指定当前线程阻塞的毫秒数;
- sleep存在异常InterruptedException;
- sleep时间达到后线程进入就绪状态;
- sleep可以模拟网络延时,倒计时等。
- 每一个对象都有一一个锁,
- sleep不会释放锁;
例:
//模拟倒计时
public class ThreadSleepTest implements Runnable{
int a = 10;
@Override
public void run() {
do {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(a--);
} while (a != 0);
}
public static void main(String[] args) {
ThreadSleepTest threadSleepTest = new ThreadSleepTest();
Thread thread = new Thread(threadSleepTest);
thread.start();
}
}
4.3 线程礼让(yield)
- 礼让线程,让当前正在执行的线程暂停,但不阻塞
- 将线程从运行状态转为就绪状态
- 让cpu重新调度,礼让不-定成功! 看CPU心情
例:
public class ThreadYieldTest implements Runnable{
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "线程开始执行");
Thread.yield();
System.out.println(Thread.currentThread().getName() + "线程停止执行");
}
public static void main(String[] args) {
ThreadYieldTest threadYieldTest = new ThreadYieldTest();
new Thread(threadYieldTest,"a").start();
new Thread(threadYieldTest,"b").start();
}
}
例:假设AB线程同时执行,CPU先调度了A,A此时调用线程礼让,于是AB又重新回到起跑线,交给CPU调度,但是接下来CPU执行的线程不一定是B,还是有可能会是A,AB的可能性相同。
未礼让的情况:a开始,a结束。b开始,b结束。或者b开始,b结束。a开始,a结束。
礼让的情况:
b开始后,礼让,于是a也开始执行。
礼让也可能没有成功,也就是a给b礼让后,第二次cpu调度的还是a
4.4 线程插队(Join)
- Join合并线程,待此线程执行完成后,再执行其他线程,其他线程阻塞,可以理解成插队。
例:
public class ThreadJoinThread implements Runnable{
@Override
public void run() {
for (int i = 0; i < 500; i++) {
System.out.println(Thread.currentThread().getName() + "线程开始执行" + i);
}
}
public static void main(String[] args) {
ThreadJoinThread threadJoinThread = new ThreadJoinThread();
Thread thread = new Thread(threadJoinThread,"a");
thread.start();
for (int i = 0; i < 200; i++) {
System.out.println("主线程" + i);
if (i == 100){
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
未调用join方法,则a与main线程交替执行
调用join方法:开始是交替执行,当触发join方法后可以看到,main方法进入阻塞状态,a线程开始持续调用,直到a线程调用结束
4.5 线程状态监测(getState)
线程状态。线程可以处于以下状态之一 :
- NEW
尚未启动的线程处于此状态。 - RUNNABLE
在Java虚拟机中执行的线程处于此状态。 - BLOCKED
被阻塞等待监视器锁定的线程处于此状态。 - WAITING
正在等待另一-个线程执行特定动作的线程处于此状态。 - TIMED WAITING
正在等待另一-个线程执行动作达到指定等待时间的线程处于此状态。 - TERMINATED
已退出的线程处于此状态。
例子:
public class ThreadStateTest {
public static void main(String[] args) {
Runnable runnable = new Runnable() {
@Override
public void run() {
for (int i = 0; i < 5; i++) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("执行中");
}
}
};
Thread thread = new Thread(runnable);
Thread.State state = thread.getState();
System.out.println("new 后面的" + state);
thread.start();
state = thread.getState();
System.out.println("start 后面的" + state);
while (state != Thread.State.TERMINATED){
state = thread.getState();
System.out.println("while循环中" + state);
}
}
}
new完之后进入新生状态,调用start之后进入就绪状态,之后进入运行状态,运行有两种结果,正常运行完就是死亡状态。若未运行完停止则是进入阻塞状态。
4.6 线程优先级(setPriority/getPriority)
- Java提供一个线程调度器来监控程序中启动后进 入就绪状态的所有线程,线程调度
器按照优先级决定应该调度哪个线程来执行。 - 线程的优先级用数字表示,范围从1~10.
- Thread.MIN_ PRIORITY = 1;
- Thread.MAX_ PRIORITY= 10;
- Thread.NORM_ PRIORITY= 5;
- 使用以下方式改变或获取优先级
getPriority().setPriority(int xxx)
线程执行还是看CPU的调度,线程优先级高并不一定会先执行,只是先执行的可能性大.
public class ThreadPriorityTest {
public static void main(String[] args) {
System.out.println("主线程优先级" + Thread.currentThread().getPriority());
Runnable runnable = () -> System.out.println(Thread.currentThread().getName() + ":" + Thread.currentThread().getPriority());
Thread thread3 = new Thread(runnable);
Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);
Thread thread4 = new Thread(runnable);
thread4.setPriority(10);
thread4.start();
thread1.setPriority(1);
thread1.start();
thread2.setPriority(4);
thread2.start();
thread3.setPriority(7);
thread3.start();
}
}
4.7 CountDownLatch
CountDownLatch是JDK提供的一个同步工具,有countDown方法和await方法,CountDownLatch在初始化时,需要指定用给定一个整数作为计数器。当调用countDown方法时,计数器会被减1;当调用await方法时,如果计数器大于0时,线程会被阻塞,一直到计数器被countDown方法减到0时,线程才会继续执行。计数器是无法重置的,当计数器被减到0时,调用await方法都会直接返回。
例:
countDown数量大于0线程就会一直阻塞:
countDown = 0后线程正常执行:
countDown > 0,但是await设置了阻塞时间,阻塞一定时间后恢复。
五、守护(daemon)线程(setDaemon)
- 线程分为用户线程和守护线程
- 虚拟机必须确保用户线程执行完毕
- 虚拟机不用等待守护线程执行完毕
用户线程:main线程
守护线程:GC,日志,监控等。
设置守护线程的方法:
//默认为false,设置为true后为守护线程 t1.setDaemon(true);
例
package com.fwb.Demo1;
/**
* @author fengweibo
* @version 1.0
* @date 2021/5/23 16:27
*/
//守护进程
public class ThreadDaemonTest {
public static void main(String[] args) {
Runnable runnable1 = () -> {
while (true) {
System.out.println(Thread.currentThread().getName() + "执行");
}
};
Runnable runnable2 = () -> {
for (int i = 0; i < 100000; i++) {
System.out.println(Thread.currentThread().getName() + "执行");
}
};
Thread t1 = new Thread(runnable1,"守护线程");
Thread t2 = new Thread(runnable2,"普通线程");
//默认为false,设置为true后为守护线程,虽然线程1是无限循环,但是随着线程2的结束而停止
t1.setDaemon(true);
t1.start();
t2.start();
}
}
六、线程同步——synchronized
并发:同一个对象被多个线程同时操作,例如上万人同时抢100个票,两个银行同时取钱。
保证线程安全的两种方式
1.队列,保证线程排队进入
2.锁,当有一个线程去调用这个对象时,将这个线程锁住,其他线程将无法调用。
关于synchronized:
- 一个线程持有锁会导致其他所有需要此锁的线程挂起。
- 在多线程竞争下,加锁,释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
- 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能问题。
对于最后一句话的解释:
可能当前线程优先级低需要执行30分钟,别的线程优先级高需要执行3秒,但是当前线程拿到了锁,就导致了后面可以3秒执行完的线程也需要等待30分钟。
非同步的例子:
例1,当多个线程将名称写入同一位置时,就可能出现名称被覆盖的情况
例2,模拟买票,会出现不同线程买到同一个票的情况。
package com.fwb.syn;
/**
* @author fengweibo
* @version 1.0
* @date 2021/5/23 17:13
*/
public class UnsafeBuyTickets {
public static void main(String[] args) {
RunnableTest runnableTest = new RunnableTest();
Thread thread1 = new Thread(runnableTest,"A");
Thread thread2 = new Thread(runnableTest,"B");
Thread thread3 = new Thread(runnableTest,"C");
thread1.start();
thread2.start();
thread3.start();
}
}
class RunnableTest implements Runnable {
private int tickets = 10;
private boolean flag = true;
@Override
public void run() {
while (flag) {
try {
buy();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
void buy() throws InterruptedException {
//模拟延时
Thread.sleep(10);
System.out.println(Thread.currentThread().getName() + tickets--);
if (tickets <= 0){
flag = false;
}
}
}
6.1 同步代码块(synchronized作用于代码块)
格式:
synchronized(同步监视器){
//需要被同步的代码块
}
说明:
- 操作共享数据的代码即为需要被同步的代码。
- 共享数据:多个线程共同操作的变量。比如ticket就是共享数据
- 同步监视器(同步锁),俗称:锁。任何一个类的对象都可以作为锁。
要求:多个线程必须共用同一把锁。
理解:可以理解为是一种信号灯,当有线程使用当前对象灯为红,没有灯为绿。当线程操作对象时,他的灯为红色,但是别的线程的灯还是为绿色,此时别的线程还是会去操作对象。 - 当继承Thread类实现多线程时,建议使用当前类.class作为同步监视器。
缺点:
操作同步代码块时。只能有一个线程参与,其他线程等待。相当于是一个单线程的过程,效率低。
package com.fwb.syn;
/**
* @author fengweibo
* @version 1.0
* @date 2021/5/23 17:13
*/
public class UnsafeBuyTickets {
public static void main(String[] args) {
RunnableTest runnableTest = new RunnableTest();
Thread thread1 = new Thread(runnableTest,"A");
Thread thread2 = new Thread(runnableTest,"B");
Thread thread3 = new Thread(runnableTest,"C");
thread1.start();
thread2.start();
thread3.start();
}
}
class RunnableTest implements Runnable {
private int tickets = 100;
//final Object object = new Object();
@Override
public void run() {
while (true) {
// synchronized (object) {
// synchronized (this) {//可直接使用this,也就是当前对象,建议使用类对象
synchronized (RunnableTest.class)
if (tickets > 0) {
try {
Thread.sleep(10);
System.out.println(Thread.currentThread().getName() + tickets);
tickets--;
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
break;
}
}}
}
}
6.2 同步方法(synchronized作用于方法)
如果操作共享数据的代码完整的声明在一个方法中,则可以使用同步方法。
例:将上文的同步代码块改为同步方法:
package com.fwb.syn;
/**
* @author fengweibo
* @version 1.0
* @date 2021/5/23 17:13
*/
public class SafeBuyTickets {
public static void main(String[] args) {
RunnableTest runnableTest = new RunnableTest();
Thread thread1 = new Thread(runnableTest,"A");
Thread thread2 = new Thread(runnableTest,"B");
Thread thread3 = new Thread(runnableTest,"C");
thread1.start();
thread2.start();
thread3.start();
}
}
class RunnableTest implements Runnable {
private int tickets = 100;
@Override
public void run() {
while (true) {
show();
}
}
synchronized void show(){
if (tickets > 0) {
try {
Thread.sleep(10);
System.out.println(Thread.currentThread().getName() + tickets);
tickets--;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
6.3 synchronized作用于静态方法
注:当通过继承Thread类来实现多线程,同时new了多个Thread对象时再使用同步方法,或是new 了多个实现了Runnable 的类是就需要注意了,因为会有多个同步锁,此时需要给同步方法加上static,因为类对象是唯一的
例:继承Thread类
package com.fwb.syn;
/**
* @author fengweibo
* @version 1.0
* @date 2021/5/26 15:42
*/
public class BuyTickets {
public static void main(String[] args) {
Thread t1 = new ThreadTest();
Thread t2 = new ThreadTest();
Thread t3 = new ThreadTest();
t1.setName("t1");
t2.setName("t2");
t3.setName("t3");
t1.start();
t2.start();
t3.start();
}
}
class ThreadTest extends Thread {
private static int tickets = 100;
@Override
public void run() {
while (true) {
show();
}
}
static synchronized void show(){
if (tickets > 0) {
try {
Thread.sleep(10);
System.out.println(Thread.currentThread().getName() + "买第" + tickets + "个");
tickets--;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
例:实现·Runnable接口
public class BuyTickets {
public static void main(String[] args) {
RunnableTest runnableTest1 = new RunnableTest();
RunnableTest runnableTest2 = new RunnableTest();
RunnableTest runnableTest3 = new RunnableTest();
Thread thread1 = new Thread(runnableTest1,"A");
Thread thread2 = new Thread(runnableTest2,"B");
Thread thread3 = new Thread(runnableTest3,"C");
thread1.start();
thread2.start();
thread3.start();
}
}
class RunnableTest implements Runnable {
private int tickets = 100;
@Override
public void run() {
while (true) {
synchronized (this) {
show();
}
}
}
synchronized void show(){
if (tickets > 0) {
try {
Thread.sleep(10);
System.out.println(Thread.currentThread().getName() + tickets);
tickets--;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
总结:
6.4 synchronzied原理
同步代码块,及反编译结果::
public class Test{
private static Object object = new Object();
public static void main(String[] args) {
synchronized (object) {
System.out.println("hello world");
}
}
}
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: getstatic #2 // Field object:Ljava/lang/Object; 3: dup
4: astore_1
5: monitorenter // 看这里!!!
6: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
9: ldc #4 // String hello world
11: invokevirtual #5 // Method java/io/PrintStream.println: (Ljava/lang/String;)V
14: aload_1
15: monitorexit // 看这里!!!
16: goto 24
19: astore_2 20: aload_1
21: monitorexit // 看这里!!! 22: aload_2
23: athrow
24: return
Exception table:
from to target type
6 16 19 any
19 22 19 any
执行同步代码块后首先要先执行monitorenter指令,退出的时候monitorexit指令。通过分析之后可以看出,使用 Synchronized进行同步,其关键就是必须要对对象的监视器monitor进行获取,当线程获取monitor后才能继续往下执 行,否则就只能等待。而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到monitor。
不知道大家是否注意到上述字节码中包含一个monitorenter指令以及多个monitorexit指令。这是因为Java虚拟机需要确保所获得的锁不管是在正常执行,还是抛出异常都能够被解锁。
同步方法,及反编译结果:
public synchronized void foo() {
System.out.println("hello world");
}
public synchronized void foo();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED // look!!!!!
Code:
stack=2, locals=1, args_size=1
0: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #6 // String hello world
5: invokevirtual #7 // Method java/io/PrintStream.println: (Ljava/lang/String;)V
8: return
LineNumberTable:
line 9: 0
line 10: 8
}
当用 synchronized 标记方法时,你会看到字节码中方法的访问标记包括 ACC_SYNCHRONIZED。该标记表示在进 入该方法时,Java 虚拟机需要进行 monitorenter 操作。而在退出该方法时,不管是正常返回,还是向调用者抛异常,Java 虚拟机均需要进行 monitorexit 操作。
这里 monitorenter 和 monitorexit 操作所对应的锁对象是隐式的。对于实例方法来说,这两个操作对应的锁对象是 this;对于静态方法来说,这两个操作对应的锁对象则是所在类的 Class 实例。
monitorenter 和 monitorexit 的作用:
关于 monitorenter 和 monitorexit 的作用,我们可以抽象地理解为每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。
当执行 monitorenter 时,如果目标锁对象的计数器为 0,那么说明它没有被其他线程所持有。在这个情况下,Java 虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加 1。
在目标锁对象的计数器不为 0 的情况下,如果锁对象的持有线程是当前线程,那么 Java 虚拟机可以将其计数器加 1,否则需要等待,直至持有线程释放该锁。
当执行 monitorexit 时,Java 虚拟机则需将锁对象的计数器减 1。当计数器减为 0 时,那便代表该锁已经被释放掉了。
之所以采用这种计数器的方式,是为了允许同一个线程重复获取同一把锁。举个例子,如果一个 Java 类中拥有多个 synchronized 方法,那么这些方法之间的相互调用,不管是直接的还是间接的,都会涉及对同一把锁的重复加锁操 作。因此,我们需要设计这么一个可重入的特性,来避免编程里的隐式约束。
总结:
- 任意一把Syncronized锁对象,使用java -c xx.class 编译以后会产生两个指令monitorEnter和monitorExit,这两个指令代表锁的获取和锁的释放,多出来的monitorExit是程序异常的时候,可以正常的释放锁。
- 每个锁对象拥有一个锁计数器和一个指向持有该锁的线程指针当执行moniterenter时,如果目标锁对象的计数器为零,那么说明他没有被其他线程持有,java虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加1,在目标锁对象的计数器不为零的情况下,如果锁对象的持有线程是当前线程,那么java虚拟机计数器可以加1否则需要等待,直至持有线程释放锁。当执行monitorexit时,java虚拟机则将锁对象的计数器减1,计数器为0说明锁已经释放
七、synchronizde优化
Synchronized最大的特征就是在同一时刻只有一个线程能够获得对象的监视器 (monitor),从而进入到同步代码块或者同步方法之中,即表现为互斥性(排它性)。这种方式肯定效率低下,每次只能通过一个线程,既然每次只能通过一个,这种形式不能改变的话,那么我们能不能让每次通过的速度变快一点呢。
例如,去收银台付款,之前的方式是,大家都去排队,然后取纸币付款收银员找零,有的时候付款的时候在包里 拿出钱包再去拿出钱,这个过程是比较耗时的,然后,支付宝解放了大家去钱包找钱的过程,现在只需要扫描下就可 以完成付款了,也省去了收银员跟你找零的时间的了。同样是需要排队,但整个付款的时间大大缩短,是不是整体的 效率变高速率变快了?这种优化方式同样可以引申到锁优化上,缩短获取锁的时间。
在聊到锁的优化也就是锁的几种状态前,有两个知识点需要先关注:(1)CAS操作 (2)Java对象头,这是理解下 面知识的前提条件。
7.1 了解CAS(自旋锁)机制
使用锁时,线程获取锁是一种悲观锁策略,即假设每一次执行临界区代码都会产生冲突,所以当前线程获取到锁的时候同时也会阻塞其他线程获取该锁。
而CAS操作(又称为无锁操作)是一种乐观锁策略,它假设所有线程访问共享资源的时候不会出现冲突,既然不会出现冲突自然而然就不会阻塞其他线程的操作。因此,线程就不会出现阻塞停顿的状态。那么,如果出现冲突了怎么办?无锁操作是使用CAS(compare and swap)又叫做比较交换来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。
CAS比较交换的过程可以通俗的理解为CAS(V,O,N),包含三个值分别为:V 内存地址存放的实际值;O 预期的值 (旧值);N 更新的新值。当V和O相同时,也就是说旧值和内存中实际的值相同,表明该值没有被其他线程更改过, 即该旧值O就是目前来说最新的值了,自然而然可以将新值N赋值给V。反之,V和O不相同,表明该值已经被其他线程改过了则该旧值O不是最新版本的值了,所以不能将新值N赋给V,返回V即可。当多个线程使用CAS操作一个变量时,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试,当然也可以选择挂起线程。
CAS的实现需要硬件指令集的支撑,在JDK1.5后虚拟机才可以使用处理器提供的CMPXCHG指令实现。
元老级的Synchronized(未优化前)最主要的问题是:在存在线程竞争的情况下会出现线程阻塞和唤醒锁带来的性能问题,因为这是一种互斥同步(阻塞同步)。而CAS并不是武断的将线程挂起,当CAS操作失败后会进行一定的尝试, 而非进行耗时的挂起唤醒的操作,因此也叫做非阻塞同步。这是两者主要的区别。
7.2 CAS的问题
1.ABA问题
因为CAS会检查旧值有没有变化,这里存在这样一个有意思的问题。比如一个旧值A变为了成B,然 后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号、时间戳等可以解决。在JDK1.5后的atomic包中提供了 AtomicStampedReference来解决ABA问题,解决思路就是这样的。
2.自旋会浪费大量的处理器资源
与线程阻塞相比,自旋会浪费大量的处理器资源。这是因为当前线程仍处于运行状况,只不过跑的是无用指令。它期望在运行无用指令的过程中,锁能够被释放出来。
我们可以用等红绿灯作为例子。Java 线程的阻塞相当于熄火停车,而自旋状态相当于怠速停车。如果红灯的等待时间非常长,那么熄火停车相对省油一些;如果红灯的等待时间非常短,比如我们在同步代码块中只做了一个整型加法,那么在短时间内锁肯定会被释放出来,因此怠速停车更合适。
然而,对于JVM来说,它并不能看到红灯的剩余时间,也就没法根据等待时间的长短来选择是自旋还是阻塞。JVM给出的方案是自适应自旋,根据以往自旋等待时能否获取锁,来动态调整自旋的时间(循环数)。
就我们的例子来说,如果之前不熄火等待了绿灯,那么这次不熄火的时间就长一点;如果之前不熄火没等待绿灯,那 么这次不熄火的时间就短一点。
3.公平性
自旋状态还带来另外一个副作用,不公平的锁机制。处于阻塞状态的线程,无法立刻竞争被释放的锁。然而,处于自旋状态的线程,则很有可能优先获得这把锁。
举个例子:多个线程同时去对数据a做a++操作,线程B拿完数据并进行了++操作,然后通过CAS改变了a的值,这时候线程C也通过++操作,当执行CAS时候发现compare的值和自己当时拿的值不同,所以线程C在CAS操作时失败了,就需要重新获取a的值再进行如上的操作,线程C如果人品很差,就有可能一直在进行CAS操作,所以就陷入了无限的循环之中,直到没有其他线程和他进行争抢了,他最后一个执行了CAS的操作改变了a的值。
根据上述的陈述,Atomic类会造成大量的空循环线程,一定意义上会影响性能。于是java8中推出了一个新的类用于解决这个问题 LongAdder 我个人理解为他的实现原理和 ForkJoin 是一样的,就是把一个任务拆分为多个同时进行,最后再将所有结果进行统一的计算。一定程度上解决了空循环的问题。
7.3 Java对象头以及锁机制(偏向、轻量、重量)
Java对象保存在内存中时,由以下三部分组成:
- 对象头
- 实例数据
- 对齐填充字节
一、对象头
java的对象头由以下三部分组成:
- Mark Word
- 指向类的指针
- 数组长度(只有数组对象才有)
1.Mark Word
Mark Word记录了对象和锁有关的信息,当这个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Word有关。
Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。
Mark Word在不同的锁状态下存储的内容不同,在32位JVM中是这么存的:
其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态。
JDK1.6以后的版本在处理同步锁时存在锁升级的概念,JVM对于同步锁的处理是从偏向锁开始的,随着竞争越来越激烈,处理方式从偏向锁升级到轻量级锁,最终升级到重量级锁。
JVM一般是这样使用锁和Mark Word的:
-
当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。
-
当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态。
-
当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码。
-
当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行步骤5。
-
偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。
-
轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7。
-
自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。
2.指向类的指针
该指针在32位JVM中的长度是32bit,在64位JVM中长度是64bit。
Java对象的类数据保存在方法区。
3.数组长度
只有数组对象保存了这部分数据。
该数据在32位和64位JVM中长度都是32bit。
二、实例数据
对象的实例数据就是在java代码中能看到的属性和他们的值。
三、对齐填充字节
因为JVM要求java的对象占的内存大小应该是8bit的倍数,所以后面有几个字节用于把对象的大小补齐至8bit的倍数,没有特别的功能。
7.4 其他优化
1. 锁粗化
锁粗化就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成为一个范围更大的锁。举例如 下:
public class Test{
private static StringBuffer sb = new StringBuffer(); public static void main(String[] args) {
sb.append(“a”); sb.append(“b”); sb.append(“c”); }
}
这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解 锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方 法结束后进行解锁。
2. 锁消除
锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那 么可以认为这段代码是线程安全的,不必要加锁。看下面这段程序:
public class Test{
public static void main(String[] args) { StringBuffer sb = new StringBuffer(); sb.append(“a”).append(“b”).append(“c”); }
}
虽然StringBuffer的append是一个同步方法,但是这段程序中的StringBuffer属于一个局部变量,并且不会从该方法中 逃逸出去,所以其实这过程是线程安全的,可以将锁消除。
八、死锁
主要原因有
系统资源不足;
程序执行的顺序有问题;
资源分配不当等。
总结为:不同的线程分别占用对方需要的同步资源不放弃,都在等待对方先放弃自己需要的同步资源。
现象:
出现死锁后,不会出现异常,不会出现提示,只是所有的线程都处于阻塞状态,无法继续。
解决方法:
- 专门的算法、原则
- 尽量减少同步资源的定义
- 尽量减少嵌套同步
例:此处A线程先获得b2锁,之后去获得b1锁,B线程先获得b1锁,再去获得b2锁,此时AB线程都在等待对方释放自己需要的锁,因此这就形成了“循环等待条件”,从而形成了死锁。想要解决这个死锁很简单只要将AB线程获取b1、b2锁的顺序一直即可。
此外也可以让b1 b2的值相同也可以
public static final String b1 = "lock";
public static final String b2 = "lock";
因为字符串有一个常量池,如果不同的线程持有的锁是具有相同字符的字符串锁时,那么两个锁实际上就是同一个锁。
package com.fwb.Demo3;
/**
* @author fengweibo
* @version 1.0
* @date 2021/5/25 18:16
*/
//线程死锁
public class DeadLock {
public static void main(String[] args) {
StringBuffer b1 = new StringBuffer();
StringBuffer b2 = new StringBuffer();
//A线程
new Thread() {
@Override
public void run() {
synchronized (b2){
b1.append(3);
b2.append("c");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (b1) {
b1.append(4);
b2.append("d");
System.out.println(b1);
System.out.println(b2);
}
}
}
}.start();
//B线程
new Thread() {
@Override
public void run() {
synchronized (b1){
b1.append(1);
b2.append("a");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (b2) {
b1.append(2);
b2.append("b");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(b1);
System.out.println(b2);
}
}
}
}.start();
}
}
九、ThreadLock
9.1 应用
ThreadLocal 适用于如下两种场景
1、每个线程需要有自己单独的实例
2、实例需要在多个方法中共享,但不希望被多线程共享
public class Test {
private static String commStr;
private static final ThreadLocal<String> threadStr = new ThreadLocal<>();
public static void main(String[] args) {
commStr = "main";
threadStr.set("main");
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
commStr = "thread";
threadStr.set("thread");
}
});
thread.start();
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(commStr);
System.out.println(threadStr.get());
}
}
可以看到commStr被覆盖了,但是threadStr没有被覆盖,说明每个线程都有自己独特的threadLocal。
使用实例:(参考1)(参考2)
1.我们上线后发现部分用户的日期居然不对了,排查下来是SimpleDataFormat的锅,当时我们使用SimpleDataFormat的parse()方法,内部有一个Calendar对象,调用SimpleDataFormat的parse()方法会先调用Calendar.clear(),然后调用Calendar.add(),如果一个线程先调用了add()然后另一个线程又调用了clear(),这时候parse()方法解析的时间就不对了。
其实要解决这个问题很简单,让每个线程都new 一个自己的 SimpleDataFormat就好了,但是1000个线程难道new1000个SimpleDataFormat?
所以当时我们使用了线程池加上ThreadLocal包装SimpleDataFormat,再调用initialValue让每个线程有一个SimpleDataFormat的副本,从而解决了线程安全的问题,也提高了性能。
2.存储用户Session
private static final ThreadLocal threadSession = new ThreadLocal();
public static Session getSession() throws InfrastructureException {
Session s = (Session) threadSession.get();
try {
if (s == null) {
s = getSessionFactory().openSession();
threadSession.set(s);
}
} catch (HibernateException ex) {
throw new InfrastructureException(ex);
}
return s;
}
3.数据库连接,处理数据库事务
4.数据跨层传递(controller,service, dao)
每个线程内需要保存类似于全局变量的信息(例如在拦截器中获取的用户信息),可以让不同方法直接使用,避免参数传递的麻烦却不想被多线程共享(因为不同线程获取到的用户信息不一样)。
例如,用 ThreadLocal 保存一些业务内容(用户权限信息、从用户系统获取到的用户名、用户ID 等),这些信息在同一个线程内相同,但是不同的线程使用的业务内容是不相同的。
在线程生命周期内,都通过这个静态 ThreadLocal 实例的 get() 方法取得自己 set 过的那个对象,避免了将这个对象(如 user 对象)作为参数传递的麻烦。
比如说我们是一个用户系统,那么当一个请求进来的时候,一个线程会负责执行这个请求,然后这个请求就会依次调用service-1()、service-2()、service-3()、service-4(),这4个方法可能是分布在不同的类中的。这个例子和存储session有些像。
package com.kong.threadlocal;
public class ThreadLocalDemo05 {
public static void main(String[] args) {
User user = new User("jack");
new Service1().service1(user);
}
}
class Service1 {
public void service1(User user){
//给ThreadLocal赋值,后续的服务直接通过ThreadLocal获取就行了。
UserContextHolder.holder.set(user);
new Service2().service2();
}
}
class Service2 {
public void service2(){
User user = UserContextHolder.holder.get();
System.out.println("service2拿到的用户:"+user.name);
new Service3().service3();
}
}
class Service3 {
public void service3(){
User user = UserContextHolder.holder.get();
System.out.println("service3拿到的用户:"+user.name);
//在整个流程执行完毕后,一定要执行remove
UserContextHolder.holder.remove();
}
}
class UserContextHolder {
//创建ThreadLocal保存User对象
public static ThreadLocal<User> holder = new ThreadLocal<>();
}
class User {
String name;
public User(String name){
this.name = name;
}
}
执行的结果:
service2拿到的用户:jack
service3拿到的用户:jack
9.2 实现
1.set方法
每个线程Thread都维护了一个自己的ThreadLocalMap,所以在每个线程创建ThreadLocal的时候,实际上数据是存在自己线程Thread的ThreadLocalMap变量里面的,key为当前线程,value为要存入的数据,别人没办法拿到,从而实现了隔离。
2.get方法
同样的,在 get() 方法中也会获取到当前线程的 ThreadLocalMap,如果 ThreadLocalMap 不为 null,则把获取 key 为当前 ThreadLocal 的值;否则调用 setInitialValue() 方法返回初始值,并保存到新创建的 ThreadLocalMap 中
9.3 ThreadLocalMap
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
……
}
结构如下:
ThreadLocalMap如何解决哈希冲突:
源码:
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
if (k == key) {
e.value = value;
return;
}
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
从源码里面可以看到ThreadLocalMap在存储的时候会给每一个ThreadLocal对象一个threadLocalHashCode,在插入过程中,根据ThreadLocal对象的hash值,定位到table中的位置i,int i = key.threadLocalHashCode & (len-1)。
然后会判断一下:如果当前位置是空的,就初始化一个Entry对象放在位置i上;
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
如果位置i不为空,如果这个Entry对象的key正好是即将设置的key,那么就刷新Entry中的value;
if (k == key) {
e.value = value;
return;
}
如果位置i的不为空,而且key不等于entry,那就找下一个空位置,直到为空为止。
这样的话,在get的时候,也会根据ThreadLocal对象的hash值,定位到table中的位置,然后判断该位置Entry对象中的key是否和get的key一致,如果不一致,就判断下一个位置,set和get如果冲突严重的话,效率还是很低的。
开放地址法:
为什么hashmap用链表法,而threadlocalmap用的是开放寻址法呢?
在开放寻址法中,所有的数据都存储在一个数组中,比起链表法来说,冲突的代价更高。
所以,使用开放寻址法解决冲突的散列表,装载因子的上限不能太大。这也导致这种方法比链表法更浪费内存空间。
但是反过来看,链表法指针需要额外的空间,故当结点规模较小时,开放寻址法较为节省空间,而若将节省的指针空间用来扩大散列表的规模,可使装填因子变小,这又减少了开放寻址法中的冲突,从而提高平均查找速度。
所以这正是threadlocalmap选择开放寻址法的原因。
9.4 共享线程的ThreadLocal数据
使用InheritableThreadLocal可以实现多个线程访问ThreadLocal的值,我们在主线程中创建一个InheritableThreadLocal的实例,然后在子线程中得到这个InheritableThreadLocal实例设置的值。
public class InheritableThreadLocalTest {
public static void main(String[] args) {
final ThreadLocal threadLocal = new ThreadLocal();
threadLocal.set("hh");
Thread t = new Thread() {
@Override
public void run() {
super.run();
System.out.println(threadLocal.get());
}
};
t.start();
}
}
public class InheritableThreadLocalTest {
public static void main(String[] args) {
final ThreadLocal threadLocal = new InheritableThreadLocal();
threadLocal.set("hh");
Thread t = new Thread() {
@Override
public void run() {
super.run();
System.out.println(threadLocal.get());
}
};
t.start();
}
}
9.5 ThreadLocal线程泄露
弱引用:不管内存是否够用被弱引用关联的对象只能生存到下一次垃圾回收发生之前。Java强软弱虚引用
Entry 中的 key 使用了弱引用的方式,这样做是为了降低内存泄漏发生的概率,但不能完全避免内存泄漏。
ThreadLocal在没有外部强引用时,发生GC时会被回收,如果创建ThreadLocal的线程一直持续运行,那么这个Entry对象中的value就有可能一直得不到回收,发生内存泄露。
就比如线程池里面的线程,线程都是复用的,那么之前的线程实例处理完之后,出于复用的目的线程依然存活,所以,ThreadLocal设定的value值被持有,导致内存泄露。
假设 Entry 的 key 没有使用弱引用的方式,就会造成和entry中value一样内存泄漏的场景。
解决方法:
在代码的最后使用remove
remove() 的时候都会清除当前线程 ThreadLocalMap 中所有 key 为 null 的 value
ThreadLocal模块参考自:敖丙
十、Lock接口与AQS
10.1 Lock简介
锁是用来控制多个线程访问共享资源的方式,一般来说,一个锁能够防止多个线程同时访问共享资源。
在Lock接口出 现之前,java程序主要是靠synchronized关键字实现锁功能的,而JDK5之后,并发包中增加了lock接口,它提供了与 synchronized一样的锁功能。
虽然它失去了像synchronize关键字隐式加锁解锁的便捷性,但是却拥有了锁获取和释 放的可操作性,可中断的获取锁以及超时获取锁等多种synchronized关键字所不具备的同步特性。
Lock接口中定义了这些方法:
// 获取锁
1. void lock();
// 获取锁的过程能够响应中断
2. void lockInterruptibly() throws InterruptedException;
// 非阻塞式响应中断能立即返回,获取锁返回true反之为false
3. boolean tryLock();
// 超时获取锁,在超时内或未中断的情况下能获取锁
4. boolean tryLock(long time,TimeUnit unit);
// 获取与lock绑定的等待通知组件,当前线程必须先获得了锁才能等待,等待会释 放锁,再次获取到锁才能从等待中返回。
5. Condition newCondition();
通常使用显示使用lock的形式如下:
Lock lock = new ReentrantLock(); lock.lock();
try{
.......
}finally{
lock.unlock(); }
例:
public class Test1 {
public static void main(String[] args) {
Ticket ticket = new Ticket();
new Thread(ticket,"A").start();
new Thread(ticket,"B").start();
new Thread(ticket,"C").start();
}
}
class Ticket implements Runnable{
int ticket = 100;
// 此处可设置参数,默认为false,当设为true时,会认为是公平的,也就是当当前线程操作完对象后将优先由其他线程操作对象。
// ReentrantLock lock = new ReentrantLock(true);
ReentrantLock lock = new ReentrantLock(true);
@Override
public void run() {
while (true){
lock.lock();
try {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (ticket > 0){
System.out.println(Thread.currentThread().getName() + "买到了第" + ticket + "票");
ticket--;
}else {
break;
}
} finally {
lock.unlock();
}
}
}
}
Lock接口的实现子类中几乎所有的方法的实现实际上都是调用了其静态内存类Sync中的方法:
而Sync类继承了 AbstractQueuedSynchronizer(AQS)。可以看出要想理解ReentrantLock关键核心在于对队列同步器 AbstractQueuedSynchronizer(简称同步器)的理解。
10.2 AQS
AbstractQuenedSynchronizer:抽象的队列式同步器
加锁会导致阻塞,有阻塞就会排队,实现排队必然需要有某种形式的队列来进行管理
在同步组件的实现中,AQS是核心部分,同步组件的实现者通过使用AQS提供的模板方法实现同步组件语义,AQS 则实现了对同步状态的管理,以及对阻塞线程进行排队,等待通知等等一些底层的实现处理。
AQS的核心也包括了这 些方面:同步队列,独占式锁的获取和释放,共享锁的获取和释放以及可中断锁,超时等待锁获取这些特性的实现, 而这些实际上则是AQS提供出来的模板方法,归纳整理如下:
独占式锁:
- void acquire(int arg) : 独占式获取同步状态,如果获取失败则插入同步队列进行等待。
- void acquireInterruptibly(int arg) : 与acquire方法相同,但在同步队列中等待时可以响应中断。
- boolean tryAcquireNanos(int arg,long nanosTimeout) : 在2的基础上增加了超时等待功能,在超时时间内没有 获得同步状态返回false
- boolean tryAcquire(int arg) : 获取锁成功返回true,否则返回false
- boolean release(int arg) : 释放同步状态,该方法会唤醒在同步队列中的下一个节点。
共享式锁:
- void acquireShared(int arg) : 共享式获取同步状态,与独占锁的区别在于同一时刻有多个线程获取同步状态。
- void acquireSharedInterruptibly(int arg) : 增加了响应中断的功能
- boolean tryAcquireSharedNanos(int arg,lone nanosTimeout) : 在2的基础上增加了超时等待功能
- boolean releaseShared(int arg) : 共享锁释放同步状态。
10.3 同步队列——AQS对同步状态的管理的基石
当共享资源被某个线程占有,其他请求该资源的线程将会阻塞,从而进入同步队列。就数据结构而言,队列的实现方式无外乎两者一是通过数组的形式,另外一种则是链表的形式。AQS中的同步队列则是通过链式方式进行实现。接下 来,很显然我们至少会抱有这样的疑问:1. 节点的数据结构是什么样的?2. 是单向还是双向?3. 是带头结点的还是 不带头节点的?
public class Test{
private static Lock lock = new ReentrantLock();
public static void main(String[] args) {
for (int i = 0;i < 5;i++) {
Thread thread = new Thread(()->{
lock.lock();
try {
Thread.sleep(10000);
}catch (Exception e) {
e.printStackTrace();
}
finally {
lock.unlock();
}
});
thread.start();
}
}
}
Debug上述代码可以看到
Thread-0先获得锁后进行睡眠,其他线程(Thread-1,Thread-2,Thread-3,Thread-4)获取锁失败进入同步队列。
同时也可以很清楚的看出来每个节点有两个域:prev(前驱)和next(后继),并且每个节点用来保存获取同步状态失败的线程引用以及等待状态等信息。另外AQS中有两个重要的成员变量:
private transient volatile Node head;
private transient volatile Node tail;
也就是说AQS实际上通过头尾指针来管理同步队列,同时实现包括获取锁失败的线程进行入队,释放锁时对同步队列 中的线程进行通知等核心方法。其示意图如下:
通过对源码的理解以及做实验的方式,现在我们可以清楚的知道这样几点:
- 节点的数据结构,即AQS的静态内部类Node,节点的等待状态等信息;
- 同步队列是一个双向队列,AQS通过持有头尾指针管理同步队列;
那么,节点如何进行入队和出队操作?实际上这对应着锁的获取和释放两个操作:获取锁失败进行入队操作,获取锁成功进行出队操作。
10.4 独占锁之ReentrantLock源码透解
独占锁:
独占锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排他锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和 JUC中Lock的实现类就是互斥锁。
以ReentrantLock源码为例说明独占锁:
1. lock()
调用lock()方法是获取独占锁,获取失败就将当前线程加入同步队列,成功则 线程执行。来看ReentrantLock源码:
lock方法使用compareAndSetState来尝试将同步状态改为1,如果成功则将同步状态持有线程置为当前线程,否则将调用AQS提供的 acquire()方法。
public final void acquire(int arg) {
// 再次尝试获取同步状态,如果成功则方法直接返回
// 如果失败则先调用addWaiter()方法再调用acquireQueued()方法
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
当线程获取独占式锁失败后就会将当前线程加入同步队列,那么加入队列的方式是怎样的了?我们接下来就应该去研 究一下addWaiter()和acquireQueued()。addWaiter()源码如下:
private Node addWaiter(Node mode) { // 将当前线程包装称为Node类型
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure Node pred = tail;
// 当前尾节点不为空
if (pred != null) {
// 将当前线程以尾插的方式插入同步队列中
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 当前尾节点为空或CAS尾插失败
enq(node);
return node;
}
分析可以看上面的注释。程序的逻辑主要分为两个部分:
1.当前同步队列的尾节点为null,调用方法enq()插入;
2.当前队列的尾节点不为null,则采用尾插入(compareAndSetTail()方法)的方式入队。
另外还会有另外一个问题: 如果 if (compareAndSetTail(pred, node))为false怎么办?会继续执行到enq()方法,同时很明显 compareAndSetTail是一个CAS操作,通常来说如果CAS操作失败会继续自旋(死循环)进行重试。
经过我们这样的分析,enq()方法承担两个任务:
- 处理当前同步队列尾节点为null时进行入队操作;
- 如果CAS尾插入节点失败后负责自旋进行尝试。
enq()源码如下:
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
// 头结点初始化
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
// CAS尾插,失败进行自旋重试直到成功为止。
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
在上面的分析中我们可以看出:
第1步中会先创建头结点,说明同步队列是带头结点的链式存储结构。带头结点与不带头结点相比,会在入队和出队的操作中获得更大的便捷性,因此同步队列选择了带头结点的链式存储结构。
那么带头节点的队列初始化时机是什么?
自然而然是在tail为null时,即当前线程是第一次插入同步队列。
compareAndSetTail(t, node)方法会利用CAS操作设置尾节点,如果CAS操作失败会在for (;;)死循环中不断尝试, 直至成功return返回为止。因此,对enq()方法可以做这样的总结:
- 在当前线程是第一个加入同步队列时,调用compareAndSetHead(new Node())方法,完成链式队列的头结 点的初始化;
- 自旋不断尝试CAS尾插入节点直至成功为止。
现在我们已经很清楚获取独占式锁失败的线程包装成Node然后插入同步队列(双向链表)的过程了。
那么紧接着会有下一个问题——在同步队列中的节点(线程)会做什么事情了来保证自己能够有机会获得独占式锁了?带着这样的问题我们就来看看 acquireQueued()方法,从方法名就可以很清楚,这个方法的作用就是排队获取锁的过程,源码如下:
需要注意代码中的head已在AbstractQueuedSynchronizer源码中声明,顾名思义是等待队列的头节点。
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
// 获得当前节点的前驱节点
final Node p = node.predecessor();
// 当前节点的前驱节点是头结点 && 当前节点能否获取独占式锁
if (p == head && tryAcquire(arg)) {
// 队列头指针指向当前节点
setHead(node);
// 释放前驱节点
p.next = null; // help GC
failed = false;
return interrupted;
}
// 获取同步状态失败,线程进入等待状态等待获取独占锁
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
程序逻辑通过注释已经标出,整体来看这是一个这又是一个自旋的过程(for (;;)),代码首先获取当前节点的先驱节点,如果先驱节点是头结点的并且成功获得同步状态的时候(if (p == head && tryAcquire(arg))),当前节点所指向的线程能够获取锁。反之,获取锁失败进入等待状态。整体示意图为下图:
获取锁成功,出队操作
//队列头结点引用指向当前节点
setHead(node);
//释放前驱节点
p.next = null; // help GC
failed = false;
return interrupted;
setHead()方法为:
private void setHead(Node node) {
head = node;
node.thread = null;
node.prev = null;
}
将当前节点通过setHead()方法设置为队列的头结点,然后将之前的头结点的next域设置为null并且pre域也为null,即 与队列断开,无任何引用方便GC时能够将内存进行回收。示意图如下:
获取锁失败,会调用shouldParkAfterFailedAcquire()方法和parkAndCheckInterrupt()方法
shouldParkAfterFailedAcquire()方法源码为:
int ws = pred.waitStatus;
if (ws == Node.SIGNAL) /*
* This node has already set status asking a release * to signal it, so it can safely park.
*/
return true;
if (ws > 0) {
/*
* Predecessor was cancelled. Skip over predecessors and * indicate retry.
*/
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
/*
* waitStatus must be 0 or PROPAGATE. Indicate that we
* need a signal, but don't park yet. Caller will need to
* retry to make sure it cannot acquire before parking.
*/
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
shouldParkAfterFailedAcquire()方法主要逻辑是使用compareAndSetWaitStatus(pred, ws, Node.SIGNAL) 使用CAS将节点状态由INITIAL设置成SIGNAL,表示当前线程阻塞。
当compareAndSetWaitStatus设置失败则说明 shouldParkAfterFailedAcquire方法返回false,然后会在acquireQueued()方法中for (;;)死循环中会继续重试,直至 compareAndSetWaitStatus设置节点状态位为SIGNAL时shouldParkAfterFailedAcquire返回true时才会执行方法 parkAndCheckInterrupt()方法,该方法的源码为:
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
该方法的关键是会调用LookSupport.park()方法(关于LookSupport会在后文进行讨论),该方法是用来阻塞当前线程的。因此到这里就应该清楚了,acquireQueued()在自旋过程中主要完成了两件事情:
- 如果当前节点的前驱节点是头节点,并且能够获得同步状态的话,当前线程能够获得锁该方法执行结束退出;
- 获取锁失败的话,先将节点状态设置成SIGNAL,然后调用LookSupport.park方法使得当前线程阻塞。
经过上面的分析,独占式锁的获取过程也就是acquire()方法的执行流程如下图所示:
2. unlock()
独占锁的释放(release()方法),调用unlock方法,而该方法实际调用了AQS的release方法。下面来看这两个方法的源码:
public void unlock() {
sync.release(1);
}
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
这段代码逻辑就比较容易理解了,如果同步状态释放成功(tryRelease返回true)则会执行if块中的代码,当head指向的头结点不为null,并且该节点的状态值不为0的话才会执行unparkSuccessor()方法。unparkSuccessor方法源码:
private void unparkSuccessor(Node node) {
/*
* If status is negative (i.e., possibly needing signal) try
* to clear in anticipation of signalling. It is OK if this
* fails or if status is changed by waiting thread.
*/
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
/*
* Thread to unpark is held in successor, which is normally
* just the next node. But if cancelled or apparently null,
* traverse backwards from tail to find the actual
* non-cancelled successor.
*
*/
// 头结点的后继节点
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
if (s != null)
// 后继节点不为null时唤醒
LockSupport.unpark(s.thread);
}
源码的关键信息请看注释,首先获取头节点的后继节点,当后继节点的时候会调用LookSupport.unpark()方法,该方 法会唤醒该节点的后继节点所包装的线程。因此,每一次锁释放后就会唤醒队列中该节点的后继节点所引用的线程, 从而进一步可以佐证获得锁的过程是一个FIFO(先进先出)的过程。
3. lock与unlock总结
- 线程获取锁失败,线程被封装成Node进行入队操作,核心方法在于addWaiter()和enq(),同时enq()完成对同 步队列的头结点初始化工作以及CAS操作失败的重试;
- 线程获取锁是一个自旋的过程,当且仅当 当前节点的前驱节点是头结点并且成功获得同步状态时,节点出队 即该节点引用的线程获得锁,否则,当不满足条件时就会调用LookSupport.park()方法使得线程阻塞;
- 释放锁的时候会唤醒后继节点;
总体来说:在获取同步状态时,AQS维护一个同步队列,获取同步状态失败的线程会加入到队列中进行自旋;移除队列(或停止自旋)的条件是前驱节点是头结点并且成功获得了同步状态。在释放同步状态时,同步器会调用 unparkSuccessor()方法唤醒后继节点。
4. 可中断式获取锁(lock.lockInterruptibly())
lock相较于synchronized有一些更方便的特性,比如能响应中断以及超时等待等特性,现在我们依旧采用通 过学习源码的方式来看看能够响应中断是怎么实现的。可响应中断式锁可调用方法lock.lockInterruptibly();而该方法其 底层会调用AQS的acquireInterruptibly方法,源码为:
public final void acquireInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
if (!tryAcquire(arg))
// 线程获取锁失败
doAcquireInterruptibly(arg);
}
在获取同步状态失败后就会调用doAcquireInterruptibly方法:
private void doAcquireInterruptibly(int arg)
throws InterruptedException { /
/ 将节点插入到同步队列中
final Node node = addWaiter(Node.EXCLUSIVE);
boolean failed = true;
try {
for (;;) {
final Node p = node.predecessor();
// 获取锁出队
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
// 线程中断异常
throw new InterruptedException();
}
} finally {
if (failed)
cancelAcquire(node);
}
}
关键信息请看注释,与acquire方法逻辑几乎一致,唯一的区别是当 parkAndCheckInterrupt返回true时即线程阻塞时该线程被中断,代码抛出被中断异常。
共享锁
共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。 独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
5. 超时等待式获取锁(tryAcquireNanos())
通过调用lock.tryLock(timeout,TimeUnit)方式达到超时等待获取锁的效果,该方法会在三种情况下才会返回:
- 在超时时间内,当前线程成功获取了锁;
- 当前线程在超时时间内被中断;
- 超时时间结束,仍未获得锁返回false。
我们仍然通过采取阅读源码的方式来学习底层具体是怎么实现的,该方法会调用AQS的方法tryAcquireNanos(),源码 为:
public final boolean tryAcquireNanos(int arg, long nanosTimeout)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
return tryAcquire(arg) ||
// 实现超时等待的效果
doAcquireNanos(arg, nanosTimeout);
}
最终是靠doAcquireNanos方法实现超时等待的效果,该方法源码如下:
private boolean doAcquireNanos(int arg, long nanosTimeout)
throws InterruptedException {
if (nanosTimeout <= 0L)
return false;
// 1.根据超时时间和当前时间计算出截止时间
final long deadline = System.nanoTime() + nanosTimeout;
final Node node = addWaiter(Node.EXCLUSIVE);
boolean failed = true;
try {
for (;;) {
final Node p = node.predecessor();
// 2.当前线程获得锁出队列
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return true;
}
// 3.1 重新计算超时时间
nanosTimeout = deadline - System.nanoTime();
// 3.2 已经超时返回false
if (nanosTimeout <= 0L)
return false;
// 3.3 线程阻塞等待
if (shouldParkAfterFailedAcquire(p, node) &&
nanosTimeout > spinForTimeoutThreshold)
LockSupport.parkNanos(this, nanosTimeout);
// 3.4 线程被中断抛出被中断异常
if (Thread.interrupted())
throw new InterruptedException();
}
} finally {
if (failed)
cancelAcquire(node);
}
}
程序逻辑图如图所示:
程序逻辑同独占锁可响应中断式获取基本一致,唯一的不同在于获取锁失败后,对超时时间的处理上,在第1步会先 计算出按照现在时间和超时时间计算出理论上的截止时间,比如当前时间是8h10min,超时时间是10min,那么根据 deadline = System.nanoTime() + nanosTimeout计算出刚好达到超时时间时的系统时间就是8h
10min+10min = 8h 20min。然后根据deadline - System.nanoTime()就可以判断是否已经超时了,比如,当前 系统时间是8h 30min很明显已经超过了理论上的系统时间8h 20min,deadline - System.nanoTime()计算出来 就是一个负数,自然而然会在3.2步中的If判断之间返回false。如果还没有超时即3.2步中的if判断为true时就会继续执 行3.3步通过LockSupport.parkNanos使得当前线程阻塞,同时在3.4步增加了对中断的检测,若检测出被中断直接 抛出被中断异常。
6. ReentrantLock重入性的实现原理
ReentrantLock重入锁表示能够对共享资源能够重复加锁,即当前线程获取该锁再次获取不会被阻塞。
在java关键字synchronized隐式支持重入性,synchronized通过获取自增,释放自减的方式实现重入。
要想支持重入性,就要解决两个问题:
- 在线程获取锁的时候,如果已经获取锁的线程是当前线程的话则直接再次获 取成功;
- 由于锁会被获取n次,那么只有锁在被释放同样的n次之后,该锁才算是完全释放成功。
通过上述对AQS 的学习,不难看出同步组件主要是通过重写AQS的几个protected方法来表达自己的同步语义。针对第一个问题, 我们来看看ReentrantLock是怎样实现的,以非公平锁为例,判断当前线程能否获得锁为例,核心方法为 nonfairTryAcquire:
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
// 1.如果该锁未被任何线程占有,该锁能被当前线程获取
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
// 2.若被占有,检查占有线程是否是当前线程
else if (current == getExclusiveOwnerThread()) {
// 3.再次获取,计数+1
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
这段代码的逻辑也很简单,具体请看注释。为了支持重入性,在第二步增加了处理逻辑,如果该锁已经被线程所占有 了,会继续检查占有线程是否为当前线程,如果是的话,同步状态加1返回true,表示可以再次获取成功。每次重新 获取都会对同步状态进行加一的操作,那么释放的时候处理思路是怎样的了?(依然还是以非公平锁为例)核心方法 为tryRelease:
protected final boolean tryRelease(int releases) {
// 1.同步状态-1
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
// 2.只有当同步状态为0时,锁成功释放,返回false
free = true;
setExclusiveOwnerThread(null);
}
// 3.锁未被完全释放,返回false
setState(c);
return free;
}
需要注意的是,重入锁的释放必须得等到同步状态为0时锁才算成功释放,否则锁仍未释放。 如果锁被获取n次,释放了n-1次,该锁未完全释放返回false,只有被释放n次才算成功释放,返回true。到现在我们 可以理清ReentrantLock重入性的实现了,也就是理解了同步语义的第一条。
7. ReentrantLock公平锁与非公平锁原理
ReentrantLock支持两种锁:公平锁和非公平锁。何谓公平性,是针对获取锁而言的,如果一个锁是公平的,那么锁 的获取顺序就应该符合请求上的绝对时间顺序,满足FIFO。ReentrantLock的构造方法无参时是构造非公平锁,源码为:
public ReentrantLock() {
sync = new NonfairSync();
}
另外还提供了另外一种方式,可传入一个boolean值,true时为公平锁,false时为非公平锁,源码为:
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
在上面非公平锁获取时(nonfairTryAcquire方法)只是简单的获取了一下当前状态做了一些逻辑处理,并没有考虑到 当前同步队列中线程等待的情况。我们来看看公平锁的处理逻辑是怎样的,核心方法为:
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 增加了判断是否有前驱节点
if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
这段代码的逻辑与nonfairTryAcquire基本上一致,唯一的不同在于增加了hasQueuedPredecessors的逻辑判断,方法 名就可知道该方法用来判断当前节点在同步队列中是否有前驱节点的判断,如果有前驱节点说明有线程比当前线程更 早的请求资源,根据公平性,当前线程请求资源失败。如果当前节点没有前驱节点的话,再才有做后面的逻辑判断的 必要性。公平锁每次都是从同步队列中的第一个节点获取到锁,而非公平性锁则不一定,有可能刚释放锁的线程能再 次获取到锁。
公平锁 VS 非公平锁
公平锁每次获取到锁为同步队列中的第一个节点,保证请求资源时间上的绝对顺序,而非公平锁有可能刚释放锁的线 程下次继续获取该锁,则有可能导致其他线程永远无法获取到锁,造成“饥饿”现象。
公平锁为了保证时间上的绝对顺序,需要频繁的上下文切换,而非公平锁会降低一定的上下文切换,降低性能开销。 因此,ReentrantLock默认选择的是非公平锁,则是为了减少一部分上下文切换,保证了系统更大的吞吐量。
10.5 读写锁ReentrantReadWriteLock
在并发场景中用于解决线程安全的问题,我们几乎会高频率的使用到独占式锁,通常使用java提供的关键字 synchronized或者concurrents包中实现了Lock接口的ReentrantLock。它们都是独占式获取锁,也就是在同一时刻只 有一个线程能够获取锁。而在一些业务场景中,大部分只是读数据,写数据很少,如果仅仅是读数据的话并不会影响 数据正确性(出现脏读),而如果在这种业务场景下,依然使用独占锁的话,很显然这将是出现性能瓶颈的地方。针 对这种读多写少的情况,java还提供了另外一个实现Lock接口的ReentrantReadWriteLock(读写锁)。读写锁允许同一 时刻被多个读线程访问,但是在写线程访问时,所有的读线程和其他的写线程都会被阻塞。在分析WirteLock和ReadLock的互斥性时可以按照WriteLock与WriteLock之间,WriteLock与ReadLock之间以及ReadLock与ReadLock 之间进行分析。这里做一个归纳总结:
- 公平性选择:支持非公平性(默认)和公平的锁获取方式,吞吐量还是非公平优于公平;
- 重入性:支持重入,读锁获取后能再次获取,写锁获取之后能够再次获取写锁,同时也能够获取读锁;
- 锁降级:遵循获取写锁,获取读锁再释放写锁的次序,写锁能够降级成为读锁
要想能够彻底的理解读写锁必须能够理解这样几个问题:1. 读写锁是怎样实现分别记录读写状态的?2. 写锁是怎样 获取和释放的?3.读锁是怎样获取和释放的?我们带着这样的三个问题,再去了解下读写锁。
1. 写锁详解
1. 写锁的获取
同步组件的实现聚合了同步器(AQS),并通过重写重写同步器(AQS)中的方法实现同步组件的同步语义因此, 写锁的实现依然也是采用这种方式。在同一时刻写锁是不能被多个线程所获取,很显然写锁是独占式锁,而实现写锁 的同步语义是通过重写AQS中的tryAcquire方法实现的。源码为:
protected final boolean tryAcquire(int acquires) {
/*
* Walkthrough:
* 1. If read count nonzero or write count nonzero
* and owner is a different thread, fail.
* 2. If count would saturate, fail. (This can only
* happen if count is already nonzero.)
* 3. Otherwise, this thread is eligible for lock if
* it is either a reentrant acquire or
* queue policy allows it. If so, update state
* and set owner.
*/
Thread current = Thread.currentThread();
// 获取写锁当前的同步状态
int c = getState();
// 获取写锁获取的次数
int w = exclusiveCount(c);
if (c != 0) {
// (Note: if c != 0 and w == 0 then shared count != 0)
// 当读锁已被读线程获取或当前线程不是已经获取写锁的线程,获取锁失败
if (w == 0 || current != getExclusiveOwnerThread())
return false;
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// Reentrant acquire
// 当前线程获取写锁,并且支持可重入
setState(c + acquires);
return true;
}
// 写锁未被任何线程获取,则当前线程获取写锁
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
其主要逻辑为:当读锁已经被读线程获取或者写锁已经被其他写线程 获取,则写锁获取失败;否则,获取成功并支持重入,增加写状态。
2. 读写锁如何实现分别记录读写状态
这里有一个地方需要重点关注,exclusiveCount(c)
方法,该方法源码为:
/** Returns the number of exclusive holds represented in count */
static int exclusiveCount(int c) {
return c & EXCLUSIVE_MASK;
}
其中EXCLUSIVE_MASK为: static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
EXCLUSIVE _MASK为1左移16位然后减1,即为65536-1。位运算符
而exclusiveCount方法是将同步状态(state为int类型)与EXCLUSIVE _MASK相与,即取同步状态的低16位。
根据exclusiveCount方法的注释为独占式获取的次数即写锁被获取的次数,现在就可以得出来一个结论:
同步状态的低16位用来表示写锁的获取次数。
同时 还有一个方法值得我们注意:
/** Returns the number of shared holds represented in count */
static int sharedCount(int c) {
return c >>> SHARED_SHIFT;
}
该方法是获取读锁被获取的次数,是将同步状态(int c)右移16次,即取同步状态的高16位,现在我们可以得出另外一个结论:同步状态的高16位用来表示读锁被获取的次数。
现在还记得我们开篇说的需要弄懂的第一个问题吗?读写锁 是怎样实现分别记录读锁和写锁的状态的,现在这个问题的答案就已经被我们弄清楚了,其示意图如下图所示:
也就是:高16位记录读状态,低16位记录写状态
3. 写锁的释放
写锁释放通过重写AQS的tryRelease方法,源码为:
protected final boolean tryRelease(int releases) {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
// 同步状态减去写状态
int nextc = getState() - releases;
// 当前写状态是否为0,为0则释放写锁
boolean free = exclusiveCount(nextc) == 0;
if (free)
setExclusiveOwnerThread(null);
// 不为0则更新同步状态
setState(nextc);
return free;
}
源码的实现逻辑请看注释,与ReentrantLock基本一致,这里需要注意的是,减少写状态int nextc = getState() - releases;
只需要用当前同步状态直接减去写状态的原因正是我们刚才所说的写状态是由同步状态的低16位表示的,可以结合上面的示意图理解。
2. 读锁详解
1. 读锁的获取
看完了写锁,现在来看看读锁,读锁不是独占式锁,即同一时刻该锁可以被多个读线程获取也就是一种共享式锁。按 照之前对AQS介绍,实现共享式同步组件的同步语义需要通过重写AQS的tryAcquireShared方法和tryReleaseShared 方法。读锁的获取实现方法为:
protected final int tryAcquireShared(int unused) { /*
* Walkthrough:
* 1. If write lock held by another thread, fail.
* 2. Otherwise, this thread is eligible for
* lock wrt state, so ask if it should block
* because of queue policy. If not, try
* to grant by CASing state and updating count.
* Note that step does not check for reentrant
* acquires, which is postponed to full version
* to avoid having to check hold count in
* the more typical non-reentrant case.
* 3. If step 2 fails either because thread
* apparently not eligible or CAS fails or count
* saturated, chain to version with full retry loop.
*/
Thread current = Thread.currentThread();
int c = getState();
// 如果写锁已经被获取并且获取写锁的线程不是当前线程,则线程获取读锁失败并返回-1
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
int r = sharedCount(c);
if (!readerShouldBlock() &&
r < MAX_COUNT &&
// 当前线程获取读锁
compareAndSetState(c, c + SHARED_UNIT)) {
// 新增关于读锁的一些功能,比如getReadHoldCount()方法返回
// 当前获取读锁的次数
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
}
return 1;
}
// CAS失败或者已经获取读锁的线程再次重入
return fullTryAcquireShared(current); }
代码的逻辑请看注释,需要注意的是当写锁被其他线程获取后,读锁获取失败,否则获取成功利用CAS更新同步状态。
另外,当前同步状态需要加上SHARED_UNIT((1 << SHARED_SHIFT)即0x00010000)的原因这是我们在上 面所说的同步状态的高16位用来表示读锁被获取的次数。
如果CAS失败或者已经获取读锁的线程再次获取读锁时, 是靠fullTryAcquireShared方法实现的
2. 读锁的释放
读锁释放的实现主要通过方法tryReleaseShared,源码如下,主要逻辑请看注释:
protected final boolean tryReleaseShared(int unused) {
Thread current = Thread.currentThread();
// 前面还是为了实现getHoldCount等新功能
if (firstReader == current) {
// assert firstReaderHoldCount > 0;
if (firstReaderHoldCount == 1)
firstReader = null;
else
firstReaderHoldCount--;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
rh = readHolds.get();
int count = rh.count;
if (count <= 1) {
readHolds.remove();
if (count <= 0)
throw unmatchedUnlockException();
}
--rh.count;
}
for (;;) {
int c = getState();
// 读锁释放,将同步状态减去读状态即可
int nextc = c - SHARED_UNIT;
if (compareAndSetState(c, nextc))
// Releasing the read lock has no effect on readers,
// but it may allow waiting writers to proceed if
// both read and write locks are now free.
return nextc == 0;
}
}
3. 锁降级
读写锁支持锁降级,遵循按照获取写锁,获取读锁再释放写锁的次序,写锁能够降级成为读锁,不支持锁升级。
4. 读写锁的应用
使用读写锁实现缓存
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* 读写锁实现缓存 */
class Cache {
static Map<String,Object> map = new HashMap<>();
static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
static Lock readLock = rwl.readLock();
static Lock writeLock = rwl.writeLock();
/**
* 线程安全的根据一个key获取value
* * @param key
* @return
*/
public static Object get(String key) {
readLock.lock();
try {
return map.get(key);
}finally {
readLock.unlock();
}
}
/**
* 线程安全的根据key设置value,并返回旧的value
* * @param key
* @param value
* @return
*/
public static Object put(String key, Object value) {
writeLock.lock();
try {
return map.put(key,value);
}finally {
writeLock.unlock();
}
}
/**
* 线程安全的清空所有value
*/
public static void clear() {
writeLock.lock();
try {
map.clear();
}finally {
writeLock.unlock();
}
}
}
上述代码使用Cache组合一个非线程安全的HashMap作为缓存的实现,同时使用读写锁保证Cache的线程安全性。
在get方法中,需要获取读锁,使得并发访问该方法时不会被阻塞。
set方法与clear方法在更新HashMap时必须获取 写锁,当获取写锁后,其他线程对于读锁和写锁的获取均被阻塞,而只有写锁被释放后,其他读写操作才能够继续。
Cache使用读写锁提升读操作的并发性,也保证每次写操作对所有读写操作的可见性。
10.6 Condition
任何一个java对象都天然继承于Object类,在线程间实现通信的往往会应用到Object的几个方法,比如 wait(),wait(long timeout),wait(long timeout, int nanos)与notify(),notifyAll()几个方法实现等待/通知机制。同样的, 在 java Lock体系下依然会有同样的方法实现等待/通知机制。
从整体上来看Object的wait和notify/notify是与对象监视器配合完成线程间的等待/通知机制,**而Condition与Lock配合完成等待通知机制,前者是java底层级别的,后者是 语言级别的,具有更高的可控制性和扩展性。**两者除了在使用方式上不同外,在功能特性上还是有很多的不同:
- Condition能够支持不响应中断,而通过使用Object方式不支持;
- Condition能够支持多个等待队列(new 多个Condition对象),而Object方式只能支持一个;
- Condition能够支持超时时间的设置,而Object不支持
参照Object的wait和notify/notifyAll方法,Condition也提供了同样的方法:
针对Object的wait方法
- void await() throws InterruptedException:当前线程进入等待状态,如果其他线程调用condition的signal或者 signalAll方法并且当前线程获取Lock从await方法返回,如果在等待状态中被中断会抛出被中断异常;
- long awaitNanos(long nanosTimeout):当前线程进入等待状态直到被通知,中断或者超时;
- boolean await(long time, TimeUnit unit)throws InterruptedException:同第二种,支持自定义时间单位
- boolean awaitUntil(Date deadline) throws InterruptedException:当前线程进入等待状态直到被通知,中断或 者到了某个时间
针对Object的notify/notifyAll方法
- void signal():唤醒一个等待在condition上的线程,将该线程从等待队列中转移到同步队列中,如果在同步队 列中能够竞争到Lock则可以从等待方法中返回。
- void signalAll():与1的区别在于能够唤醒所有等待在condition上的线程
10.7 LockSupport
在AbstractQueuedSynchronizer中,使用LockSupport的park()和unpark()操作来控制线程的运行状态。
LockSupport可以理解为一个工具类。它的作用很简单,就是挂起和继续执行线程。API如下:
1.public static void park() : 如果没有可用许可,则挂起当前线程
public static void park(){
//permit默认是0,所以一开始调用park方法,当前线程就会阻塞,
//直到别的线程将当前线程的permit设置为1时,park方法会被唤醒
//然后会将permit再次设置为0并返回
UNSAFE.park(false,0L);
}
2.public static void unpark(Thread thread):给thread一个可用的许可,让它得以继续执行
public static void unpark(Thread thread){
//调用unpark()方法后,就会将thread线程的许可证permit设置成1.
//注意多次调用unpark方法不会累加permit值还是1,会自动唤醒thread线程
//即之前阻塞中的LockSupport()方法会立即返回
UNSAFE.unpark(thread);
}
例如:
LockSupport.unpark(Thread.currentThread());
LockSupport.park();
park()之后,当前线程可以继续执行。那是因为在park()之前,先执行了unpark(),进而释放了一个许可,也就是说当前线程有一个可用的许可。而park()在有可用许可的情况下,是不会阻塞线程的。
综上所述,park()和unpark()的执行效果和它调用的先后顺序没有关系。这一点相当重要,因为在一个多线程的环境中,我们往往很难保证函数调用的先后顺序(都在不同的线程中并发执行),因此,这种基于许可的做法能够最大限度保证程序不出错。
需要注意的是凭证的累加上限是1唤醒两次后阻塞两次,但最终结果还是会阻塞线程:
因为凭证的数量最多为1,连续调用两次unpark和调用一次unpark效果一样,只会增加一个凭证。 调用两次park却需要消费两个凭证,证不够,不能放行
补充让线程阻塞和唤醒的方法:
- 方法1: 使用Object中的wait()方法让线程阻塞,使用Object中的notify()方法唤醒线程
- 方法2:使用JUC包中Condition的awati()方法让线程阻塞,使用signal唤醒线程
- 方法3: 使用LockSupport类可以阻塞线程以及唤醒执行被阻塞的线程
10.8 synchronized与Lock的对比
- Lock是显式锁(手动开启和关闭锁,别忘记关闭锁),synchronized是隐式锁,出了作用域自动释放
- Lock只有代码块锁,synchronized有 码块锁和方法锁
- 使用Lock锁,JVM将花费较少的时间来调度线程,性能更好。并且具有.更好的扩展性( 提供更多的子类)
十一、线程通信(wait/notify/notifyAll)
package com.fwb.threadCommunication;
/**
* @author fengweibo
* @version 1.0
* @date 2021/5/26 16:03
*
* 线程通信的例子,实现AB线程交替执行。
* 使用前提:
* 只能出现在同步方法或同步代码块中
* 执行wait,线程会进入阻塞状态,同时释放锁,sleep不会释放,wait默认使用当前对象作为锁,如果使用其他对象需要注明。
* notify:一旦执行此方法就会唤醒被wait的一个线程,如果有多个线程被wait,唤醒优先级高的线程。
* notifyAll:唤醒所有被wait的线程。
*/
public class Test1 {
public static void main(String[] args) {
ThreadTest threadTest1 = new ThreadTest();
ThreadTest threadTest2 = new ThreadTest();
threadTest1.setName("A");
threadTest2.setName("B");
threadTest1.start();
threadTest2.start();
}
}
class ThreadTest extends Thread{
static int a = 100;
@Override
public void run(){
synchronized (ThreadTest.class) {
while (true){
ThreadTest.class.notify();
System.out.println(Thread.currentThread().getName() + a);
a--;
try {
//wait会释放锁,sleep不会释放
//wait默认使用当前对象作为锁,如果使用其他对象需要注明
ThreadTest.class.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
if (a <=0){
break;
}
}
}
}
}
注:
sleep与wait的异同:
- 相同点:
都可以让线程停止执行,进入阻塞状态。 - 异同点
a. sleep声明在Thread类中,wait声明在Object类中。
b. 如果两个方法都在同步代码块中,sleep不会释放锁,wait会释放锁。
c. sleep可以在任何地方调用,wait只能在同步代码块中。