目录
并发编程概述
程序、进程和线程:
进程:是程序运行和资源分配的基本单位,一个程序至少有一个进程,一个进程至少有一个线程。
线程:是进程的一个实体,是 cpu 调度和分派的基本单位,是比程序更小的能独立运行的基本单位。一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行 。
单核CPU,其实是一种假的多线程,因为在一个时间单元内,也只能执行一个线程的任务。如果是多核的话,才能更好的发挥多线程的效率。
一个Java应用程序java.exe,其实至少有三个线程:main()主线程,gc()垃圾回收线程,异常处理线程。当然如果发生异常,会影响主线程。
并行与并发:
并行:多个CPU同时执行多个任务。
并发:一个CPU(采用时间片)同时执行多个任务。
通常既有并发也有并行。
线程的优先级和调度:
我们可以“建议”操作系统给某些线程多分配一点执行时间,但线程优先级并不是一项稳定的调节手段, 很显然因为主流虚拟机上的Java线程是被映射到系统的原生线程上来实现的, 所以线程调度最终还是由操作系统说了算。 如果 cpu 比较忙,那么优先级高的线程会获得更多的时间片,但 cpu 闲时,优先级几乎没作用
定义优先级:MAX_PRIORITY:10 ;MIN _PRIORITY:1 ;NORM_PRIORITY:5(默认优先级)
相同优先级的线程由CPU执行时间片调度(抢占式调度),随机调度,两种线程输出有交叉;高优先级线程也执行时间片调度,高优先级线程执行概率较高,和低优先级交叉概率较低。
▲注意:如果在主线程中启动多线程的代码相连,那么先执行哪个线程不由start顺序决定。
java中线程的分类:
用户线程:平时用到的普通线程均是用户线程,当在Java程序中创建一个线程,它就被称为用户线程。默认都是用户线程。
守护线程:是服务线程,准确地来说就是服务其他的线程,这是它的作用。一般守护线程是一个死循环,所有的用户线程结束后,守护线程会自动结束。
区别在于:
用户线程:当存在任何一个用户线程未离开,JVM是不会离开的。
守护线程:如果只剩下守护线程未离开,JVM是可以离开的。
转换:
用户线程:thread.setDaemon(false)
守护线程:thread.setDaemon(true)
垃圾回收线程是一个守护线程。
上下文切换
多线程会共同使用一组计算机上的CPU,而线程数大于给程序分配的CPU数量时,为了让各个线程都有执行的机会,就需要轮流使用CPU。不同的线程切换使用CPU发生的数据切换等就是上下文切换
线程的创建和启用
创建新线程的方法:
1、继承Thread类
Thread类的特性:
1、每个线程都是通过某个特定Thread对象的run()方法来完成操作的,经常把run()方法的主体称为线程体。
2、通过该Thread对象的start()方法来启动这个线程,而非直接调用run()。
步骤:
1、定义子线程类继承Thread类。
2、子线程类中重写Thread类中的run方法。
3、创建Thread子线程类对象。
4、调用子线程对象start方法:①启动新线程;②调用当前线程run方法。
▲如果想重新新建线程,一个对象不能调用两次start方法,如果新线程run的方法不变需重新new对象来调用start();如果新线程run的方法要改变则需要重新继承Thread类,重新写run方法。
Thread类的方法:
1、start(): 启动线程,并执行对象的run()方法
2、run(): 线程在被调度时执行的操作,如果不适用线程的start方法而直接使用线程的run方法,那么相当于在main线程中执行相关操作。
3、String getName(): 返回线程的名称
4、void setName(String name):设置该线程名称
5、static Thread currentThread(): 返回当前线程类的对象。在Thread子类中就是this,通常用于主线程和Runnable实现类
6、static void yield():线程让步:让当前线程从Runnable的Running进入Runnable的Ready就绪状态,把CPU时间片调度分给优先级相同或更高的线程的概率增加,但是自己也有可能获得CPU时间片调度。若队列中没有其他线程,则CPU时间片调度还是会分给当前线程。
7、static void sleep(long millis):令当前活动线程进入BLOCKED阻塞状态,不占用CPU时间片,阻塞指定时间段。
sleep和yield:
sleep会让线程进入TIMED WAIT状态,不会获得时间片调度,yield进入Runnable的Ready状态,有可能获得时间片调度。
sleep有参数,yield没有参数
8、void join():当线程A中调用其他正在执行线程的join()方法时,线程A将被阻塞,直到其他线程执行完为止(低优先级的线程也可以获得执行)。如果让执行完的线程join等于没有join。这时调用interrupt方法会让当前睡眠方法唤醒并中断并抛出InterruptedException异常。
8、void join(long):当线程A中调用其他正在执行线程的join(long)方法时,线程A将被阻塞,直到其他线程执行完或者阻塞时间到为止。这时调用interrupt方法会让当前睡眠方法唤醒并中断并抛出InterruptedException异常。
9、void interrupt():可以打断阻塞状态和运行状态的线程标记位,线程有一个打断标记位,如果调用interrupt方法则标记位变为true,如果没有调用interrupt方法则标记位为false。如果打断的是阻塞状态的线程,那么标记位会被从true自动重置为false;如果打断的是运行状态的线程,那么标记位不会自动重置,为true。注意:对运行状态Runnable的线程调用interrupt方法不会真正让线程停止运行,线程还会继续执行,只是改变标志位的状态,可以让程序自己通过下面两种方法来控制到底是否停止运行这种通过标识位或者中断操作的方式能够使线程在终止时有机会去清理资源,而不是武断地将线程停止,因此这种终止线程的做法显得更加安全和优雅。对sleep/wait/join阻塞状态的线程调用interrupt方法会抛出异常,可以在异常中选择是否真正打断。而对park阻塞状态的线程调用interrupt会唤醒线程,改变标记位,不会清除标识位。
10、static boolean interrupted():判断当前线程是否被打断,返回后会清空标记位状态
11、boolean isInterrupted():判断当前线程是否被打断,返回后不会清空标记位状态
12、boolean isAlive():返回boolean,判断线程是否还活着
13、getPriority() :返回线程优先值
14、setPriority(int newPriority) :改变线程的优先级,高优先级的被时间片调度的几率更大。
15、setDaemon(false):将守护线程转变为用户线程
16、setDaemon(true):将用户线程转变为守护线程
Thread类构造器:
Thread(): 创建新的Thread对象
Thread(String threadname): 创建线程并指定线程实例名
Thread(Runnable target): 指定创建线程的目标对象,它实现了Runnable接口中的run方法
Thread(Runnable target, String name): 创建新的Thread对象
2、实现Runnable接口
步骤:
1、定义运行的逻辑类,实现Runnable接口。
2、运行逻辑类中重写Runnable接口中的run方法。
3、创建运行逻辑类对象。
4、创建Thread类对象,将Runnable接口的运行逻辑类对象作为实际参数传递给Thread类的构造器中。
5、调用Thread类的start方法:①开启线程;②调用运行逻辑接口的run方法。
▲如果想重新新建线程,一个对象不能调用两次start方法,如果新线程run的方法不变需重新new Thread对象来调用start();如果新线程run的方法要改变则需要新建一个运行逻辑类或直接在Thread构造器中传入新建匿名子类的匿名对象。
▲注意创建线程和调用start方法都是main线程去做,run方法里的才是另一个线程。
▲如果自己手动调用run()方法,那么就只是普通方法,没有启动多线程模式。
▲开发中优先使用实现接口的方式:1、打破单继承局限性,脱离了Thread体系,让逻辑实现类还可以实现别的接口。2、天然适合多个线程处理同样的数据(因为继承的方式需要将共享数据声明为static,不然不同子线程不能处理同样数据)。3、增强程序健壮性,代码和数据是独立的。4、更容易与高级API配合,如线程池
▲注意当主线程开启分线程后,main方法中的主线程和其他线程是并发工作的。
3、实现Callable接口
步骤:
1、定义一个运行逻辑类,实现Callable接口。
2、运行逻辑类中重写call()方法,注意此call()方法可以有返回值并能抛出异常。
3、创建运行逻辑类对象,将运行逻辑类对象传入FutureTask构造器,创建FutureTask类的对象。
4、将FutureTask类的对象传入Thread构造器中,创建Thread类的对象。
5、调用Thread类的start方法。
6、若有需要,可获取运行逻辑类中重写call()方法的返回值:futureTask.get(),由于call方法在其他线程中调用,因此会阻塞等待原线程call方法执行完后才返回,体现了同步的概念,该方法也会抛出异常。
为什么Callable接口比Runnable接口更强大?
1、call()方法有返回值,可以使用get方法同步等待获取结果;
2、call()方法能抛出异常;
3、Callable可以支持泛型。
4、使用线程池
线程的状态
操作系统层面5种:
新建:当一个Thread类或其子类的对象被声明并创建时,新生的线程对象处于新建状态
就绪:处于新建状态的线程被start()后,将进入线程队列等待CPU时间片,此时它已具备了运行的条件,只是没分配到CPU资源。
运行:当就绪的线程被调度并获得CPU时间片资源时,便进入运行状态,run()方法定义了线程的操作和功能。
阻塞:在某种特殊情况下,被人为挂起或执行输入输出操作时,让出CPU时间片调度并临时中止自己的执行,进入阻塞状态。
死亡:线程完成了它的全部工作或线程被提前强制性地中止或出现异常导致结束。
java层面6种
NEW,RUNNABLE,WAITING,TIMED_WAITING,BLOCKED,TERMINATED
从runnable到blocked可以是synchronized关键字也可以是lock对象
线程的同步
1、线程安全问题:当多个线程对具有共享变量的多个语句执行写操作时(不能只看成执行同一个run方法,也可能在不同的run方法也有共享变量)时,一个线程对多条语句只执行了一部分,还没有执行完,另一个线程就开始执行。导致共享数据(共同操作的变量)的错误。
2、此问题的原因:jvm虚拟机中字节码指令因为上下文切换引起的指令交错。在jvm虚拟机中一条修改值的java指令通常分为多个字节码指令,当a线程没有来的及将修改后的值(比如是1)存入变量后被上下文切换到了b线程时,不管b线程中是怎么操作,接下来执行a线程存值后变量的值仍然是1。
3、解决办法:对多条操作共享数据的语句,只能让一个线程都执行完,在执行过程中,其他线程不能执行。对共享变量进行写入时,结果要正确,必须保证是原子操作。原子操作是指不能被中断的一个或一系列操作。
①阻塞式:synchronized,LOCK锁,其他线程会处于BLOCKED状态。
②非阻塞式:原子变量
4、具体方法:
①synchronized同步代码块
格式:synchronized(锁){
需要被同步的代码
}
▲任何一个引用类型的对象都可以充当锁,但是也要把对象和锁的概念区分,一个对象同时可以作为对象和锁。一个线程执行过程中“锁”不能由其他线程使用,直到执行完毕把“锁”归还。但是操作共享数据的多个线程(不管是执行同一个run方法的多个线程还是执行不同run方法的多个线程)只能用一把锁,也就是只能用同一个对象。
①可以用this当锁,前提是确定this类只会有一个对象
②Java还支持对“任意对象”作为对象监视器来实现同步的功能。这种任意对象大多是该方法所属类中的实例变量,不然抛开这个类去使用别的对象作为对象监视器,意义不大,前提也是该类只会创建一个对象;如果不能保证,则应该将该实例变量设为static。
③不能保证该类只会创建一个对象时,用“该类名.class”表示锁。因为类本身是Class类的对象。
▲t1线程在执行过程中如果有其他CPU的调度让t2线程执行,但是运行到synchronized时,会会从RUNNABLE进入到BLOCKED状态。
这中间即使 t1 的 cpu 时间片不幸用完,被踢出了门外(不要错误理解为锁住了对象就能一直执行下去哦),这时门还是锁住的,t1 仍拿着钥匙,同一个CPU把时间片分给了t2线程,如果也运行到了synchronized同一个锁,这时其他线程还会进入阻塞状态进不来,从RUNNABLE进入到BLOCKED状态,只有下次轮到 t1 自己再次获得时间片时才能开门进入
▲synchronized代码块要把共享数据全都包裹但不能包含过多其他代码。如果把while(true)包含,则会让先执行run的线程一一直循环,直到把ticket消耗完才出来。
②synchronized同步方法
如果操作共享数据的代码被写在一个方法中,我们不妨将整个方法加synchronized
格式:在由需要被同步代码组成的方法前加synchronized
▲同样有“锁”但是不用显式表示出来。
不加static同步方法中的锁指“this”,也就是该类对象;
加static同步方法中的锁指类.class。
▲同样含有synchronized方法的线程也会发生上下文切换,切换到没有锁的线程就罢了,该线程会正常运行,但一旦切换到执行到有同样锁的synchronized的线程,这个线程就会从RUNNABLE进入到BLOCKED状态。
▲同样要注意改同步方法也要包裹所有共享数据但不能包含过多代码,原因同上。
③Lock实现类锁(主要是ReentrantLock)
以Reentrantlock为例
相比synchronized的主要特点是:
可以对阻塞状态中断:
lock.lockInterruptibly()方法可以让在有线程竞争的时候,没有竞争到锁的线程在BLOCKED状态时可让别的线程调用interrupt方法人为地中断这种状态,可以用来解决死锁问题。synchronized不行,没有竞争到锁时只能阻塞等待。
可以非阻塞加锁:
lock.tryLock方法是让线程尝试竞争锁,如果不成功不会像其他方法一样进入阻塞等待,而是返回false,无论如何都会返回。lock.tryLock(Long)方法时让线程尝试竞争锁,如果竞争不到锁则在等待指定时间内保持运行RUNNABLE并同时竞争锁,如果在等待时间内获取不到锁,返回false,可以让我们决定接下来如何执行,最后返回boolean,第二种方法会在等待时间内被打断。两种方法通常都搭配if使用。
可以设置为公平锁:
可以设置获取锁的顺序为先来后到,先进入阻塞队列的先获得锁。
支持多个条件变量:
这里指ConditionObject,类似synchronized中的Waitset休息室,condition.await()在线程中调用这个方法表明让线程进入这个休息室,可以创建不同的休息室让不同线程等待。其意义就在于能精确唤醒某个线程。
共同特点是:
可重入:
可重入是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住 。
格式:class A{
ReentrantLock reentrantLock = new ReenTrantLock(Boolean fair);
//若fair=true,则是公平锁,加锁前检查是否有排队等待的线程,优先排队等待的线程,先来先得一个线程。若是非公平锁(Nonfair):加锁时不考虑排队等待问题,直接尝试获取锁,获取不到自动到队尾等待
public void 方法名(){
reentrantLock.lock();
try{
//执行代码;
}
finally{
lock.unlock();
}
}
}
▲ReentrantLock和synchronized都是可重入锁
▲不要将获取锁的过程写在try块中,因为如果在获取锁(自定义锁的实现)时发生了异常,异常抛出的同时,也会 导致锁无故释放
▲Lock实现类锁和synchronized的异同:
1、Lock实现类锁是显式锁(手动开启和关闭锁,别忘记关闭锁),synchronized是隐式锁,出了作用域自动释放
2、Lock实现类锁只有代码块锁,synchronized有代码块锁和方法锁。
3、使用Lock实现类锁,JVM将花费较少的时间来调度线程,性能更好。并且具有更好的扩展性(提供更多的子类)
▲new ReenTrantLock(形参)构造器中可以选择boolean形参,true为线程公平。
▲在lock方法中,Reentrantlock对象就相当于“锁”,也不用显式的表示出来。
在实现Runnable中,不用考虑static;
在继承Thread中,要注意Reentrantlock一般要static。
④ReadWriteLock实现类锁(主要是ReentrantReadWriteLock)
使用ReentrantLock锁的这种保护有时候会有点过头。因为我们发现,任何时刻,只允许一个线程修改,但是,get()方法只读取数据,不修改数据,它实际上允许多个线程同时调用。
实际上我们想要的是:允许多个线程同时读,但只要有一个线程在写,其他线程就必须等待。ReadWriteLock锁保证了只允许一个线程写入(其他线程既不能写入也不能读取);多个线程允许同时读(其他线程不能写入)。
格式:class A{
ReadWriteLock rwlock = new ReentrantReadWriteLock();
Lock rlock = rwlock.readLock();
Lock wlock = rwlock.writeLock();
public void 方法名(){
try{
rlock.lock();
//保证是只读取数据的代码,允许多个线程同时读取;
}
finally{
rlock.unlock();
}
}
public void 方法名(){
try{
wlock.lock();
//执行代码;
}
finally{
wlock.unlock();
}
}
}
⑤StampedLock锁(乐观锁)
和ReadWriteLock相比,写入的加锁是完全一样的,StampedLock把读锁细分为乐观读和悲观读,能进一步提升并发效率。
5、共享变量的线程安全(建议看PDF)
这里的共享变量分为局部变量,静态变量,实例变量
实例变量和静态变量的线程安全(实例变量和静态变量在堆和方法区中,所有线程都可以访问到,容易出现安全问题):
如果变量只在一个线程中使用过或者是线程安全类,没有共享,那么线程对该变量操作是安全的
如果变量在线程间都有过操作
如果只有读操作,则线程安全
如果有读写操作,需要考虑线程安全问题
局部变量的线程安全(局部变量在栈中,而每个线程都有一个自己的栈,不容易出现安全问题):
局部变量被初始化为基本数据类型是安全的
局部变量是引用类型或者是对象引用则未必是安全的
如果局部变量引用的对象是和当前线程绑定的,即当前线程运行到该方法时新建一个引用对象,另外一个线程运行到该方法时又新建了另一个引用对象,没有共享,那么是线程安全的
如果局部变量引用的对象共享给了另外的线程(比如下面的例子,在子类中重写方法里创建新线程,由于三个方法的list是相同的list,所以对于主线程和新线程来说共享了list,则list局部变量也是不安全的),那么要考虑线程安全问题
▲对于局部变量所在方法来说,设置为private或final可以避免被子类重写,以免创建新线程而导致局部变量出现线程安全问题。
6、常见的线程安全类:
这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的,因为有些方法加了锁或者类本身是不可变类(String,Integer),方法内的共享变量不会受到影响。
但注意它们多个方法的组合不是线程安全的 。
线程的通信
可以在synchronized关键字下使用:
使用wait/notify线程通信:
wait/notify的原理如下
锁对象.wait():让该线程进入Monitor的WaitSet中,并释放当前锁。调用此方法后,当前线程将释放对象监控权 ,然后进入等待WAITING状态。在当前线程被notify后会立刻进入Monitor的EntryList中处于BLOCLED状态,等待锁的释放然后重新竞争锁,竞争成功后从断点处继续代码的执行。
用if判断的话,唤醒后线程会从wait之后的代码开始运行,但是不会重新判断if条件,直接继续运行if代码块之后的代码;而如果使用while的话,也会从wait之后的代码运行,但是唤醒后会重新判断循环条件,如果不成立再执行while代码块之后的代码块,成立的话继续wait。
sleep和wait方法的异同:
相同:1、两种方法都能造成线程的阻塞。
不同:1、sleep方法原始定义在Thread类中,wait方法原始定义在Object类中;
2、sleep方法可以在任何位置使用,wait方法只能使用在同步代码块和同步方法中(只能配合synchronized);
3、sleep方法不会释放锁,wait方法会释放锁;
4、sleep方法只能阻塞有限时间,wait方法可以一直阻塞。
锁对象.notify():唤醒随机一个WaitSet的线程,该方法存在虚假唤醒,即唤醒线程后发现条件不满足。
锁对象.notifyAll():唤醒所有WaitSet中的线程,一般都用这个方法。
▲这三种方法只能在synchronized(同步代码块和同步方法)中使用;
▲notify()和notifyAll()调用后要等到当前线程走完synchronized释放锁后才能让被唤醒线程开始争夺
▲三种方法只能由当前同步代码块或同步方法中的“锁”来调用;
▲因为这三个方法必须有锁对象调用,而任意对象都可以作为synchronized的同步锁,因此这三个方法只能在Object类中声明。
线程通信使用模板:
synchronized(lock) {
while(条件不成立) {
lock.wait();
}
// 干活
}
//另一个线程
synchronized(lock) {
lock.notifyAll();
}
保护性暂停
用在一个线程等待另一个线程的一个执行结果,相当于简化版的生产者消费者模型;
有一个结果需要从一个线程传递到另一个线程,让他们关联同一个GuardedObject,如果有结果不断从一个线程到另一个线程那么可以使用消息队列(见生产者/消费者);
JDK 中,join 的实现、Future 的实现,采用的就是此模式。因为要等待另一方的结果,因此归类到同步模式。
保护性暂停的模板:
把线程间要返回的用于线程通信的结果数据设置成成员变量,生成存取方法,两方法通过wait/notifyAll通信。
异步模式之生产者消费者模式:
与前面的保护性暂停中的 GuardObject 不同,不需要产生结果和消费结果的线程一一对应;
消费队列可以用来平衡生产和消费的线程资源。生产者仅负责产生结果数据,不关心数据该如何处理,而消费者专心处理结果数据;
消息队列是有容量限制的,满时不会再加入数据,空时不会再消耗数据;
JDK 中各种阻塞队列,采用的就是这种模式 。因为不需要等待消费者线程返回的值,只用专注生产,所以异步。
消息队列模板(理论上只用创造这一个类就行,分别在两个线程里使用存取方法即可)
把消息队列看成成员变量list,把要存取的元素看成list的元素,生成存取方法,互相wait/notifyAll通信。
在Lock接口实现类下的线程通信:
Condition.await()底层是调用LockSupport.park()来实现阻塞当前线程的。
await/signal:
可以用同一个锁创建多个休息室,这里的休息室不是synchronized的Waitset,而是conditionObject,对不同的线程调用await方法让他们进入不同的休息室 。其他用法和wait/notify相同
ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();
condition.await():在线程中调用这个方法表明让线程进入这个休息室,用法和wait()相同。
condition.signal():用法和notify()相同。
condition.signalAll():用法和notifyAll()相同。
使用LockSupport类中的park和unpark方法,但是LockSupport中的park/unpark方法底层使用的是Unsafe类的
park/unpark方法:
park/unpark的原理如下
LockSupport.park():
每个线程都有自己的一个 Parker 对象,内部由三属性组成 _counter、 _condition 条件变量 和 _mutex互斥锁
当线程调用 park() 方法时,先检查 _counter,如果为 0,则获得 _mutex锁 然后进入 _cond 阻塞,最后设置 _counter = 0;如果为1,则不进入阻塞接着运行,最后设置counter为0。
park方法不会释放锁。
LockSupport.unpark(线程对象):
先park再unpark:
先park会让线程阻塞,此时counter属性为0,unpark时先把counter置为1,再唤醒线程,释放互斥锁,最后counter=0
先unpark再park:
先把counter置为1,此时没有线程唤醒,park时先检查counter,此时为1,这时不进入阻塞继续运行,最后将counter置为0。
和wait/notify相比的特点:
1、可以先也可以后
2、不需要用锁或condition调用
3、阻塞和唤醒时都是针对某个线程
synchronized的原理(重量)
synchronized修饰代码块
每个 Java 对象都可以关联一个 Monitor 对象,Monitor对象由jvm提供。当字节码指令执行到monitorenter 指令时,该java对象的对象头中指向一个Monitor ,此时该对象可以作为锁。当执行到monitorexit 指令时,释放Monitor,释放锁。
以上是成功获取锁的情况,在Monitor底层中,如果Monitor已经被关联,则
1、首先进行自旋等待尝试竞争锁,如果不成功进入ContentionList。这对那些已经在等待队列中的线程来说,显得不公平。
2、进入ContentionList中,ContentionList是一个先入先出虚拟队列,从头部入,从尾部出。当owner线程释放锁后,会从ContentionList尾部迁移线程进入EntryList中。
3、进入EntryList会指定EntryList中的某个线程(一般为队头)为OnDeck线程。OnDeck线程是唯一一个具有竞争资格的线程,需要重新和没有进入Monitor底层的线程竞争锁。这样做虽然牺牲了一定的公平性,但极大的提高了整体吞吐量,在 Hotspot中把OnDeck的选择行为称之为“竞争切换”。
4、OnDeck线程获得锁后即变为owner线程,没有则会依然留在EntryList中。
5、如果Owner线程被wait方法阻塞,则转移到WaitSet队列;如果在某个时刻被notify/notifyAll唤醒, 则再次转移到EntryList。还要重新获取锁
synchronized修饰方法
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用
synchronized原理(轻量,偏向,无锁)
轻量级锁
当一个对象处于无锁状态(001),加锁但是没有线程竞争;
或者偏向锁遇到其他线程无竞争的获取时会升级为轻量级锁
Lock Record对象
遇到synchronized后,也是由JVM在每个线程的栈帧中创建
上锁和解锁:
1、遇到synchronized后,创建锁记录Lock Record空间,每个线程的栈帧都会包含锁记录的结构。
2、让锁记录中 Object reference 指向锁对象,并尝试用 cas 把锁记录中的自己的地址替换为 Object 的 Mark Word,将 Mark Word 的值存入锁记录 (为什么要交换?看对象头的信息表,轻量级锁对应的markword就是锁记录)
如果 cas 替换成功(对象头mark word状态为01),对象头中存储了锁记录地址和状态 00 ,表示由该线程给对象加锁
如果 cas 失败(对象头mark word状态为00),有两种情况
如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,若当前只有一个等待线程(也就是总共两个线程),当前线程便尝试使用自旋来获取锁。自旋一定次数获取不到或者有第三个线程竞争会升级为重量级锁进入锁膨胀过程。
如果是自己又执行了 synchronized ,锁重入了,那么再添加一个 Lock Record 作为重入的计数
3、当退出 synchronized 代码块(解锁时)如果有取值为null 的锁记录,表示有重入,这时去掉锁记录指向Object的指针,表示重入计数减一
4、当线程退出 synchronized 代码块的时候,如果获取的锁记录取值不为 null,那么使用 cas 将 Mark Word 的值恢复给对象
成功则解锁成功,mark word变成001的状态。
失败,则说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
锁膨胀
如果在尝试加轻量级锁的过程中,cas 操作无法成功,这时有一种情况就是其它线程已经为这个对象加上了轻量级锁,这时就要进行锁膨胀,将轻量级锁变成重量级锁,因为开始存在竞争,轻量级锁安全系数不够。
上锁和解锁:
1、当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
2、这时 Thread-1 加轻量级锁失败,进入锁膨胀流程,
①为对象申请Monitor锁,让Object指向重量级锁地址
②然后自己进入Monitor 的EntryList 变成BLOCKED状态
3、当 Thread-0 退出 synchronized 同步块时,使用 cas 将Lock Record空间中Mark Word的值恢复给对象头,因为对象的对象头指向 Monitor,所以恢复失败,会进入重量级锁的解锁过程,即按照 Monitor 的地址找到 Monitor 对象,将 Owner 设置为 null ,唤醒 EntryList 中的 Thread-1 线程
偏向锁
轻量级锁每次遇到synchronized都需要尝试执行 CAS 替换操作,在没有竞争时比较繁琐。Java 6 中引入了偏向锁来做进一步优化:只有第一次synchronized时使用 CAS 将线程 ID 设置到对象的 Mark Word 头(不再存锁记录地址),之后如果遇到synchronized时,检查锁的markword发现这个线程 ID 是自己的就不用重新 CAS。如果发现在没有竞争的情况下线程不是自己的,则升级为轻量级锁;如果有竞争,则升级为重量级锁 。
一个对象创建时:
1、如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为 101,这时它的thread、epoch、age 都为 0
2、偏向锁是默认是有几秒延迟的,不会在程序启动时立即生效,可以加 VM 参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟
3、如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为 001,这时它的 hashcode、age 都为 0,第一次用到 hashcode 时才会赋值
4、处于偏向锁的对象解锁后,不会主动释放偏向锁,所以尾部还是101,而其线程 id 仍存储于对象头中,不会消失;轻量级锁和重量级锁解锁后会变成001的无锁状态。
当当前线程持有锁的时候其他线程同时竞争偏向锁时:锁升级为重量级锁
当没有线程持有该锁时,即其他线程在不同时刻使用同一个偏向锁对象时:
锁升级
当一个线程的锁对象使用完后(即synchronized结束),又在另一个线程中使用到了这个锁对象(不是在一个线程使用锁对象的过程中另一个线程尝试获取,那样是锁竞争),那么因为这个锁对象mark word里还保留这上一个线程ID,所以会导致锁升级为轻量级锁,锁对象mark word的状态变成00,指向新线程的Lock Record空间
批量重偏向
当锁升级(t2线程在不竞争的情况下使用之前t1使用过的偏向锁19次后)在第20次的时候,会进行批量重偏向。前19次进行锁膨胀,升级为轻量级锁00,退出后变为001;第20次开始进行重偏向,将原来的偏向的t1线程ID更改为t2线程ID101,退出后仍然保留着t2线程ID101。
批量重偏向后还是在t2线程在不竞争的情况下使用之前t1使用过的偏向锁的情况下才会更改为t2ID
批量撤销
当锁升级的次数在第40次的时候,会进行批量撤销,后面所有的锁对象的状态都会变成无锁,类的对象和类中新建对象都是无锁的。
以PDF例子:t2在第1-19个锁对象执行了锁升级,退出后锁状态为无锁,在第20-39个执行了重偏向,执行完锁状态为t2线程ID偏向锁;t3在第1-19个锁执行了无锁对象正常加锁操作,无锁状态synchronized加的是轻量锁,退出后锁状态为无锁,在第20-39个执行了锁膨胀,执行完锁状态为t3偏向锁,此时一共执行了t2+t3=19+20=39次锁膨胀,第40个往后就会执行锁撤销。
膨胀为重量级锁
Mark Word大部分的空间(23个比特) 都用于存储持有锁的线程ID了, 这部分空间占用了原有存储对象哈希码的位置, 那原来对象的哈希码怎么办呢?
当一个对象已经计算过一致性哈希码后, 它就再也无法进入偏向锁状态了; 而当一个对象当前正处于偏向锁状态, 又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。
偏向锁遇到竞争的情况会膨胀为重量级锁。
在偏向锁或轻量级锁中调用wait/notify/notifyAll后会自动膨胀为重量级锁。
锁消除
锁削除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据的锁进行消除。
JMM内存问题和可见性有序性
JMM
java内存模型,屏蔽各种硬件和操作系统的内存访问差异, 以实现让Java程序在各种平台下都能达到一致的内存访问效果。
围绕以下几个方面建立:
原子性 - 保证指令不会受到线程上下文切换的影响
可见性 - 保证指令不会受 cpu 缓存的影响
有序性 - 保证指令不会受 cpu 指令重排优化的影响
一个线程的工作内存和主内存
主内存:我们常说的物理内存,共享变量(实例变量,静态变量)都存储在主内存中 。
工作内存:指每个CPU提供的资源,包括cpu寄存器,cpu高速缓存。工作内存中保存了被该线程使用的共享变量的主内存副本,保存的是变量副本而不是对象本身。线程对变量的所有操作(读取、 赋值等) 都必须在工作内存中进行, 而不能直接读写主内存中的数据,不同的线程之间也无法直接访问对方工作内存中的变量。
主内存和工作内存与jvm内存模型没有任何联系,属于不同层面上的定义。如果一定要勉强对应起来,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。
内存之间的交互操作
一个变量如何从主内存拷贝到工作内存、 如何从工作内存同步回主内存这一类的实现细节, Java内存模型中定义了以下8种原子操作来完成。
lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
CAS操作与原子整数
CAS操作
该操作由sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()和compareAndSwapObject()等几个方法包装提供。J.U.C包里面的原子类, 例如compareAndSet()和getAndIncrement()等方法都使用了Unsafe类的CAS操作来实现。
CAS能保证修改变量的值是线程安全的,CAS的底层是 lock cmpxchg 指令(X86 架构),在单核 CPU 和多核 CPU 下都能够保证【比较-交换】的原子性,在多核状态下,某个核执行到带 lock 的指令时,CPU 会让总线锁住,当这个核把此指令执行完毕,再开启总线。这个过程中不会被线程的调度机制所打断,保证了多个线程对内存操作的准确性,是原子的。
在原子类中有一个boolean方法compareAndSet(prev,next),这是一个对变量进行修改的方法,prev是当前线程调用get方法获取到的变量值(也即传入原子类中的值),如果运行该方法的时候当前线程获取到的值和prev相同,就把该变量修改为next,如果该方法成功修改返回true,没有修改成功,运行方法时的变量值和之前获取到的变量值prev不一致,说明有别的线程修改了变量,返回false。
compareAndSet(prev,next)必须借助 volatile 才能读取到共享变量的最新值来实现【比较并交换】的效果,传入原子类的变量值要用volatile修饰,以便于让其他线程更改变量后能在当前线程调用compareAndSet读取变量时及时比较。
compareAndSet(prev,next)方法一般作为模板封装为其他具体方法
模板
为什么无锁效率高
无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而 synchronized 会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。打个比喻线程就好像高速跑道上的赛车,高速运行时,速度超快,一旦发生上下文切换,就好比赛车要减速、熄火,等被唤醒又得重新打火、启动、加速... 恢复到高速运行,代价比较大。但无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,CPU 在这里就好比高速跑道,没有额外的跑道,线程想高速运行也无从谈起,虽然不会进入阻塞,但由于没有分到时间片,仍然会进入可运行状态,还是会导致上下文切换。
CAS 的特点
结合 CAS 和 volatile 可以实现无锁并发,适用于线程数少、多核 CPU 的场景下。
CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
CAS 体现的是无锁并发、无阻塞并发,请仔细体会这两句话的意思:因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一;但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响 ,所以CAS适合线程数不远高于CPU数的情况。
CAS虽然很高效的解决原子操作,但是CAS仍然存在三大问题:ABA问题,循环时间长开销大和只能保证一个共享变量的原子操作
1. ABA问题。因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。
从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
2. 循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。CAS自旋volatile变量
3. 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
原子整数
用于对整数类进行无锁的线程安全的修改操作
AtomicBoolean
AtomicInteger
AtomicLong
这些方法都是线程安全的,因为内部都使用了CAS方法,这些方法内部模板如下
先用get获取原来的值,next是计算后的值,if,while循环尝试修改,成功就break循环。
原子引用
用于对引用类型进行无锁的线程安全的修改操作
AtomicReference
AtomicMarkableReference
AtomicStampedReference
ABA问题
大部分原子类都会遇到一个问题,CAS方法只会判断第一个参数的值是不是和该线程用get()方法获取时候的值一样,但并不能确定该共享变量是不是被其他线程多次修改。
解决:
AtomicStampedReference类,该类初始化时除了共享变量额外还有一个stamp参数,代表版本号。该类的CAS方法除了prev和next,还有期望得到的版本号和成功修改后的版本号。如果其他线程成功进行CAS操作,stamp参数会改变,在当前线程进行CAS操作时即使共享变量值是期望的,如果版本号不同于自己之前获得的也无法成功执行CAS。
AtomicMarkableReference类,有时候并不关心引用变量更改了几次,只是单纯的关心是否更改过,可以用AtomicMarkableReference类。该类初始化时除了共享变量额外还有一个mark布尔参数,代表是否被修改过。该类的CAS方法除了prev和next,还有期望得到的mark值和成功修改后的mark值。如果其他线程成功进行CAS操作,第一次操作将mark改变,后边再修改可以把两个mark参数设置为一样的;在当前线程进行CAS操作时即使共享变量值是期望的,如果mark不同于自己之前获得的也无法成功执行CAS。
原子数组
数组是引用类型,但是AtomicReference不能对数组的元素进行线程安全的更改,原子数组类可以用自带的方法对数组元素进行原子修改。
AtomicIntegerArray
AtomicLongArray
AtomicReferenceArray
原子字段更新器
原子累加器longAdder原理
Unsafe
Unsafe 对象提供了非常底层的,操作内存、线程的方法。Unsafe 对象只能通过反射获取
Unsafe 最主要的一个作用:使用Unsafe获取并原子更新一个类的属性
偏移地址相当于属性在对象中的位置地址
原子类中的许多修改操作的方法内部都是封装了Unsafe的方法。比如AtomicInteger类里就有unsafe和value属性,它的compareAndSet()方法内部是unsafe的compareAndSwapInt()方法,unsafe先获得value属性的偏移量,然后就可以用compareAndSwapInt()方法操作value属性的值;它的getAndIncrement()方法内部是unsafe的getAndAddInt()方法,同样只要获得value属性的偏移量,就能代入进去操作value属性的值。
多线程之所以会导致并发问题,是因为:
1. 多核CPU,以及多核的cpu高速缓存,高速缓存是并发问题根本原因。
2. 栈是线程私有的,而线程必须单独由一个cpu执行,也可以理解为cpu为当前栈私有的,所以这里会让人混淆了栈和工作内存。但事实上是两个不同的东西。
3. 堆的对象,会在栈的localvariable保存一个指向对象的指针。但是cpu高速缓存就是把这个对象直接复制一个备份到高速缓存中,所以一个对象两个备份,就会存在一个线程(cpu)修改后其他线程(cpu)不知道的问题。volatile就是解决这个问题的,强制线程(cpu)从主内存获取最新的值。
试想一下,如果cpu高速缓存也是保存指向堆的指针,那么是不会有并发问题的,因为都是操作同一个对象。
保证原子性的方法:
SYNCHRONIZED关键字
不能中断锁,适合竞争不激烈的情况,竞争激烈后会造成很多线程阻塞,代码简单,可读性好
LOCK锁
可以中断锁,可以选择性的唤醒锁实现多样化同步,竞争激烈时可以维持常态
ATOMIC原子类
竞争激烈时性能比Lock锁好,但是只能同步单一的值
可见性
普通变量与volatile变量的区别是, volatile的特殊规则保证了新值能立即同步到主内存, 以及每次使用前立即从主内存刷新。
可见性问题发生在共享变量在线程间一个写,一个/多个读的情况;原子性问题发生在共享变量在线程间的多个写的情况,后者范围更大,因为写操作要先读。
解决:
volatile(易变关键字)
它可以用来修饰成员变量和静态成员变量
synchronized
①、进入同步快前,先清空工作内存中的共享变量,从主内存中重新加载
②、解锁前必须把修改的共享变量同步回到主内存中
final
被final修饰的字段在构造器中一旦被初始化完成, 并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情, 其他线程有可能通过这个引用访问到“初始化了一半”的对象) ,由于final属性不能被重新赋值, 那么在其他线程中就一直能看见final字段的值。
可见性和原子性
可见性问题发生在共享变量在线程间一个写,一个/多个读的情况;原子性问题发生在共享变量在线程间的多个写的情况,后者范围更大,因为写操作要先读。
可见性只能保证一个线程在获取到变量的时候是其他线程上一瞬间修改过的最新值,但是原子性是指线程间的字节码指令交错。线程一的获取变量的字节码指令获得到最新变量是可见性,往下对变量的修改,线程二的加入属于指令交错。可见性不能解决指令交错。
实事求是地说, 使用字节码来分析并发问题仍然是不严谨的, 因为即使编译出来只有一条字节码指令, 也并不意味执行这条指令就是一个原子操作。一条字节码指令在编译或解释的过程中要用到好几道指令。
有序性(我们说的有序性一般指jvm编译器的重排序)
有序性问题发生在单线程程序结果不能改变的情况下对不存在数据依赖关系的操作做重排序,结果导致多线程时共享变量在一个线程中由于重排序导致在另一个线程中不能正确读取。
重排序:
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。重排序分3种类型
1、编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2、指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3、内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
不管怎么重排序,单线程程序的执行结果不能被改变。编译器、运行时和处理器都必须遵守“as-if-serial”语义。
as-if-serial:
不管怎么重排序,(单线程)程序的执行结果不能被改变。 为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们。
但是对于没有正确同步的多线程,这种符合asifserial的重排序还是会影响到线程的安全(因为没有符合hb规则)
解决
将会被指令重排的变量用volatile修饰,可以禁用指令重排。
synchronized加锁
为什么synchronized无法禁止指令重排,却能保证有序性??因为在一个线程内部,他不管怎么指令重排,他都是as if serial的,也就是说单线程即使重排序之后的运行结果和串行运行的结果是一样的,是类似串行的语义。
Hotspot JVM Volatile的原理
内存屏障是概念,有LoadLoad、LoadStore、StoreStore、StoreLoad四种屏障,分别具有不同语义。但是在x86架构下的Hotspot虚拟机中,不会对其他三种重排序,只有StoreLoad且通过lock前缀指令实现。在volatile写操作后插入了一个lock前缀的指令,它的作用是1、将本处理器的缓存写入了内存;2、应用缓存一致性协议,该写入动作也会引起别的处理器或者别的内核无效化其缓存。起到了内存屏障的功能。
可见性原理
volatile 的底层实现原理就是lock前缀指令,指令前就将缓存中所有变量同步到主存中去(将use和load动作相关联,即use前必须是load,load后必须是use;将assign和store相关联,即store前必须是assign,assign后必须是store)。
有序性原理
和可见性原理一样,有序性原理也是因为在volatile写操作后插入了一个lock前缀的指令,指令前后代码不会重排序。
当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的一个操作不会被编译器重排序到volatile读之前。
当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的一个操作不会被编译器重排序到volatile写之后。
当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
不能解决指令交错:
写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
而有序性的保证也只是保证了本线程内相关代码不被重排序,而指令交错是由CPU时间片调度决定的
volatile和synchronized
synchronized既可以保证代码块的原子性,也同时保证代码块内变量的可见性,也可以保证有序性。
volatile只能保证可见性和有序性。
但用于可见性和有序性时经常使用volatile
①、主要原因:volatile比synchronized简单
②、volatile比synchronized性能要好,因为volatile没有加锁
③、volatile和synchronized同时使用的时候,可以适当的提高效率,比如懒汉式的单例模式(volatile的使用场景分析)
线程池相关
线程池体现了享元模式的概念
自定义线程池和阻塞队列
类似生产者消费者模型,阻塞队列里是 待运行的任务,ThreadPool是线程池,里面的线程执行完任务后从阻塞队列里取任务对象,在main方法里可以往队列里加任务对象。
JDK中提供的线程池ThreadPoolExecutor和ScheduledExecutorService
ThreadPoolExecutor的构造方法
corePoolSize 核心线程数目 (最多保留的线程数)
maximumPoolSize 最大线程数目
keepAliveTime 生存时间 - 针对救急线程
unit 时间单位 - 针对救急线程
workQueue 阻塞队列
threadFactory 线程工厂 - 可以为线程创建时起个好名字
handler 拒绝策略
ThreadPoolExecutor的工作流程
线程池中刚开始没有线程,当一个任务提交给线程池后,线程池会创建一个新线程来执行任务。
当线程数达到 corePoolSize 并没有线程空闲,这时再加入任务,新加的任务会被加入workQueue 队列排
队,直到有空闲的线程。
如果队列选择了有界队列,那么任务超过了队列大小时,会创建 maximumPoolSize - corePoolSize 数目的线
程来救急。
如果线程到达 maximumPoolSize 仍然有新任务这时会执行拒绝策略。拒绝策略 jdk 提供了 4 种实现,其它
著名框架也提供了实现
AbortPolicy 让调用者抛出 RejectedExecutionException 异常,这是默认策略
CallerRunsPolicy 让调用者运行任务
DiscardPolicy 放弃本次任务
DiscardOldestPolicy 放弃队列中最早的任务,本任务取而代之
当高峰过去后,超过corePoolSize 的救急线程如果一段时间没有任务做,需要自行关闭节省资源,这个时间由构造方法里的
keepAliveTime 和 unit 参数来控制
Executors
工具类、线程池的工厂类,用于创建并返回不同类型的线程池,内部调用了ThreadPoolExecutor和ScheduledExecutorService的构造方法。
Executors.newFixedThreadPool(n); 创建一个可重用的固定线程数的线程池。特点是最大线程数就是核心线程数,没有救急线程,所以等待时间为0;阻塞队列LinkedBlockingQueue是无界的,可以放任意数量的任务。适用于任务量已知,相对耗时的任务
Executors.newCachedThreadPool():创建一个可根据需要创建新线程的线程池。特点是核心线程数是 0, 最大线程数是 Integer.MAX_VALUE,救急线程的空闲生存时间是 60s,意味着全部都是救急线程(60s 后可以回收), 救急线程可以无限创建;阻塞队列采用了 SynchronousQueue ,它没有容量,是无缓冲等待队列,是一个不存储元素的阻塞队列,会直接将任务交给消费者,向队列中添加元素时必须要等到有消费者消费元素后才会把元素放入队列并立刻被消费者取走,否则不会放进队列。适合任务数比较密集,但每个任务执行时间较短的情况。
Executors.newSingleThreadExecutor() :创建一个只有一个线程的线程池。特点是多个任务排队执行,核心线程数和最大线程数固定为 1,任务数多于 1 时,会放入LinkedBlockingQueue无界队列排队。任务执行完毕,这唯一的线程也不会被释放。
单线程线程池和不用线程池自己new的区别:
自己创建一个单线程串行执行任务,如果任务出现异常而终止那么下面的任务代码不会再继续执行,但线程池会新建一个线程,保证阻塞队列后面的任务正常工作
单线程线程池和newFixedThreadPool(1)的区别:
单线程线程池的线程数量固定为1,无法被修改。因为单线程线程池实际返回的是FinalizableDelegatedExecutorService而不是ThreadPoolExecutor,只是把ThreadPoolExecutor当做参数,所以只能把它当做返回类型ExecutorService;而newFixedThreadPool实际返回的是ThreadPoolExecutor,不仅可把它当做返回类型ExecutorService,还可以将它强转成ThreadPoolExecutor从而调用方法修改核心线程等。
Executors.newScheduledThreadPool(n):创建一个任务调度线程池线程池,用法和上面的一样,但是它用于在给定延迟后运行命令或者定期地执行。特点是返回ScheduledExecutorService,内部调用的还是ThreadPoolExecutor的构造方法,采用DelayedworkQueue无界延迟队列,最大线程数无限但是用不上。
线程池创建的个数
过小会导致程序不能充分地利用系统资源、容易导致饥饿,过大会导致更多的线程上下文切换,占用更多内存
cpu密集型
cpu 核数 + 1
IO密集型
线程数 = 核数 * 期望 CPU 利用率 * 总时间(CPU计算时间+等待时间) / CPU 计算时间
步骤:
1、创建指定数量的线程池,使用Executors工具类。
ExecutorService executorService = Executors.newFixedThreadPool(int);
ScheduledExecutorService sES = Executors.newScheduledThreadPool(int);
▲Executors:工具类、线程池的工厂类,用于创建并返回不同类型的线程池
Executors.newFixedThreadPool(n); 创建一个可重用的固定线程数的线程池
Executors.newCachedThreadPool():创建一个可根据需要创建新线程的线程池
Executors.newSingleThreadExecutor() :创建一个只有一个线程的线程池
Executors.newScheduledThreadPool(n):创建一个任务调度线程池线程池,它可安排在给定延迟后运行命令或者定期地执行。
2、创建Runnable接口或Callable接口的实现类,重写run()或call()方法。
3、多次调用execute/submit/schedule这种方法实现线程池执行排队任务的功能
executorService.execute(Runnable 接口实现类对象);该方法没有返回值
executorService.submit(Runnable 接口实现类对象);该方法有返回值,返回值类型为Future
executorService.submit(Callable 接口实现类对象);该方法有返回值,返回值类型为Future(其实就是FutureTask,因为Future只有这一个实现类),作用是使用返回对象的get()方法得到call方法的返回值。
executorService.invokeAll(Callable 接口实现类对象的集合);该方法有返回值,返回类型为元素是Future的List集合
executorService.invokeAny(Callable 接口实现类对象的集合);该方法有返回值,返回类型为最先执行完的任务的返回值,其他任务取消
sES.schedule(Runnable 接口实现类对象,1, TimeUnit.SECONDS):1秒后执行一次性任务。
sES.scheduleAtFixedRate(Runnable 接口实现类对象,2,3, TimeUnit.SECONDS):2秒后开始执行定时任务,不管是否执行完,每3秒执行下一次。
sES.scheduleWithFixedDelay(Runnable 接口实现类对象,2,3, TimeUnit.SECONDS):2秒后开始执行定时任务,任务执行完间隔3秒执行下一次。
4、关闭线程池(这个不要忘!!!!)。
executorService.shutdown():等待正在执行的任务和工作队列里的任务先完成,然后再关闭。
executorService.shutdownNow():会使用interrupt中断正在执行的任务并返回等待队列中的任务,关闭线程池。
tomcat中的线程池
ForkJoinPool线程池
它体现的是一种分治思想,适用于能够进行任务拆分的 cpu 密集型运算,可以把每个任务的分解和合并交给不同的线程来完成,进一步提升了运算效率,Fork/Join 默认会创建与 cpu 核心数大小相同的线程池提交给 Fork/Join 线程池的task任务需要继承 RecursiveTask(有返回值)或 RecursiveAction(没有返回值)
创建
ForkJoinPool pool = new ForkJoinPool (int)
pool.invoke(task)
使用线程池的好处:
1、提高响应速度(减少了创建新线程的时间)
2、降低资源消耗(重复利用线程池中线程,不需要每次都创建)
3、便于线程管理
JUC ReentrantLock原理
AQS
AbstractQueuedSynchronizer,是一个抽象类,称之为同步器,是lock式锁加锁和解锁真正用到的框架。
特点:
用 state 属性来表示是否上锁,子类需要定义如何维护这个状态,控制如何获取锁和释放锁
提供了基于 FIFO 的阻塞队列,是双向链表结构,类似于 Monitor 的 EntryList
提供了Condition来实现等待、唤醒机制。内部维持了一个等待队列,是双向链表结构,支持多个条件变量,类似于 Monitor 的 WaitSet
exclusiveOwnerThread
表示当前获取到锁的线程
state状态
state属性表示线程的上锁状态,在Reentrantlock中0表示没有上锁,大于0表示重入了几次锁。在ReentrantReadWriteLock中state分为读状态和写状态。
Node节点的模式
AQS中有一个Node类,分为Exclusive独占模式和Shared共享模式,如果加的是Reentrantlock锁或写锁,对线程采用Exclusive独占模式;如果加的是读锁,对线程采用Shared共享模式
Node节点的waitStatus状态
在阻塞队列/同步队列中除了tail指向的Node的waitStatus为0,其他都为-1,表明它的继任节点需要被唤醒。
在等待队列/条件队列中的Node的waitStatus都为-2,表明正处于等待队列
子类Sync主要实现这样一些方法
tryAcquire
tryRelease
tryAcquireShared
tryReleaseShared
isHeldExclusively
AQS 要实现的功能目标
阻塞版本获取锁 acquire 和非阻塞的版本尝试获取锁 tryAcquire
获取锁超时机制
通过打断取消机制
独占机制及共享机制
条件不满足时的等待机制
Reentrantlock原理
绿色实现,蓝色继承,红色依赖,Reentrantlock中有三个内部类,Sync,NonfairSync,FairSync
NonfairSync非公平同步器原理
刚开始加锁成功
后面再尝试加锁失败
上面CAS失败后进入acquire()方法
执行tryAcquire方法
进入nonfairTryAcquire方法,state是1,当没有锁重入时,返回false
进入addWaiter方法,
如果尾节点不为null,把当前线程封装成Node.EXCLUSIVE节点,把当前线程关联的Node加入阻塞队列尾部。
如果尾节点为null,构造哨兵节点,把当前线程关联的Node加入哨兵节点后面。要注意这里的head/tail只是AQS的一个属性,真正的头结点/尾节点是后面连接的Node(head连接的哨兵节点就是头结点)
进入acquireQueued方法
1. acquireQueued 会在一个死循环中不断尝试获得锁,失败后进入 park 阻塞
2. 如果自己是紧邻着 head(排第二位),那么再次 tryAcquire 尝试获取锁,当然这时 state 仍为 1,失败
3. 进入 shouldParkAfterFailedAcquire 逻辑,将前驱 node,即 head 的 waitStatus 改为 -1,这次返回 false
4. shouldParkAfterFailedAcquire 执行完毕回到 acquireQueued ,再次 tryAcquire 尝试获取锁,当然这时state 仍为 1,失败
5. 当再次进入 shouldParkAfterFailedAcquire 时,这时因为其前驱 node 的 waitStatus 已经是 -1,这次返回true
6. 进入 parkAndCheckInterrupt, Thread-1 park(灰色表示)
解锁,Thread-0 释放锁,进入 tryRelease 流程,如果成功 设置 exclusiveOwnerThread 为 null,state = 0
当前队列不为 null,并且 head 的 waitStatus = -1,进入 unparkSuccessor 流程
找到队列中离 head 最近的一个 Node(没取消的),unpark 恢复其运行,本例中即为 Thread-1
回到 Thread-1 的 acquireQueued 流程,从park代码处继续运行,进入tryAquire
如果tryAquire为真加锁成功(没有竞争),会设置
exclusiveOwnerThread 为 Thread-1,state = 1
head 指向刚刚 Thread-1 所在的 Node,该 Node 清空 Thread
原本的 head 因为从链表断开,而可被垃圾回收
如果有竞争失败
Thread-4 被设置为 exclusiveOwnerThread,state = 1
Thread-1 再次进入 acquireQueued 流程,获取锁失败,重新进入 park 阻塞
进入unparkSuccessor方法
Reentrantlock的可重入原理
发生在第一次加锁失败后,进入nonfairTryAcquire方法时,会检测当前线程是不是已经获得了锁的线程,如果是的话,会默认对state加1,即重入一次加一次1,返回true
Reentrantlock的可中断原理
不可打断的原因
当试图打断不可打断锁时,在aquireQueued方法中标记位会返回true,然后进入for循环。如果没有解锁tryAquire获取不到锁时,还会进入第二个if块被park;如果解锁了tryAquire获取锁成功,才会进入上级方法aquire方法,执行selfInterrupt进行中断。
可打断的原因,调用LockInterruptibly()方法会调用同步器类的acquireInterruptibly()方法,该方法会在没有获取到锁时调用doAcquireInterruptibly方法,该方法和前面的aquireQueued方法很像,只是在park阻塞那里如果被打断阻塞被唤醒后,会抛出异常,不会再进入for循环,真正地打断线程。
Reentrantlock的公平锁原理
锁的公平与否体现在解锁的时候,假如新来了一个线程,这个线程和阻塞队列中的线程获得锁的优先级是随机的还是队列先获得。而阻塞队列和等待队列内还是FIFO的。
唯一不同的位置为判断条件多了
hasQueuedPredecessors()方法,即加入了同步队列中当前节点是否有前驱节点的判断,如果该
方法返回true,则表示有线程比当前线程更早地请求获取锁,因此需要等待前驱线程获取并释
放锁之后才能继续获取锁
Reentrantlock的条件变量原理
每个条件变量其实就对应着一个等待队列/条件队列,其实现类是ConditionObject,即类中持有这个队列
await
开始 Thread-0 持有锁,调用 await,进入 ConditionObject 的 addConditionWaiter 方法,创建新的 Node, 状态为 -2(Node.CONDITION),关联当前线程,加入等待队列尾部
接下来进入 AQS 的 fullyRelease 流程,释放同步器上的锁并unpark AQS 队列中的下一个节点,竞争锁,假设没有其他竞争线程,那么 Thread-1 竞争成功 ,加入竞争不成功,则它还在阻塞队列中。(为什么不用release方法?因为如果这时锁重入的话release方法一次默认只能减1,而fullyRelease方法能把加锁的数量全部减掉以完全释放锁)
然后在await方法内park当前线程
signal
假设 Thread-1 要来唤醒 Thread-0,先检查唤醒线程是否持有锁,然后进入 ConditionObject 的 doSignal 方法,进入循环,取得等待队列中第一个 Node,即 Thread-0 所在 Node,将其置为null,通过transferForSignal方法更改其waitstatus为0并通过enq方法将Thread-0放在阻塞队列尾部。判断是否转移到阻塞队列成功,成功则退出循环,不成功取等待队列中的下一个Node置为null并转移,如此循环。
JUC 读写锁原理
读写锁
当前线程获取读锁后,自身线程可以获取读锁不能获取写锁,其他线程可以获取读锁不能获取写锁,尝试获取锁的线程会进入阻塞队列,直到读锁释放。
当前线程获取写锁后,自身线程可以获取读锁和写锁,其他线程不可以获取读锁和写锁。尝试获取锁的线程会阻塞,直到写锁释放。
锁降级
state读写状态和写状态和读状态
读写锁的state高16位存放读状态,即每获取一次读锁,高16位加1,即加的是0x00010000,低16位写状态,即每获取一次写锁,低16位加1。获取写状态的方式为:state&0x0000FFFF(将高16位全部抹去) ;获取读状态的方式为:state>>>16(无符号补0右移16位)
AQS中Node节点的状态
分为Exclusive独占模式和Shared共享模式,如果加的是Reentrantlock锁或写锁,对线程采用Exclusive独占模式;如果加的是读锁,对线程采用Shared共享模式
ReentrantReadWriteLock原理
读写锁用的是同一个 Sycn 同步器,因此阻塞队列、state 等也是同一个。
t1写锁上锁
写锁的lock方法同样还是用到了同步器的acquire方法,这里的参数默认为1
进入acquire方法,if内先尝试获取写锁
tryAquire方法,c是获得读写状态,为0表示高16位低16位都是0即没有获得一次读写锁,w获得写状态即低16位(
c&0x0000FFFF,将高16位全部抹去。
第一次加锁c应该为0,即进入第二个if,writerShouldBlock方法时判断是否是公平锁的关键,非公平锁返回false,公平锁返回true,这里返回false。然后尝试更改读写锁状态(对写锁更改,直接加1),更改成功或失败。
非第一次加锁,c不为0,即进入第一个if,w==0即写锁状态没有变但读写锁状态变了即读锁加过锁没解锁,这时写锁就不能上锁,返回false加锁失败;或者已经加过锁但加锁线程不是当前线程,这时加写锁也失败返回false。
然后是判断写状态超过低16位最大值,报错,一般不会出现。
然后就是修改写状态加1,返回true,加写锁成功。
tryAquire方法成功后,在aquire方法内就不用采取操作;失败返回false后,进入aquiredQueued方法,和Reentrantlock相同,构建Node阻塞队列,把该线程放入阻塞队列,成功则也不用在aquire方法内采取操作,失败则selfInterrupt中断。
t1上写锁后t2读锁上锁
读锁的lock方法用到了同步器的acquireShared方法,这里的参数默认为1
进入acquireShared方法,if内先尝试获取读锁,若失败则执行doAquireShared方法
进入tryAquireShared方法,返回-1表示失败,返回1表示成功。c为读写状态,r为读状态即高16位单独拿出来。
第一个if表示写状态不为0之前加过写锁且加写锁的线程不是当前线程,即其他线程持有着写锁,加读锁失败返回false(因为自身线程内可以先写再读锁降级,跨线程间不能先写再读)
第二个if就表示可以加读锁,将读状态加1(加的其实不是1,而是相对于高16位的1,0x00010000),加读锁成功返回true
如果tryAquireShared方法失败则进入doAquireShared方法,关联Node对象,创建Node队列。
如果该线程Node的前一个节点是head节点,再尝试获取读锁,
如果成功(一般出现在被唤醒的情况下)则进入setHeadAndPropagate方法,把它(t2)所在的节点置为头结点,移除原来的头结点,然后依次检查后继节点是不是SHARED模式,如果是则依次将SHARED模式的节点置为头结点,将SHARED节点的线程释放为运行状态,直到后继节点是EXCLUSIVE节点;
如果失败则进入下个if
如果该线程Node的前一个节点不是head节点或者第一个if尝试获取读锁失败,进入第二个if,shouldParkAfterFailedAcquire是否在读锁失败时阻塞为false,然后重新进入for循环,如此重复三次,第四次返回true,park住当前线程。
t3上读锁,t4上写锁
t1解写锁
调用同步器的release方法
进入release方法,调用tryRelease方法,如果尝试释放写锁成功,则判断头结点不等于空且waitstatus不为0,成功则调用unparkSuccessor方法唤醒头结点的后继节点t2,t1线程从owner移除,t1线程终结。此时头结点的后继节点t2即从park的位置唤醒进入for循环,重新执行tryAquireShared方法尝试获取读锁。
进入tryRelease方法尝试解锁,将写状态减1,看是否为0,如果为0则表明没有写锁重入,将当前线程设为null解锁成功返回true,如果不为0则表明还有重入,将修改后的写状态设为当前写状态,返回false
t2,t3读锁解锁
进入releaseShared,执行tryReleaseShared,成功(完全解开读锁)则执行doReleaseShared,失败(没有完全解开)则返回false虽然返回值用不上但是已经解开了一个锁。
进入tryReleaseShared,将读状态减1,完全解开返回true,没有完全解开返回false
当tryReleaseShared完全解开读锁后,进入doReleaseShared方法,如果此时头结点waitstatus为-1则更改其为0并唤醒后继EXCLUSIVE节点。后继 EXCLUSIVE节点从acquireQueued 中 parkAndCheckInterrupt 处恢复运行,再次 for (;;) 这次自己是老二,执行tryAcquire(),ExclusiveOwnerThread改为t4,修改头结点,流程结束