Java多线程
什么是多线程
多线程就是把操作系统中的这种并发执行机制原理运用在一个程序中,把一个程序划分为若干个子任务,多个子任务并发执行,每一个任务就是一个线程。
多线程有什么好处
采用多线程技术的应用程序可以更好地利用系统资源。主要优势在于充分利用了CPU的空闲时间片,用尽可能少的时间来对用户的要求做出响应,使得进程的整体运行效率得到较大提高,同时增强了应用程序的灵活性。由于同一进程的所有线程是共享同一内存,所以不需要特殊的数据传送机制,不需要建立共享存储区或共享文件,从而使得不同任务之间的协调操作与运行、数据的交互、资源的分配等问题更加易于解决。
什么是并发和并行
并发是指一个处理器同时处理多个任务。
并行是指多个处理器或者是多核的处理器同时处理多个不同的任务。
并发编程中的三个概念
原子性
即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
上面一句话虽然看起来简单,但是理解起来并不是那么容易。看下面一个例子i:
请分析以下哪些操作是原子性操作:
x = 10; //语句1 y = x; //语句2 x++; //语句3 x = x + 1; //语句4
咋一看,有些朋友可能会说上面的4个语句中的操作都是原子性操作。其实只有语句1是原子性操作,其他三个语句都不是原子性操作。
语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。
语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。
所以上面4个语句只有语句1的操作具备原子性。
也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,**如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。**由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
对于可见性,Java提供了volatile关键字来保证可见性。
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
有序性
即程序执行的顺序按照代码的先后顺序执行。
什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
在Java里面,可以通过volatile关键字来保证一定的“有序性”(具体原理在下一节讲述)。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
下面就来具体介绍下happens-before原则(先行发生原则):
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
线程状态
JDK中线程定义了6种状态,分别是NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED六中状态。
1、NEW:当线程被创建但是还未启动时处于这个状态。
2、RUNNABLE:当线程在虚拟机中运行时,或者等待系统调度时处于此状态。
3、BLOCKED:当前线程处于等待监视器锁的状态。等待进入同步代码块或者方法。
4、WAITING:当调用Object.wait()/join()/LockSupport.park()方法后线程处于等待状态,此状态需要等待其他线程执行操作才能唤醒。
5、TIMED_WAITING:当调用Object.wait(long)/join(long)/LockSupport.parkNanos()方法后线程处于超时等待状态。此状态当时间达到时则自动唤醒。
6、TERMINATED:当线程完成时处于此状态。
线程的状态转换图如下图所示。
常用的线程方法
1、currentThread()
这是一个Thread类的静态方法,返回的是此条语句运行在的当前线程对象。当这条语句在自定义线程类的构造函数中时,返回的线程是创建该线程类所在的线程对象。
2、isAlive()
判断调用线程是否是活动状态,该线程调用start()后到线程死亡之间该方法返回的都是true。
3、sleep()
此方法为Thread类的静态方法,是让当前所在线程(该语句运行所在的线程)等待设定的时间。线程进入阻塞状态,其不会释放锁。
4、getId()
此方法获得线程的唯一标识。
5、interrupt()
此方法并不能将线程中断,只是将线程中断标志设置为true,则线程内部可以根据这个标志位来处理线程。当线程处于WAITING/TIMED_WAITING状态时,调用此方法会抛出InterruptedException异常并将中断标志恢复为false。
6、中断标志位判断
**interrupted()**方法为Thread的静态方法,返回运行该语句所在的线程的中断标志。当中断标志位true时,此方法会将该标志恢复为false,则下次再调用则返回false。
**isInterrupted()**方法为对象普通方法,判断调用线程的中断标志位。其不会修改中断标志。
7、yield()
此方法会放弃系统资源,由运行状态回到就绪状态,等待系统调度。其不会释放锁。
8、setPriority()与getPriority()
此方法设置和获得线程的优先级,优先级越高,则获得系统调度的机会越大。线程的优先级具有继承性,也就是创建的线程会和创建时所在线程的优先级相同。
9、LockSupport.park()
此方法会使得当前线程进入WAITING状态,但是如果当前线程的中断标志位位true时,则此方法无效。此类内部有一个标志位若该标志位大于0,则表明之前调用过unpark()方法,则此方法将标志位改为0则退出,并不使线程进入WAITING状态。
注意:当调用interrupt()方法是同样可以唤醒线程。
10、LockSupport.unpark(Thread t)
此方法会使得调用了park()的线程t 唤醒。此类内部有一个标志位,若为1时则表示调用过unpark()方法,则unpark()不做任何修改。若为0且线程在WAITING状态则唤醒线程t,并将标志位改为1。无论调用多少次unpark()方法,标志位最大为1。
11、sleep()
sleep:是Java中Thread类中的方法,会使当前线程暂停执行让出cpu的使用权限。但是监控状态依然存在,即如果当前线程进入了同步锁的话,sleep方法并不会释放锁,即使当前线程让出了cpu的使用权限,但其它被同步锁挡在外面的线程也无法获得执行。待到sleep方法中指定的时间后,sleep方法将会继续获得cpu的使用权限而后继续执行之前sleep的线程。
12、wait()
是Object类的方法,wait方法指的是一个已经进入同步锁的线程内,让自己暂时让出同步锁,以便其它正在等待此同步锁的线程能够获得机会执行。,只有其它方法调用了notify或者notifyAll(需要注意的是调用notify或者notifyAll方法的线程本身并不释放锁,只是告诉调用wait方法的其它 线程可以参与锁的竞争了…)方法后,才能够唤醒相关的线程。此外注意wait方法必须在同步关键字修饰的方法中才能调用。
调用wait()
、notify()
方法时,当前线程必须要成功获得锁(必须写在同步代码块锁中),否则将抛出异常。
13、notify和notifyAll
释放因为调用wait方法而正在等待中的线程。notify和notifyAll的唯一区别在于notify唤醒某个正在等待的线程。而notifyAll会唤醒所有正在等等待的线程。需要注意的是notify和notifyAll并不会释放对应的同步锁哦。
14、join()
Thread类中的join方法的主要作用就是同步,它可以使得线程之间的并行执行变为串行执行。具体看代码:
public class JoinTest {
public static void main(String [] args) throws InterruptedException {
ThreadJoinTest t1 = new ThreadJoinTest("小明");
ThreadJoinTest t2 = new ThreadJoinTest("小东");
t1.start();
/**join的意思是使得放弃当前线程的执行,并返回对应的线程,例如下面代码的意思就是:
程序在main线程中调用t1线程的join方法,则main线程放弃cpu控制权,并返回t1线程继续执行直到线程t1执行完毕
所以结果是t1线程执行完后,才到主线程执行,相当于在main线程中同步t1线程,t1执行完了,main线程才有执行的机会
*/
t1.join();
t2.start();
}
}
class ThreadJoinTest extends Thread{
public ThreadJoinTest(String name){
super(name);
}
@Override
public void run(){
for(int i=0;i<1000;i++){
System.out.println(this.getName() + ":" + i);
}
}
}
sleep和wait的区别
- 来自不同的类:sleep是Thread的静态类方法 , wait是Object的方法
- 有没有释放锁(释放资源):
- sleep不出让系统资源( 不会让出CPU )
- wait是进入线程等待池等待,出让系统资源,其他线程可以占用CPU。
- sleep到时间会自动醒来, wait必须执行notify() 或者notifyAll()来唤醒
- sleep必须捕获异常,因为它不能被抛出;而wait,notify和notifyAll不需要捕获异常。
线程间通讯的方式
1.锁与同步
2.等待/唤醒
3.信号量
4.管道
5.其他
- join()
- sleep()
- ThreadLocal
这里只介绍一下管道
管道是基于“管道流”的通信⽅式。JDK提供了 PipedWriter 、 PipedReader 、PipedOutputStream 、 PipedInputStream 。其中,前⾯两个是基于字符的,后⾯两个是基于字节流的。
package com.example.demo;
import java.io.IOException;
import java.io.PipedReader;
import java.io.PipedWriter;
class Pipe {
public static void main(String[] args) throws Exception {
PipedWriter writer = new PipedWriter();
PipedReader reader = new PipedReader();
writer.connect(reader); // 这⾥注意⼀定要连接,才能通信
new Thread(new ReaderThread(reader)).start();
Thread.sleep(1000);
new Thread(new WriterThread(writer)).start();
}
static class ReaderThread implements Runnable {
private final PipedReader reader;
public ReaderThread(PipedReader reader) {
this.reader = reader;
}
@Override
public void run() {
System.out.println("this is reader");
int receive = 0;
try {
while ((receive = reader.read()) != -1) {
System.out.print((char) receive);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
static class WriterThread implements Runnable {
private final PipedWriter writer;
public WriterThread(PipedWriter writer) {
this.writer = writer;
}
@Override
public void run() {
System.out.println("this is writer");
int receive = 0;
try {
writer.write("test");
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
writer.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
// 输出:
// this is reader
// this is writer
// test
我们通过线程的构造函数,传⼊了 PipedWrite 和 PipedReader 对象。可以简单分析
⼀下这个示例代码的执⾏流程:
-
线程ReaderThread开始执⾏,
-
线程ReaderThread使⽤管道reader.read()进⼊”阻塞“,
-
线程WriterThread开始执⾏,
-
线程WriterThread⽤writer.write(“test”)往管道写⼊字符串,
-
线程WriterThread使⽤writer.close()结束管道写⼊,并执⾏完毕,
-
线程ReaderThread接受到管道输出的字符串并打印,
-
线程ReaderThread执⾏完毕。
Java创建线程的四种方式
- 继承Thread类
- 实现Runnable接口
- 通过Callable和Future创建线程
- 通过线程池启动多线程
继承Thread类实现多线程
-
run()为线程类的核心方法,相当于主线程的main方法,是每个线程的入口
-
一个线程调用 两次start()方法将会抛出线程状态异常,也就是的start()只可以被调用一次
-
native生明的方法只有方法名,没有方法体。是本地方法,不是抽象方法,而是调用c语言方法
registerNative()方法包含了所有与线程相关的操作系统方法
-
run()方法是由jvm创建完本地操作系统级线程后回调的方法,不可以手动调用(否则就是普通方法)
public class MyThread extends Thread {
public MyThread() {
}
public void run() {
for(int i=0;i<10;i++) {
System.out.println(Thread.currentThread()+":"+i);
}
}
public static void main(String[] args) {
MyThread mThread1=new MyThread();
MyThread mThread2=new MyThread();
MyThread myThread3=new MyThread();
mThread1.start();
mThread2.start();
myThread3.start();
}
}
覆写Runnable()接口实现多线程,而后同样覆写run().推荐此方式
- 覆写Runnable接口实现多线程可以避免单继承局限
- 当子类实现Runnable接口,此时子类和Thread的代理模式(子类负责真是业务的操作,thread负责资源调度与线程创建辅助真实业务。
public class MyThread implements Runnable{
public static int count=20;
public void run() {
while(count>0) {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"-当前剩余票数:"+count--);
}
}
public static void main(String[] args) {
MyThread Thread1=new MyThread();
Thread mThread1=new Thread(Thread1,"线程1");
Thread mThread2=new Thread(Thread1,"线程2");
Thread mThread3=new Thread(Thread1,"线程3");
mThread1.start();
mThread2.start();
myThread3.start();
}
}
继承Thread和实现Runnable接口的区别
- 实现Runnable接口避免多继承局限
- 实现Runnable()可以更好的体现共享的概念
覆写Callable接口实现多线程(JDK1.5)
- 核心方法叫call()方法,有返回值
- 有返回值
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class MyThread implements Callable<String> {
private int count = 20;
@Override
public String call() throws Exception {
for (int i = count; i > 0; i--) {
// Thread.yield();
System.out.println(Thread.currentThread().getName()+"当前票数:" + i);
}
return "sale out";
}
public static void main(String[] args) throws InterruptedException, ExecutionException {
Callable<String> callable =new MyThread();
FutureTask <String>futureTask=new FutureTask<>(callable);
Thread mThread=new Thread(futureTask);
Thread mThread2=new Thread(futureTask);
Thread mThread3=new Thread(futureTask);
// mThread.setName("hhh");
mThread.start();
mThread2.start();
mThread3.start();
System.out.println(futureTask.get());
}
}
通过线程池启动多线程
通过Executor 的工具类可以创建三种类型的普通线程池:
FixThreadPool(int n); 固定大小的线程池
使用于为了满足资源管理需求而需要限制当前线程数量的场合。使用于负载比较重的服务器。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Test {
public static void main(String[] args) {
ExecutorService ex=Executors.newFixedThreadPool(5);
for(int i=0;i<5;i++) {
ex.submit(new Runnable() {
@Override
public void run() {
for(int j=0;j<10;j++) {
System.out.println(Thread.currentThread().getName()+j);
}
}
});
}
ex.shutdown();
}
}
SingleThreadPoolExecutor :单线程池
需要保证顺序执行各个任务的场景
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Test {
public static void main(String[] args) {
ExecutorService ex=Executors.newSingleThreadExecutor();
for(int i=0;i<5;i++) {
ex.submit(new Runnable() {
@Override
public void run() {
for(int j=0;j<10;j++) {
System.out.println(Thread.currentThread().getName()+j);
}
}
});
}
ex.shutdown();
}
}
CashedThreadPool(); 缓存线程池
当提交任务速度高于线程池中任务处理速度时,缓存线程池会不断的创建线程
适用于提交短期的异步小程序,以及负载较轻的服务器
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Test {
public static void main(String[] args) {
ExecutorService ex=Executors.newCachedThreadPool();
for(int i=0;i<5;i++) {
ex.submit(new Runnable() {
@Override
public void run() {
for(int j=0;j<10;j++) {
System.out.println(Thread.currentThread().getName()+j);
}
}
});
}
ex.shutdown();
}
}
区别
Thread和Runnable区别:
- Thread中有许多方法可以供给子类使用, 而Runnable中只有Run方法。
Callable和Runnable区别:
- Callable规定的方法是call(),而Runnable规定的方法是run().
- Callable的任务执行后可返回值 (void 和 Object)
- call() 可抛出异常 run() 方法不能 (run是JVM调用的,无法抛)
- 运行Callable任务可拿到一个Future对象, Future表示异步计算的结果。 它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。 通过Future对象可了解任务执行情况,可取消任务的执行,还可获取任务执行的结果。
- Callable是类似于Runnable的接口,实现Callable接口的类和实现Runnable的类都是可被其它线程执行的任务。
注:Callable接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。
线程池
根据阿里巴巴Java开发手册制约
【强制】线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
说明:Executors 返回的线程池对象的弊端如下:
FixedThreadPool 和 SingleThreadPool:
允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。
CachedThreadPool:
允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。
为什么要使用线程池
- 线程创建和销毁的开销非常高:频繁的创建和销毁线程需要消耗时间,会使响应变慢;同时消耗计算资源。
- 资源耗尽:空闲的线程会占用内存,会给垃圾回收带来压力,线程竞争CPU也会产生性能开销,线程池可合理管理空闲线程
- 稳定性:在一定范围内,增加线程可以提升系统的处理能力,如果超过这个范围,继续创建线程只会降低执行速度,甚至导致系统OOM,线程池即可以通过程序控制线程数。
线程池的七个重要参数:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
-
corePoolSize
:核心线程数
- 核心线程会一直存活,及时没有任务需要执行
- 线程数 < corePoolSize时,即使有线程空闲,线程池也会优先创建新线程处理
- 设置allowCoreThreadTimeout=true(默认false)时,核心线程会超时关闭
-
workQueue:
任务队列(阻塞队列) 当核心线程数达到最大时,新任务会放在队列中排队等待执行
ArrayBlockingQueue:是一个有边界的阻塞队列,它的内部实现是一个数组。它的容量在初始化时就确定不变。
LinkedBlockingQueue:阻塞队列大小的配置是可选的,其内部实现是一个链表。
PriorityBlockingQueue:是一个没有边界的队列,所有插入到PriorityBlockingQueue的对象必须实现java.lang.Comparable接口,队列优先级的排序就是按照我们对这个接口的实现来定义的。
SynchronousQueue:队列内部仅允许容纳一个元素。当一个线程插入一个元素后会被阻塞,除非这个元素被另一个线程消费。 -
maxPoolSize
:最大线程数
- 线程数>=corePoolSize,且任务队列已满时。线程池会创建新线程来处理任务
- 线程数=maxPoolSize,且任务队列已满时,线程池会拒绝处理任务而抛出异常
-
keepAliveTime
:线程空闲时间
- 当线程空闲时间达到keepAliveTime时,线程会退出,直到线程数量=corePoolSize
- 如果allowCoreThreadTimeout=true,则会直到线程数量=0
-
unit: keepAliveTime的时间单位
-
handler
:任务拒绝处理器,两种情况会拒绝处理任务:
- 当线程数已经达到maxPoolSize,切队列已满,会拒绝新任务
- 当线程池被调用shutdown()后,会等待线程池里的任务执行完毕,再shutdown。如果在调用shutdown()和线程池真正shutdown之间提交任务,会拒绝新任务
- 线程池会调用rejectedExecutionHandler来处理这个任务。如果没有设置默认是AbortPolicy,会抛出异常
拒绝策略:
- AbortPolicy(读音abort 跑里C) 直接抛出一个RejectedExecutionException,这也是JDK默认的拒绝策略
- CallerRunsPolicy 尝试直接运行被拒绝的任务,如果线程池已经被关闭了,任务就被丢弃了
- DiscardPolicy 该策略会默默丢弃无法处理的任务,不予任何处理。当然使用此策略,业务场景中需允许任务的丢失;
- DiscardOldestPolicy 移除最晚的那个没有被处理的任务,然后执行被拒绝的任务。同样,如果线程池已经被关闭了,任务就被丢弃了
- 自定义:实现RejectedExecutionHandler接口,可自定义处理器
-
threadFactory
线程工厂,主要用来创建线程,比如指定线程的名字。
线程池的执行流程
-
线程池刚创建时,里面没有一个线程。任务队列是作为参数传进来的。不过,就算队列里面有任务,线程池也不会马上执行它们。
-
当调用execute()方法添加一个任务时,线程池会做如下判断:
-
- 如果正在运行的线程数小于corePoolSize,那么马上创建线程运行这个任务。
- 如果正在运行的线程数大于或者等于corePoolSize,那么将这个任务放入队列。
- 如果这个时候队列满了,而且正在运行的线程数量小于maximumPoolSize,那么还是要创建线程运行这个任务。
- 如果队列满了,而且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会执行拒绝策略
-
当一个线程完成任务时,它会从队列中取下一个任务来执行。
-
当一个线程无事可做,超过一定的时间(keepAliveTime)时,线程池会判断,如果当前运行的线程数大于corePoolSize时,那么这个线程会被停用掉,所以线程池的所有任务完成后,它最终会收缩到corePoolSize的大小。
线程池底层详解
BlockingQueue
BlockingQueue的核心方法:
1.放入数据
- offer(anObject):表示如果可能的话,将anObject加到BlockingQueue里,即如果BlockingQueue可以容纳,则返回true,否则返回false.(本方法不阻塞当前执行方法的线程);
- offer(E o, long timeout, TimeUnit unit):可以设定等待的时间,如果在指定的时间内,还不能往队列中加入BlockingQueue,则返回失败。
- put(anObject):把anObject加到BlockingQueue里,如果BlockQueue没有空间,则调用此方法的线程被阻断直到BlockingQueue里面有空间再继续.
2.获取数据
- poll(time):取走BlockingQueue里排在首位的对象,若不能立即取出,则可以等time参数规定的时间,取不到时返回null;
- poll(long timeout, TimeUnit unit):从BlockingQueue取出一个队首的对象,如果在指定时间内,队列一旦有数据可取,则立即返回队列中的数据。否则知道时间超时还没有数据可取,返回失败。
- take():取走BlockingQueue里排在首位的对象,若BlockingQueue为空,阻断进入等待状态直到BlockingQueue有新的数据被加入;
- drainTo():一次性从BlockingQueue获取所有可用的数据对象(还可以指定获取数据的个数),通过该方法,可以提升获取数据效率;不需要多次分批加锁或释放锁。
常见BlockingQueue
- ArrayBlockingQueue
基于数组的阻塞队列实现,在ArrayBlockingQueue内部,维护了一个定长数组,以便缓存队列中的数据对象,这是一个常用的阻塞队列,除了一个定长数组外,ArrayBlockingQueue内部还保存着两个整形变量,分别标识着队列的头部和尾部在数组中的位置。
ArrayBlockingQueue在生产者放入数据和消费者获取数据,都是共用同一个锁对象,由此也意味着两者无法真正并行运行,这点尤其不同于LinkedBlockingQueue;按照实现原理来分析,ArrayBlockingQueue完全可以采用分离锁,从而实现生产者和消费者操作的完全并行运行。Doug Lea之所以没这样去做,也许是因为ArrayBlockingQueue的数据写入和获取操作已经足够轻巧,以至于引入独立的锁机制,除了给代码带来额外的复杂性外,其在性能上完全占不到任何便宜。 ArrayBlockingQueue和LinkedBlockingQueue间还有一个明显的不同之处在于,前者在插入或删除元素时不会产生或销毁任何额外的对象实例,而后者则会生成一个额外的Node对象。这在长时间内需要高效并发地处理大批量数据的系统中,其对于GC的影响还是存在一定的区别。而在创建ArrayBlockingQueue时,我们还可以控制对象的内部锁是否采用公平锁,默认采用非公平锁。
2.LinkedBlockingQueue
基于链表的阻塞队列,同ArrayListBlockingQueue类似,其内部也维持着一个数据缓冲队列(该队列由一个链表构成),当生产者往队列中放入一个数据时,队列会从生产者手中获取数据,并缓存在队列内部,而生产者立即返回;只有当队列缓冲区达到最大值缓存容量时(LinkedBlockingQueue可以通过构造函数指定该值),才会阻塞生产者队列,直到消费者从队列中消费掉一份数据,生产者线程会被唤醒,反之对于消费者这端的处理也基于同样的原理。而LinkedBlockingQueue之所以能够高效的处理并发数据,还因为其对于生产者端和消费者端分别采用了独立的锁来控制数据同步,这也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。
作为开发者,我们需要注意的是,如果构造一个LinkedBlockingQueue对象,而没有指定其容量大小,LinkedBlockingQueue会默认一个类似无限大小的容量(Integer.MAX_VALUE),这样的话,如果生产者的速度一旦大于消费者的速度,也许还没有等到队列满阻塞产生,系统内存就有可能已被消耗殆尽了。
ArrayBlockingQueue和LinkedBlockingQueue是两个最普通也是最常用的阻塞队列,一般情况下,在处理多线程间的生产者消费者问题,使用这两个类足以。
3.PriorityBlockingQueue(读音:破油提)
基于优先级的阻塞队列(优先级的判断通过构造函数传入的Compator对象来决定),但需要注意的是PriorityBlockingQueue并不会阻塞数据生产者,而只会在没有可消费的数据时,阻塞数据的消费者。因此使用的时候要特别注意,生产者生产数据的速度绝对不能快于消费者消费数据的速度,否则时间一长,会最终耗尽所有的可用堆内存空间。在实现PriorityBlockingQueue时,内部控制线程同步的锁采用的是公平锁。
4. SynchronousQueue(读音和synchronized相同)
一种无缓冲的等待队列,类似于无中介的直接交易,有点像原始社会中的生产者和消费者,生产者拿着产品去集市销售给产品的最终消费者,而消费者必须亲自去集市找到所要商品的直接生产者,如果一方没有找到合适的目标,那么对不起,大家都在集市等待。相对于有缓冲的BlockingQueue来说,少了一个中间经销商的环节(缓冲区),如果有经销商,生产者直接把产品批发给经销商,而无需在意经销商最终会将这些产品卖给那些消费者,由于经销商可以库存一部分商品,因此相对于直接交易模式,总体来说采用中间经销商的模式会吞吐量高一些(可以批量买卖);但另一方面,又因为经销商的引入,使得产品从生产者到消费者中间增加了额外的交易环节,单个产品的及时响应性能可能会降低。
声明一个SynchronousQueue有两种不同的方式,它们之间有着不太一样的行为。公平模式和非公平模式的区别:
- 如果采用公平模式:SynchronousQueue会采用公平锁,并配合一个FIFO队列来阻塞多余的生产者和消费者,从而体系整体的公平策略;
- 但如果是非公平模式(SynchronousQueue默认):SynchronousQueue采用非公平锁,同时配合一个LIFO队列来管理多余的生产者和消费者,而后一种模式,如果生产者和消费者的处理速度有差距,则很容易出现饥渴的情况,即可能有某些生产者或者是消费者的数据永远都得不到处理。
锁
锁的种类
悲/乐观锁
乐观锁
- 乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。
- java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。
悲观锁
-
顾名思义,就是比较悲观的锁,总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。
-
传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
公平/非公平锁
公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。
- 优点:所有的线程都能得到资源,不会饿死在队列中。
- 缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。
非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。
- 优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
- 缺点:你们可能也发现了,这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。
读写锁
所有读写锁的实现必须确保写操作对读操作的内存影响。换句话说,一个获得了读锁的线程必须能看到前一个释放的写锁所更新的内容。
读写锁比互斥锁允许对于共享数据更大程度的并发。每次只能有一个写线程,但是同时可以有多个线程并发地读数据。ReadWriteLock 适用于读多写少的并发情况。
ava并发包中 ReadWriteLock 是一个接口,主要有两个方法,如下:
public interface ReadWriteLock {
/**
* 返回读锁
*/
Lock readLock();
/**
* 返回写锁
*/
Lock writeLock();
}
Java并发库中 ReetrantReadWriteLock实现了 ReadWriteLock 接口并添加了可重入的特性。
ReentrantReadWriteLock
特性:
- 获取顺序
非公平模式 (默认)
- 当以非公平初始化时,读锁和写锁的获取的顺序是不确定的。非公平锁主张竞争获取,可能会延缓一个或多个读或写线程,但是会比公平锁有更高的吞吐量。
公平模式
- 当以公平模式初始化时,线程将会以队列的顺序获取锁。当当前线程释放锁后,等待时间最长的写锁线程就会被分配写锁;或者有一组读线程组等待时间比写线程长,那么这组读线程组将会被分配读锁。
- 当有写线程持有写锁或者有等待的写线程时,一个尝试获取公平的读锁(非重入)的线程就会阻塞。这个线程直到等待时间最长的写锁获得锁后并释放掉锁后才能获取到读锁。
2.可重入
允许读锁可写锁可重入。写锁可以获得读锁,读锁不能获得写锁。
3.锁降级
允许写锁降低为读锁
4.中断锁的获取
在读锁和写锁的获取过程中支持中断
5.支持Condition
写锁提供Condition实现
6.监控
提供确定锁是否被持有等辅助方法
可重入锁
可重入锁有 :
- sychronized
- ReentrantLock : ReentrantLock 和 synchronized 不一样,需要手动释放锁,所以使用 ReentrantLock的时候一定要手动释放锁,`并且加锁次数和释放次数要一样
CAS
- CAS全称
compare and swap
,一个CPU原子指令,在硬件层面实现的机制,体现了乐观锁的思想。 - JVM用C语言封装了汇编调用。Java的基础库中有很多类就是基于 JNI 调用C接口实现了多线程同步更新的功能。
CAS原理:
CAS有三个操作数:
- 当前主内存变量的值V
- 线程本地变量预期值A
- 线程本地待更新值B。
当需要更新变量值的时候,会先获取到内存变量值V然后跟预期值A进行比较,如果相同则更新为B,如果不同,则将最新的变量值更新到预期值中再重新尝试上面的步骤,也就是比较并交换直到成功为止。
关于CAS的原子性
如果普通线程执行加减操作, 反编译可以看到其是由三个指令构成的:
所以多线程切换
可能会造成数据更新的不同步
解决方案 : 就是对被操作的数据加锁,可以是悲观锁,可以是乐观锁,这里使用的就是基于乐观锁实现的AtomicInteger类
CAS的缺点
- ABA问题:内存对象从A变成B在变成A,CAS会当成没有变化,进而去更新值,实际是有变化的。
- 循环时间开销大:一直和预期值不对的情况下,会一直循环。
- 只能保证一个共享变量的原子操作。
volatile
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
2)禁止进行指令重排序。
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时(读过了放在那不更新),它会去内存中读取新值,这就保证了可见性。
在Java里面,可以通过volatile关键字来保证一定的“有序性”
Sychronized & Lock 的区别
不同点
来源不同
- Synchronized是Java提供的关键字,属于Java语法层面的互斥锁,也称“隐式锁”。竞争锁、释放锁的过程开发者无需关心也不能干预,由JVM来完成。
- Lock是指java.util.concurrent包下的Lock接口,描述的是一把同步锁,由Java代码来控制多线程同步,也称“显式锁”。可以自己实现一把锁,也可以直接使用由并发大神Doug Lea编写的ReentrantLock。
锁的释放不同
- Synchronized锁的释放由JVM来完成,开发者无法干预。同步代码块运行结束,或者出现异常JVM均会释放锁。
- Lock加的锁必须开发者手动释放,如果同步代码块抛了异常,锁没释放则会发生死锁,一般释放锁代码建议写在 finally 块中,确保锁一定释放。
性能不同
- Synchronized在JDK6之前,采用OS级别的互斥锁,竞争锁失败的线程会被挂起,性能非常低,JDK6做了大量优化,会自动进行锁膨胀,降低了锁开销,性能提升很大,但是竞争激烈时性能还是会下降。
- Lock不管锁竞争激烈与否,性能基本保持在一个数量级,适合锁竞争比较激烈的应用场景。
竞争锁失败的线程状态不同
- Synchronized竞争锁失败的线程状态是:BLOCKED。
- Lock竞争锁失败的线程状态是:WAITING。
JVM堆栈跟踪
- Synchronized阻塞的线程更加便于JVM跟踪,使用 jstrack 可以清楚的看到。
- Lock通过LockSupport.park() 来阻塞线程,不利于JVM跟踪。
响应中断
- Synchronized不支持响应中断,竞争不到锁会一直阻塞。
- Lock支持响应中断。
锁超时
- Synchronized不支持锁超时,竞争不到锁会一直死等,容易造成死锁。
- Lock支持锁超时,在给定时间内获取不到锁可以进行其他处理。
公平/非公平锁
- Synchronized采用非公平锁,且不允许修改,可能会造成“线程饿死”。
- Lock支持公平锁与非公平锁,开发者可以自己选择。
尝试获取锁判断
- Synchronized不支持获取锁成功与否的判断。
- Lock支持。
读写锁
- Synchronized不支持读写锁,对于读多写少的场景无法优化性能。
- Lock支持读写锁,读读不互斥,对于读多写少的场景可以进一步优化性能。
Sychronized
反编译的monitor
Synchronized的实现依赖于JVM指令monitorenter
和monitorexit
。
public class MonitorDemo {
synchronized void syncMethod(){ //同步方法
}
void method(){
synchronized (this){ // 同步代码块
}
}
}
使用javac编译成class文件,再使用javap -verbose
生成JVM汇编指令,如下图:
同步方法中Synchronized流程:
JVM 会给其加上 ACC_SYNCHRONIZED 标识,当线程执行一个方法前,会先检查方法是否存在ACC_SYNCHRONIZED 标识,如果存在则要去竞争对应的monitor锁,竞争锁成功再执行方法,否则线程阻塞。
同步代码块中Synchronized流程:
JVM则会在代码块开始前后插入 monitorenter和 monitorexit 指令,分别为竞争锁和释放锁。
monitorenter
在Java中,每个对象都会与一个monitor相关联,当某个monitor被拥有之后就会被锁住,当线程执行到monitorenter指令时,就会去尝试获得对应的monitor。
因为Synchronized锁是可以重入的,所以每个monitor都维护了一个计数器。
线程每次执行 monitorenter 前都会进行判断,如果当前线程拥有monitor,指令计数器就会加1;
如果monitor说明没有获得锁,线程阻塞。
monitorexit
线程每执行完一次 monitorexit ,计数器就减 1,当计数器减至 0 时,monitor将会被释放,其他线程可以来竞争。
monitor构成及原理
Java对象的构成
首先我们要了解下可以被锁住的对象的构成是什么样的
锁的是对象,而非代码
- 修饰实例方法时,锁的是实例对象。
- 修饰静态方法时,锁的是类的class对象。
- 修饰代码块时,锁的是给定对象。
在Java中,对象除了自身的实例数据外,还有开发者看不到的一些数据:对象头、对齐字节。如下图:
当线程成功竞争到锁时,会修改对象头中的Mark Word
数据:偏向线程ID和锁标记。
补充点: 在Java中,任何对象都有对象头信息,这意味着任何对象都可以当锁。
基本数据类型不是对象,没有对象头信息,这也就解释了:为什么基本数据类型不能作为锁对象?
Synchronized实现原理
也就是monitor的工作原理
首先看一下monitor大概的内存模型:
它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。
- Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;
- Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中;
- Wait Set:哪些调用wait方法被阻塞的线程被放置在这里;
- OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck;
- Owner:当前已经获取到所资源的线程被称为Owner;
- !Owner:当前释放锁的线程。
sychronized工作流程
monitor原理
操作系统中的管程
如果你在大学学习过操作系统,你可能还记得管程(monitors)在操作系统中是很重要的概念。同样Monitor在java同步机制中也有使用。
管程 (英语:Monitors,也称为监视器) 是一种程序结构,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。这些共享资源一般是硬件设备或一群变量。管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。与那些通过修改数据结构实现互斥访问的并发程序设计相比,管程实现很大程度上简化了程序设计。 管程提供了一种机制,线程可以临时放弃互斥访问,等待某些条件得到满足后,重新获得执行权恢复它的互斥访问。
Java线程同步相关的Moniter
在多线程访问共享资源的时候,经常会带来可见性和原子性的安全问题。为了解决这类线程安全的问题,Java提供了同步机制、互斥锁机制,这个机制保证了在同一时刻只有一个线程能访问共享资源。这个机制的保障来源于监视锁Monitor,每个对象都拥有自己的监视锁Monitor。
先来举个例子,然后我们在上源码。我们可以把监视器理解为包含一个特殊的房间的建筑物,这个特殊房间同一时刻只能有一个客人(线程)。这个房间中包含了一些数据和代码。
如果一个顾客想要进入这个特殊的房间,他首先需要在走廊(Entry Set)排队等待。调度器将基于某个标准(比如 FIFO)来选择排队的客户进入房间。如果,因为某些原因,该客户客户暂时因为其他事情无法脱身(线程被挂起),那么他将被送到另外一间专门用来等待的房间(Wait Set),这个房间的可以可以在稍后再次进入那件特殊的房间。如上面所说,这个建筑屋中一共有三个场所。
总之,监视器是一个用来监视这些线程进入特殊的房间的。他的义务是保证(同一时间)只有一个线程可以访问被保护的数据和代码。
Monitor其实是一种同步工具,也可以说是一种同步机制,它通常被描述为一个对象,主要特点是:
对象的所有方法都被“互斥”的执行。好比一个Monitor只有一个运行“许可”,任一个线程进入任何一个方法都需要获得这个“许可”,离开时把许可归还。
通常提供singal机制:允许正持有“许可”的线程暂时放弃“许可”,等待某个谓词成真(条件变量),而条件成立后,当前进程可以“通知”正在等待这个条件变量的线程,让他可以重新去获得运行许可。
监视器的实现
在Java虚拟机(HotSpot)中,Monitor是基于C++实现的,由ObjectMonitor实现的,其主要数据结构如下:
_owner:指向持有ObjectMonitor对象的线程
_WaitSet:存放处于wait状态的线程队列
_EntryList:存放处于等待锁block状态的线程队列
_recursions:锁的重入次数
_count:用来记录该线程获取锁的次数
当多个线程同时访问一段同步代码时,首先会进入_EntryList队列中,当某个线程获取到对象的monitor后进入_Owner区域并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器_count加1。即获得对象锁。
若持有monitor的线程调用wait()方法,将释放当前持有的monitor,_owner变量恢复为null,_count自减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示
sychronized
加锁的时候,会调用objectMonitor的enter
方法,解锁的时候会调用exit
方法。
除了enter和exit方法以外,objectMonitor.cpp中还有
void wait(jlong millis, bool interruptable, TRAPS);
void notify(TRAPS);
void notifyAll(TRAPS);
JDK6 关于锁的优化
JDK1.6之前的锁是什么样的
在JDK6之前,Synchronized是非常笨重的,以至于开发者不太愿意使用而慢慢摒弃它
但是在JDK6中,对Synchronized做了大量的优化,性能和ReentrantLock
已经不相上下,官方也更加推荐使用Synchronized。
JDK1.6之后的优化
锁消除
设计一个类时,为了考虑并发安全,往往会对代码块上锁。
但是有时候压根就不会产生并发问题
例如:在线程私有的栈内存中使用线程安全的类实例,且实例不存在逃逸。
如果不存在并发安全,那还有什么理由上锁呢?
在 JIT 编译时,会对运行上下文进行扫描,去除不可能产生并发问题的锁。
用代码举例:
public String method(){
StringBuffer sb = new StringBuffer();
sb.append("1");
sb.append("2");
return sb.toString();
}
如上代码,StringBuffer的append()方法被synchronized修饰,但是在该方法中不存在并发问题,方法栈内存为线程私有,sb实例不可能被其他线程访问到,对于这种情况就会进行锁消除。
锁粗化
由于锁的竞争和释放开销比较大,如果代码中对锁进行了频繁的竞争和释放,那么JVM会进行优化,将锁的范围适当扩大。
如下代码,在循环内使用synchronized,JVM锁粗化后,会将锁范围扩大到循环外面
public String method(){
for (int i= 0; i < 100; i++) {
synchronized (this){
...
}
}
}
自旋锁
正常情况下线程阻塞的话需要切换至内核态,但是优化后的处理方式如下:
线程阻塞不必直接转化为内核态, 尝试自旋可以节省下来切换成内核态(因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。)
所需要的时间。
Question : 当有多个线程在竞争同一把锁时,竞争失败的线程如何处理?
面对这种情况有两种选择:
将线程挂起,锁释放后再将其唤醒。
线程不挂起,自旋操作,不断的监测锁状态并竞争。
如果锁竞争非常激烈,且短时间得不到释放,那么将线程挂起效率会更高,因为竞争失败的线程不断自旋会造成CPU空转,浪费性能。
优势:
如果锁竞争并不激烈,且锁会很快得到释放,那么自旋效率会更高。因为将线程挂起和唤醒是一个开销很大的操作。
自旋锁的优化是针对“锁竞争不激烈,且会很快释放”的场景,避免了OS频繁挂起和唤醒线程。
缺点:
但是线程自旋是需要消耗CPU的,说白了就是让CPU在做无用功,如果一直获取不到锁,那线程也不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间.
自适应自旋锁
当线程竞争锁失败时,自旋和挂起哪一种更高效?
自适应自旋锁 解决的就是这个问题
策略:
当线程竞争锁失败时,会自旋N次,如果仍然竞争不到锁,说明锁竞争比较激烈,继续自旋会浪费性能,JVM就会将线程挂起。
- JDK6之前: 自旋的次数通过JVM参数 -XX:PreBlockSpin 设置,但是开发者往往不知道该设置多少比较合适
- 于是在JDK6中: 对其进行了优化,加入了“自适应自旋锁”。
自适应自旋锁的大致原理 :
- 线程如果自旋成功了,那么下次自旋的最大次数会增加,因为JVM认为既然上次成功了,那么这一次也很大概率会成功。
- 反之,如果很少会自旋成功,那么下次会减少自旋的次数甚至不自旋,避免CPU空转。
锁膨胀
在JDK6之前,Synchronized用的都是重量级锁,依赖于OS的Mutex Lock来实现,OS将线程从用户态切换到核心态,成本非常高,性能很低。
在JDK6中,针对锁进行优化,不直接使用重量级锁,而是逐步进行锁的膨胀。
锁状态的级别由低到高为:无锁、偏向锁、轻量级锁、重量级锁。
偏向锁、轻量级锁都属于乐观锁,重量级锁属于悲观锁。
默认为无锁状态,随着锁竞争的激烈程度会不断膨胀,最终才会使用开销最大的重量级锁。
无锁
在对象的头信息Mark Word中记录了对象的锁状态,如下图:
如果没有任何线程竞争锁,那么对象默认为无锁状态。
偏向锁
针对单线程锁竞争做的优化,最乐观的锁。
HotSpot作者经过研究发现,开发者为了保证线程安全问题给代码块上了锁,但是大多数情况下,锁并不存在多线程竞争,而是单线程反复获得。
单一线程,为什么还要去频繁的获取和释放锁呢?所以就有了“偏向锁”的概念。
偏向锁是针对单线程反复获得锁而做的优化,是最乐观的锁:只有单个线程来竞争锁。
在JDK5中偏向锁是关闭的,JDK6中默认开启,可以通过JVM参数-XX:-UseBiasedLocking来关闭偏向锁。
偏向锁大致流程如下:
-
线程A第一次获得锁后,CAS操作修改对象头信息中的Mark Word:无锁->偏向锁、偏向线程ID->线程A。
-
线程A需要再次获得锁时,首先判断偏向线程ID是否是自己,如果是则直接获得锁,速度非常快。
偏向锁并不会主动释放,需要等待其他线程来竞争。
线程B来竞争锁,发现锁偏向线程A,此时CAS操作失败,则进一步判断:线程A是否还在占用锁?
线程A未占用:将锁重新偏向线程B,线程B获得锁。
线程A仍占用:说明锁存在多线程竞争,升级为:轻量级锁。
轻量级锁
针对锁竞争不激烈做的优化,使用自旋锁避免线程频繁挂起和唤醒。
只有单一线程竞争锁时用的是偏向锁,最乐观的锁也是性能最高的锁。
一旦涉及到多线程竞争锁,就会升级为轻量级锁。
偏向锁发现线程不一致, 则升级为轻量级锁
- 轻量级锁认为:存在多线程竞争锁,但是竞争不激烈。
- 轻量级锁的实现原理:让竞争锁失败的线程自旋而不是挂起。
如果将竞争锁失败的线程直接挂起,然后锁释放后再将其唤醒,这是一个开销很大的操作。
而大多数情况下,锁的占用时间往往非常短,会很快被释放,那么轻量级锁认为:不要挂起线程,而是让其进行自旋,执行一些无用的指令,只要锁被释放,线程马上就能获得锁,而不用等待OS将其唤醒。
总结:自旋成本 < 线程挂起成本
线程A获得锁未释放,此时线程B来竞争锁,发现锁被线程A占用,线程B认为线程A可能很快就会释放锁,于是进行自旋操作:
- 自旋成功:说明锁的占用时间并不长,下次会自适应增加最大自旋次数(自适应自旋)。
- 自旋失败:锁的占用时间较长,继续自旋会浪费CPU资源,线程被挂起,升级为:重量级锁。
重量级锁
开销最大,性能最低的悲观锁,锁竞争激烈时采用
-
锁竞争不激烈时,竞争锁失败的线程进行自旋而非挂起可以提升性能,因为 自旋的开销 < 线程挂起、唤醒的开销。
-
但是锁竞争激烈时,自旋会造成更大的资源开销。
例如:100个线程竞争同一把锁,99个线程在自旋,意味着99%的CPU资源被浪费,此时自旋的开销>线程挂起、唤醒的开销。当竞争比较激烈时,就会膨胀为重量级锁,因为轻量级锁的效率此时更低。
重量级锁通过监视器锁(Monitor)实现,Monitor又依赖于底层OS的Mutex Lock实现。
升级为重量级锁后,所有竞争锁失败的线程都会被阻塞挂起,锁被释放后再将线程唤醒。
线程频繁的挂起和唤醒,OS需要将线程从用户态切换为核心态,这个操作成本是非常高的,需要花费较长的时间,这就导致重量级锁效率很低。
Sychornized和Lock两者如何选择?
- Synchronized是Java内置的同步器,使用简单,语法清晰易读,性能也不差,而且便于JVM堆栈跟踪,官方也表示Synchronized性能后期还有优化的余地,所以如果没有特殊要求,建议尽量使用Synchronized。
- 虽然建议尽量使用Synchronized,但是它毕竟自身存在一些功能上的缺陷,例如:无法响应中断,不支持锁超时,不能采用公平锁等等,如果确实需要这些高级特性,那么还是应该使用ReentrantLock(重进入锁)。
AQS(AbstractQueuedSynchronizer)
抽象队列同步器
引子
打个比方,你开了一家饭馆,可以容纳10桌客人同时吃饭。来客人了,如果里头吃饭的不到10桌,那么请进,如果已经有10桌在吃了,那不好意思,请排队。
同样是排队,这一次,你不开饭馆了,你组织了一个饭局,邀请了10个人过来,人齐了才能开吃,这下排队的规则就变了,客人来了,看看到场的人到没到10个,到了,好,开吃,没到,不好意思,咱们继续聊聊天,玩玩手机,等那个该死的迟到的家伙。
排队的规则不尽相同,但也有相同的地方,那就是你总要安顿好排队的客人吧?当可以进来吃饭的时候,你需要取通知客人吧?
这些各种排队系统都需要的东西,比如安顿客人、通知客人用餐,就是AQS干的活,至于具体的排队规则,则是交给具体的同步工具类去制定。
现在让我们回到二进制世界中,现在,你要同步的,不再是人,而是线程。
你要安顿的不是客人,而是线程,你要把等待的线程,放到一个队列中去,然后把它们挂起,不让它们乱动,浪费CPU;然后,在合适的时候,唤醒这些线程。
二进制世界里,
Semaphore
就是上面说的饭馆,专业点的翻译,叫信号量
,总共10个permits,进来一个线程,就拿走一个,拿完了,其他线程就不能进来,只能等待拿到permit的线程,释放permit,这时候其他线程才有机会进去。而
CountDownLatch
,没错,就是饭局,你设置了一个减数器,初始值为10,每来一个线程,减掉一个,减完还不等于0,那么线程等待,减到0时,ok,全部唤醒,放行。这里稍微分享下AQS源码里的几个关键词吧:
- state状态:这是
AbstractQueuedSynchronizer
里一个万能的属性,具体是什么含义,全看你的使用方式,比如在CountDownLatch
里,它代表了当前到达后正在等待的线程数,在Semaphore
里,它则表示当前进去后正在运行的线程数- CAS: AQS里大量用了CAS(Compare and Swap)操作来修改state的值
- LockSupport: AQS里用了大量的LockSupport的park()和unpark()方法,来挂起和唤醒线程
- 同步队列和条件队列:sync queue and condition queue,弄清楚这两个队列的关系,AQS也就弄懂大半
- 公平和非公平:有线程竞争,就有公平和非公平的问题。锁释放的时候,刚好有个线程过来获取锁,但这时候线程等待队列里也有线程在等待,到底是给排队时间最久的线程呢(公平),还是允许新来的线程参与竞争(不公平)
基本框架
AQS维护了一个volatile语义(支持多线程下的可见性)的共享资源变量state和一个FIFO线程等待队列(多线程竞争state被阻塞时会进入此队列)。
State
首先说一下共享资源变量state,它是int数据类型的,其访问方式有3种:
- getState()
- setState(int newState)
- compareAndSetState(int expect, int update)
上述3种方式均是原子操作,其中compareAndSetState()的实现依赖于Unsafe的compareAndSwapInt()方法。
private volatile int state;
// 具有内存读可见性语义
protected final int getState() {
return state;
}
// 具有内存写可见性语义
protected final void setState(int newState) {
state = newState;
}
// 具有内存读/写可见性语义
protected final boolean compareAndSetState(int expect, int update) {
// See below for intrinsics setup to support this
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
资源的共享方式分为2种:
- 独占式(Exclusive)
只有单个线程能够成功获取资源并执行,如ReentrantLock。
- 共享式(Shared)
多个线程可成功获取资源并执行,如Semaphore/CountDownLatch等。
AQS将大部分的同步逻辑均已经实现好,继承的自定义同步器只需要实现state的获取(acquire)和释放(release)的逻辑代码就可以,主要包括下面方法:
- tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
- tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
- tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
- tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
- isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
AQS需要子类复写的方法均没有声明为abstract,目的是避免子类需要强制性覆写多个方法,因为一般自定义同步器要么是独占方法,要么是共享方法,只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。
当然,AQS也支持子类同时实现独占和共享两种模式,如ReentrantReadWriteLock。
CLH队列(FIFO)
AQS是通过内部类Node来实现FIFO队列的,源代码解析如下:
static final class Node {
// 表明节点在共享模式下等待的标记
static final Node SHARED = new Node();
// 表明节点在独占模式下等待的标记
static final Node EXCLUSIVE = null;
// 表征等待线程已取消的
static final int CANCELLED = 1;
// 表征需要唤醒后续线程
static final int SIGNAL = -1;
// 表征线程正在等待触发条件(condition)
static final int CONDITION = -2;
// 表征下一个acquireShared应无条件传播
static final int PROPAGATE = -3;
/**
* SIGNAL: 当前节点释放state或者取消后,将通知后续节点竞争state。
* CANCELLED: 线程因timeout和interrupt而放弃竞争state,当前节点将与state彻底拜拜
* CONDITION: 表征当前节点处于条件队列中,它将不能用作同步队列节点,直到其waitStatus被重置为0
* PROPAGATE: 表征下一个acquireShared应无条件传播
* 0: None of the above
*/
volatile int waitStatus;
// 前继节点
volatile Node prev;
// 后继节点
volatile Node next;
// 持有的线程
volatile Thread thread;
// 链接下一个等待条件触发的节点
Node nextWaiter;
// 返回节点是否处于Shared状态下
final boolean isShared() {
return nextWaiter == SHARED;
}
// 返回前继节点
final Node predecessor() throws NullPointerException {
Node p = prev;
if (p == null)
throw new NullPointerException();
else
return p;
}
// Shared模式下的Node构造函数
Node() {
}
// 用于addWaiter
Node(Thread thread, Node mode) {
this.nextWaiter = mode;
this.thread = thread;
}
// 用于Condition
Node(Thread thread, int waitStatus) {
this.waitStatus = waitStatus;
this.thread = thread;
}
}
可以看到,waitStatus非负的时候,表征不可用,正数代表处于等待状态,所以waitStatus只需要检查其正负符号即可,不用太多关注特定值。
获取资源(独占模式)
acquire(int)
首先讲解独占模式(Exclusive)下的获取/释放资源过程,其入口方法为:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
tryAcquire(arg)为线程获取资源的方法函数,在AQS中定义如下:
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
很明显,该方法是空方法,且由protected修饰,说明该方法需要由子类即自定义同步器来实现。
acquire()方法至少执行一次tryAcquire(arg),若返回true,则acquire直接返回,否则进入acquireQueued(addWaiter(Node.EXCLUSIVE), arg)方法。
acquireQueued方法分为3个步骤:
- addWriter()将当前线程加入到等待队列的尾部,并标记为独占模式;
- acquireQueued()使线程在等待队列中获取资源,直到获取到资源返回,若整个等待过程被中断过,则返回True,否则返回False。
- 如果线程在等待过程中被中断过,则先标记上,待获取到资源后再进行自我中断selfInterrupt(),将中断响应掉。
下面具体看看过程中涉及到的各函数:
tryAcquire(int)
tryAcquire尝试以独占的模式获取资源,如果获取成功则返回True,否则直接返回False,默认实现是抛出UnsupportedOperationException,具体实现由自定义扩展了AQS的同步器来完成。
addWaiter(Node)
addWaiter为当前线程以指定模式创建节点,并将其添加到等待队列的尾部,其源码为:
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// 尝试将节点快速插入等待队列,若失败则执行常规插入(enq方法)
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 常规插入
enq(node);
return node;
}
再看enq(node)方法:
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;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
可以看到,常规插入与快速插入相比,有2点不同:
- 常规插入是自旋过程(for(;😉),能够保证节点插入成功;
- 比快速插入多包含了1种情况,即当前等待队列为空时,需要初始化队列,即将待插入节点设置为头结点,同时为尾节点(因为只有一个嘛)。
常规插入与快速插入均依赖于CAS,其实现依赖于unsafe类,具体代码如下:
private final boolean compareAndSetHead(Node update) {
return unsafe.compareAndSwapObject(this, headOffset, null, update);
}
private final boolean compareAndSetTail(Node expect, Node update) {
return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
}
unsafe中的cas操作均是native方法,由计算机CPU的cmpxchg指令来保证其原子性。
接着看acquireQueued()方法:
acquireQueued(Node, int)
相关说明已在代码中注释:
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);
// 说明前继节点已经释放掉资源了,将其next置空,以方便虚拟机回收掉该前继节点
p.next = null; // help GC
// 标识获取资源成功
failed = false;
// 返回中断标记
return interrupted; // 如果发生中断 这里返回true
}
// 若前继节点不是头结点,或者获取资源失败,
// 则需要通过shouldParkAfterFailedAcquire函数
// 判断是否需要阻塞该节点持有的线程
// 若shouldParkAfterFailedAcquire函数返回true,
// 则继续执行parkAndCheckInterrupt()函数,
// 将该线程阻塞并检查是否可以被中断,若返回true,则将interrupted标志置于true
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
// 最终获取资源失败,则当前节点放弃获取资源
if (failed)
cancelAcquire(node);
}
}
具体看一下shouldParkAfterFailedAcquire函数:
// shouldParkAfterFailedAcquire是通过前继节点的waitStatus值来判断是否阻塞当前节点的线程的
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 获取前继节点的waitStatus值ws
int ws = pred.waitStatus;
// 如果ws的值为Node.SIGNAL(-1),则直接返回true
// 说明前继节点完成资源的释放或者中断后,会通知当前节点的,回家等通知就好了,不用自旋频繁地来打听消息
// 也就是说 如果前继节点处于正常状态即ws=Node.SIGNAL 证明会唤醒下一个节点 那么就可以阻塞线程
// 否则 下面的逻辑就是认定前继节点已经报废 将此节点进行前移 直到找到一个正常的前继节点 继续下一次自旋
if (ws == Node.SIGNAL)
return true;
// 如果前继节点的ws值大于0,即为1,说明前继节点处于放弃状态(Cancelled)
// 那就继续往前遍历,直到当前节点的前继节点的ws值为0或负数
// 此处代码很关键,节点往前移动就是通过这里来实现的,直到节点的前继节点满足
// if (p == head && tryAcquire(arg))条件,acquireQueued方法才能够跳出自旋过程
if (ws > 0) {
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
// 将前继节点的ws值设置为Node.SIGNAL,以保证下次自旋时,shouldParkAfterFailedAcquire直接返回true
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
parkAndCheckInterrupt()函数则简单很多,主要调用LockSupport类的park()方法阻塞当前线程,并返回线程是否被中断过。
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);// 先发生的中断后阻塞 所以阻塞不受影响
return Thread.interrupted();
}
至此,独占模式下,线程获取资源acquire的代码就跟完了,总结一下过程:
- 首先线程通过tryAcquire(arg)尝试获取共享资源,若获取成功则直接返回,若不成功,则将该线程以独占模式添加到等待队列尾部,tryAcquire(arg)由继承AQS的自定义同步器来具体实现;
- 当前线程加入等待队列后,会通过acquireQueued方法基于CAS自旋不断尝试获取资源,直至获取到资源;
- 若在自旋过程中,线程被中断过,acquireQueued方法会标记此次中断,并返回true。
- 若acquireQueued方法获取到资源后,返回true,则执行线程自我中断操作selfInterrupt()。
static void selfInterrupt() {
Thread.currentThread().interrupt();
}
释放资源(独占模式)
讲完获取资源,对应的讲一下AQS的释放资源过程,其入口函数为:
public final boolean release(int arg) {
if (tryRelease(arg)) {
// 获取到等待队列的头结点h
Node h = head;
// 若头结点不为空且其ws值非0,则唤醒h的后继节点
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
逻辑并不复杂,通过tryRelease(arg)来释放资源,和tryAcquire类似,tryRelease也是有继承AQS的自定义同步器来具体实现。
tryRelease(int)
该方法尝试释放指定量的资源。
protected boolean tryRelease(int arg) {
throw new UnsupportedOperationException();
}
unparkSuccessor(Node)
该方法主要用于唤醒等待队列中的下一个阻塞线程。
private void unparkSuccessor(Node node) {
// 获取当前节点的ws值
int ws = node.waitStatus;
// 将当前节点的ws值置0
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next;
// 若后继节点为null或者其ws值大于0(放弃状态),则从等待队列的尾节点从后往前搜索,
// 搜索到等待队列中最靠前的ws值非正且非null的节点
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;
}
// 如果后继节点非null,则唤醒该后继节点持有的线程
if (s != null)
LockSupport.unpark(s.thread);
}
后继节点的阻塞线程被唤醒后,就进入到acquireQueued()的if (p == head && tryAcquire(arg))的判断中,此时被唤醒的线程将尝试获取资源。
当然,如果被唤醒的线程所在节点的前继节点不是头结点,经过shouldParkAfterFailedAcquire的调整,也会移动到等待队列的前面,直到其前继节点为头结点。
讲解完独占模式下资源的acquire/release过程,下面开始讲解共享模式下,线程如何完成资源的获取和共享。
获取资源(共享模式)
理解了独占模式下,资源的获取和释放过程,则共享模式下也就so easy了,首先看一下方法入口:
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
执行tryAcquireShared方法获取资源,若获取成功则直接返回,若失败,则进入等待队列,执行自旋获取资源,具体由doAcquireShared方法来实现。
tryAcquireShared(int)
同样的,tryAcquireShared(int)由继承AQS的自定义同步器来具体实现。
protected int tryAcquireShared(int arg) {
throw new UnsupportedOperationException();
}
其返回值为负值代表失败;0代表获取成功,但无剩余资源;正值代表获取成功且有剩余资源,其他线程可去获取。
doAcquireShared(int)
private void doAcquireShared(int arg) {
// 将线程以共享模式添加到等待队列的尾部
final Node node = addWaiter(Node.SHARED);
// 初始化失败标志
boolean failed = true;
try {
// 初始化线程中断标志
boolean interrupted = false;
for (;;) {
// 获取当前节点的前继节点
final Node p = node.predecessor();
// 若前继节点为头结点,则执行tryAcquireShared获取资源
if (p == head) {
int r = tryAcquireShared(arg);
// 若获取资源成功,且有剩余资源,将自己设为头结点并唤醒后续的阻塞线程
if (r >= 0) {
setHeadAndPropagate(node, r);
p.next = null; // help GC
// 如果中断标志位为真,则线程执行自我了断
if (interrupted)
selfInterrupt();
// 表征获取资源成功
failed = false;
return;
}
}
// houldParkAfterFailedAcquire(p, node)根据前继节点判断是否阻塞当前节点的线程
// parkAndCheckInterrupt()阻塞当前线程并检查线程是否被中断过,若被中断过,将interrupted置为true
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
// 放弃获取资源
cancelAcquire(node);
}
}
可以发现,doAcquireShared与独占模式下的acquireQueued大同小异,主要有2点不同:
- doAcquireShared将线程的自我中断操作放在了方法体内部;
- 当线程获取到资源后,doAcquireShared会将当前线程所在的节点设为头结点,若资源有剩余则唤醒后续节点,比acquireQueued多了个唤醒后续节点的操作。
上述方法体现了共享的本质,即当前线程吃饱了后,若资源有剩余,会招呼后面排队的来一起吃,好东西要大家一起分享嘛,哈哈。
下面具体看一下setHeadAndPropagate(Node, int)函数:
private void setHeadAndPropagate(Node node, int propagate) {
// 记录原来的头结点,下面过程会用到
Node h = head;
// 设置新的头结点
setHead(node);
// 如果资源还有剩余,则唤醒后继节点
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
if (s == null || s.isShared())
doReleaseShared();
}
}
可以看到,实际执行唤醒后继节点的方法是doReleaseShared(),继续追踪:
private void doReleaseShared() {
// 自旋操作
for (;;) {
// 获取等待队列的头结点
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue;
// 唤醒后继节点的线程
unparkSuccessor(h);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}
释放资源(共享模式)
首先进入到方法入口:
public final boolean releaseShared(int arg) {
// 尝试释放资源
if (tryReleaseShared(arg)) {
// 唤醒后继节点的线程
doReleaseShared();
return true;
}
return false;
}
同样的,tryReleaseShared(int)由继承AQS的自定义同步器来具体实现。
doReleaseShared()上节讲解setHeadAndPropagate已说明过,不再赘述。
至此,共享模式下的资源获取/释放就讲解完了,下面以一个具体场景来概括一下:
整个获取/释放资源的过程是通过传播完成的,如最开始有10个资源,线程A、B、C分别需要5、4、3个资源。
- A线程获取到5个资源,其发现资源还剩余5个,则唤醒B线程;
- B线程获取到4个资源,其发现资源还剩余1个,唤醒C线程;
- C线程尝试取3个资源,但发现只有1个资源,继续阻塞;
- A线程释放1个资源,其发现资源还剩余2个,故唤醒C线程;
- C线程尝试取3个资源,但发现只有2个资源,继续阻塞;
- B线程释放2个资源,其发现资源还剩余4个,唤醒C线程;
- C线程获取3个资源,其发现资源还剩1个,继续唤醒后续等待的D线程;