toc
从操作系统的角度来看,线程是CPU分配的最小单位。
就好像我们去食堂打饭,并行就是我们在多个窗口排队,几个阿姨同时打菜;并发就是我们挤在一个窗口,阿姨给这个打一勺,又手忙脚乱地给那个打一勺。
要说线程,必须得先说说进程。
操作系统在分配资源时是把资源分配给进程的,但是CPU资源比较特殊,它是被分配到线程的,因为真正要占用CPU运行的是线程,所以也说线程是 CPU分配的基本单位。
比如在Java中,当我们启动main函数其实就启动了一个JVM进程,而main函数在的线程就是这个进程中的一个线程,也称主线程。
一个进程中有多个线程,多个线程共用进程的堆和方法区资源,但是每个线程有自己的程序计数器和栈。
Java中创建线程主要有三种方式,分别为继承Thread类、实现Runnable接口、实现Callable接口。
public class ThreadTest {
/**
* 继承Thread类
*/
public static class MyThread extends Thread {
@Override
public void run() {
System.out.println("This is child thread");
}
}
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start();
}
}
public class RunnableTask implements Runnable {
public void run() {
System.out.println("Runnable!");
}
public static void main(String[] args) {
RunnableTask task = new RunnableTask();
new Thread(task).start();
}
}
上面两种都是没有返回值的,但是如果我们需要获取线程的执行结果,该怎么办呢?
public class CallerTask implements Callable<String> {
public String call() throws Exception {
return "Hello,i am running!";
}
public static void main(String[] args) {
//创建异步任务
FutureTask<String> task = new FutureTask<String>(new CallerTask());
//启动线程
new Thread(task).start();
try {
//等待执行完成,并获取返回结果
String result = task.get();
System.out.println(result);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}
JVM执行start方法,会先创建一条线程,由创建出来的新线程去执行thread的run方法,这才起到多线程的效果。
为什么我们不能直接调用run() 方法?也很清楚,如果直接调用Thread的run() 方法,那么run方法还是运行在主线程中,相当于顺序执行,就起不到多线程的效果。
线程等待与通知
在Object类中有一些函数可以用于线程的等待与通知。
wait()
:当一个线程A调用一个共享变量的 wait()
方法时,线程A会被阻塞挂起,发生下面几种情况才会返回notify()
或者 notifyAll()
方法;interrupt()
方法,线程A抛出InterruptedException
异常返回。wait(long timeout)
:这个方法相比wait()
方法多了一个超时参数,它的不同之处在于,如果线程A调用共享对象的wait(long timeout)
方法后,没有在指定的timeout ms时间内被其它线程唤醒,那么这个方法还是会因为超时而返回。wait(long timeout, int nanos)
,其内部调用的是 wait(long timout)
函数。上面是线程等待的方法,而唤醒线程主要是下面两个方法:
notify()
:一个线程A调用共享对象的notify()
方法后,会唤醒一个在这个共享变量上调用wait系列方法后被挂起的线程。一个共享变量上可能会有多个线程在等待,具体唤醒哪个等待的线程是随机的。notifyAll()
:不同于在共享变量上调用notify()
函数会唤醒被阻塞到该共享变量上的一个线程,notifyAll()
方法则会唤醒所有在该共享变量上由于调用 wait 系列方法而被挂起的线程。Thread类也提供了一个方法用于等待的方法:
join()
:如果一个线程A执行了thread.join()
语句,其含义是:当前线程A等待thread线程终止之后才从thread.join()
返回。线程休眠
sleep(long millis)
:Thread类中的静态方法,当一个执行中的线程A调用了Thread的sleep方法后,线程A会暂时让出指定时间的执行权,但是线程A所拥有的监视器资源,比如锁还是持有不让出的。指定的睡眠时间到了后该函数会正常返回,接着参与CPU的调度,获取到CPU资源后就可以继续运行。让出优先权
yield()
:Thread类中的静态方法,当一个线程调用 yield方法时,实际就是在暗示线程调度器当前线程请求让出自己的CPU,但是线程调度器可以无条件忽略这个暗示。线程中断
Java中的线程中断是一种线程间的协作模式,通过设置线程的中断标志并不能直接终止该线程的执行,而是被中断的线程根据中断状态自行处理。
void interrupt()
:中断线程,例如,当线程A运行时,线程B可以调用线程interrupt()
方法来设置线程的中断标志为true 并立即返回。设置标志仅仅是设置标志,线程A实际并没有被中断,会继续往下执行。boolean isInterrupted()
方法:检测当前线程是否被中断。boolean interrupted()
方法:检测当前线程是否被中断,与 isinterrupted 不同的是,该方法如果发现当前线程被中断,则会清除中断标志。在Java中,线程共有六种状态:
线程在自身的生命周期中,并不是固定地处于某个状态,而是随着代码的执行在不同的状态之间进行切换,Java线程状态变化如图示:
使用多线程的目的是为了充分利用CPU,但是我们知道,并发其实是一个CPU来应付多个线程。
为了让用户感觉多个线程是在同时执行的,CPU资源的分配采用了时间片轮转也就是给每个线程分配一个时间片,线程在时间片内占用CPU执行任务。当线程使用完时间片后,就会处于就绪状态并让出CPU让其他线程占用,这就是上下文切换。
Java中的线程分为两类,分别为daemon线程(守护线程)和user线程(用户线程)。
用户线程是虚拟机启动的线程中的普通线程,当所有用户线程结束运行后,虚拟机才会停止运行,即使还有一些守护线程在运行。
守护线程是在程序中创建的线程,它的作用是为其他线程提供服务。当所有的用户线程结束运行后,守护线程也会随之结束,而不管它是否执行完毕。守护线程通常用于执行一些辅助性任务,如垃圾回收、缓存清理等,它们不需要等待所有的任务完成后再退出。
它们之间的区别在于虚拟机在何时结束进程。
那么守护线程和用户线程有什么区别呢?
当最后一个非守护线程束时,JVM会正常退出,而不管当前是否存在守护线程,也就是说守护线程是否结束并不影响JVM退出。
换而言之,只要有一个用户线程还没结束,正常情况下JVM就不会退出。
volatile和synchronized关键字:
关键字volatile可以用来修饰字段(成员变量),就是告知程序任何对该变量的访问均需要从共享内存中获取,而对它的改变必须同步刷新回共享内存,它能保证所有线程对变量访问的可见性。
关键字synchronized可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性。
等待/通知机制:
可以通过Java内置的等待/通知机制(wait()/notify()
)实现一个线程修改一个对象的值,而另一个线程感知到了变化,然后进行相应的操作。
管道输入/输出流:
使用Thread.join():
Thread.join():join()的作用是“等待该进程终止”,也就是在子线程调用了join()方法后,主线程后面的代码要等到子线程结束了才能执行。一般应用于一个线程的输入可能依赖于另一个或者多个线程的输出,此时这个线程就需要等待依赖线程执行完毕才能继续执行。
如果一个线程A执行了thread.join()语句,其含义是:当前线程A等待thread线程终止之后才从thread.join()返回。
线程Thread除了提供join()方法之外,还提供了join(long millis)和join(long millis,int nanos)两个具备超时特性的方法。
使用ThreadLocal:
关于多线程,其实很大概率还会出一些笔试题,比如交替打印、银行转账、生产消费模型等等
ThreadLocal其实应用场景不是很多,但却是被炸了千百遍的面试老油条,涉及到多线程、数据结构、JVM,可问的点比较多,一定要拿下。
ThreadLocal,也就是线程本地变量。如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个本地拷贝,多个线程操作这个变量的时候,实际是操作自己本地内存里面的变量,从而起到线程隔离的作用,避免了线程安全问题。
//创建一个ThreadLocal变量
public static ThreadLocal<String> localVariable = new ThreadLocal<>();
localVariable.set("鄙人某某”);
localVariable.get();
有用到过的,用来做用户信息上下文的存储。
我们的系统应用是一个典型的MVC架构,登录后的用户每次访问接口,都会在请求头中携带一个token,在控制层可以根据这个token,解析出用户的基本信息。那么问题来了,假如在服务层和持久层都要用到用户信息,比如rpc调用、更新用户获取等等,那应该怎么办呢?
一种办法是显式定义用户相关的参数,比如账号、用户名….….这样一来,我们可能需要大面积地修改代码,多少有点瓜皮,那该怎么办呢?
这时候我们就可以用到ThreadLocal,在控制层拦截请求把用户信息存入ThreadLocal,这样我们在任何一个地方,都可以取出ThreadLocal中存的用户数据。
很多其它场景的cookie,session等等数据隔离也都可以通过ThreadLocal去实现。
我们常用的数据库连接池也用到了ThreadLocal:
我们看一下ThreadLocal的set(T) 方法,发现先获取到当前线程,再获取ThreadLocalMap
,然后把元素存到这个map中。
public void set(T value) {
//获取当前线程
Thread t = Thread.currentThread();
//获取ThreadLocalMap
ThreadLocalMap map = getMap(t);
//将当前元素存入map
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
ThreadLocal实现的秘密都在这个ThreadLocalMap
了,可以Thread类中定义了一个类型为ThreadLocal.ThreadLocalMap
的成员变量 threadLocals
。
public class Thread implements Runnable {
//ThreadLocal.ThreadLocalMap是Thread的属性
ThreadLocal.ThreadLocalMap threadLocals = null;
}
ThreadLocalMap既然被称为Map,那么毫无疑问它是
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
//节点类
Entry(ThreadLocal<?> k, Object v) {
//key赋值
super(k);
//value赋值
value = v;
}
}
这里的节点,key可以简单低视作ThreadLocal,value为代码中放入的值,当然实际上key并不是ThreadLocal本身,而是它的一个弱引用,可以看到Entry的key继承了WeakReference(弱引用),再来看一下key怎么赋值的:
public WeakReference(T referent) {
super(referent);
}
key的赋值,使用的是WeakReference的赋值。
所以,怎么回答ThreadLocal原理?要答出这几个点:
我们先来分析一下使用ThreadLocal时的内存,我们都知道,在JVM中,栈内存线程私有,存储了对象的引用,堆内存线程共享,存储了对象实例。
所以呢,栈中存储了ThreadLocal,Thread的引用,堆中存储了它们的具体实例。
ThreadLocalMap中使用的key为ThreadLocal的弱引用。
“弱引用:只要垃圾回收机制一运行,不管JVM的内存空间是否充足,都会回收该对象占用的内存。”
那么现在问题就来了,弱引用很容易被回收,如果ThreadLocal(ThreadLocalMap的Key) 被垃圾回收器回收了,但是ThreadLocalMap生命周期和Thread是一样的,它这时候如果不被回收,就会出现这种情况:ThreadLocalMap的key没了,value还在,这就会造成了内存泄漏问题。
那怎么解决内存泄漏问题呢?
很简单,使用完ThreadLocal后,及时调用remove()方法释放内存空间。
ThreadLocal<String> localVariable = new ThreadLocal();
try {
localVariable.set("鄙人某某”);
……
} finally {
localVariable.remove();
}
那为什么key还要设计成弱引用?
key设计成弱引用同样是为了防止内存泄漏。
假如key被设计成强引用,如果ThreadLocal Reference被销毁,此时它指向ThreadLocal的强引用就没有了,但是此时key还强引用指向ThreadLocal,就会导致ThreadLocal不能被回收,这时候就发生了内存泄漏的问题。
ThreadLocalMap虽然被叫做Map,其实它是没有实现Map接口的,但是结构还是和HashMap比较类似的,主要关注的是两个要素:元素数组 和 散列方法。
private Entry[] table;
int i = key.threadLocalHashCode & (table.length - 1);
这里的threadLocalHashCode计算有点东西,每创建一个ThreadLocal对象,它就会新增0x61c88647
,这个值很特殊,它是斐波那契数也叫黄金分割数。hash
增量为这个数字,带来的好处就是hash
分布非常均匀。
private static final int HASH_INCREMENT = 0x61c88647;
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
我们可能都知道HashMap使用了链表来解决冲突,也就是所谓的链地址法。
ThreadLocalMap没有使用链表,自然也不是用链地址法来解决冲突了,它用的是另外一种方式——开放定址法。
开放定址法是什么意思呢?简单来说,就是这个坑被人占了,那就接着去找空着的坑。
如上图所示,如果我们插入一个value=27的数据,通过hash计算后应该落入第4个槽位中,而槽位4已经有了Entry数据,而且Entry数据的key和当前不相等。此时就会线性向后查找,一直找到Entry为null的槽位才会停止查找,把元素放到空的槽中。
在get的时候,也会根据ThreadLocal对象的hash值,定位到table中的位置,然后判断该槽位Entry对象中的key是否和get的key一致,如果不一致,就判断下一个位置。
在ThreadLocalMap.set() 方法的最后,如果执行完启发式清理工作后,未清理到任何数据,且当前散列数组中Entry
的数量已经达到了列表的扩容阈值(len*2/3)
,就开始执行rehash()
逻辑:
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
再着看rehash()具体实现:这里会先去清理过期的Entry,然后还要根据条件判断size >= threshold - threshold/4
也就是 size >= threshold * 3/4
来决定是否需要扩容resize()
。
接着看看具体的resize()
方法,扩容后的newTab
的大小为老数组的两倍,然后遍历老的table数组,散列方法重新计算位置,开放地址解决冲突,然后放到新的newTab
,遍历完成之后,oldTab
中所有的entry
数据都已经放入到newTab
中了,然后table
引用指向newTab
。
父线程能用ThreadLocal来给子线程传值吗?毫无疑问,不能。那该怎么办?
这时候可以用到另外一个类——InheritableThreadLocal
。
使用起来很简单,在主线程的InheritableThreadLocal
实例设置值,在子线程中就可以拿到了。
public class InheritableThreadLocalTest {
public static void main(String[] args) {
final ThreadLocal threadLocal = new InheritableThreadLocal();
// 主线程
threadLocal.set("不擅技术");
//子线程
Thread t = new Thread() {
@Override
public void run() {
super.run();
System.out.println("鄙人某某 ," + threadLocal.get());
}
};
t.start();
}
}
那原理是什么呢?
原理很简单,在Thread类里还有另外一个变量
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
在Thread.init的时候,如果父线程的inheritableThreadLocals
不为空,就把它赋给当前线程(子线程)的inheritableThreadLocals
。
if (inheritThreadLocals && parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
Java内存模型(Java Memory Model,JMM),是一种抽象的模型,被定义出来屏蔽各种硬件和操作系统的内存访问差异。
JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在 主内存(Main Memory)中,每个线程都有一个私有的 本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。
本地内存是JMM的一个抽象概念,并不真实存在。它其实涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
图里面的是一个双核 CPU 系统架构,每个核有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器执行算术逻辅运算。
每个核都有自己的一级缓存,在有些架构里面还有一个所有CPU共享的二级缓存。那么Java内存模型里面的工作内存,就对应这里的 LI 缓存或者L2缓存或者CPU寄存器。
原子性、有序性、可见性是并发编程中非常重要的基础概念,JMM的很多技术都是围绕着这三大特性展开。
分析下面几行代码的原子性?
int i = 2;
int j = i;
i++;
i = i + 1;
原子性、可见性、有序性都应该怎么保证呢?
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型:
从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序,如图:
我们比较熟悉的双重校验单例模式就是一个经典的指令重排的例子,singleton instance=new Singleton();
对应的JVM指令分为三步:分配内存空间–>初始化对象—>对象指向分配的内存空间,但是经过了编译器的指令重排序,第二步和第三步就可能会重排序。
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
指令重排也是有一些限制的,有两个规则 happens-before
和 as-if-serial
来约束。
happens-before的定义:
happens-before和我们息息相关的有六大规则:
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例。
double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C
上面3个操作的数据依赖关系:
A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。
所以最终,程序可能会有两种执行顺序:
as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器共同编织了这么一个"楚门的世界":单线程程序是按程序的"顺序"来执行的。as-if-serial语义使单线程情况下,我们不需要担心重排序的问题,可见性的问题。
volatile有两个作用,保证可见性和有序性。
volatile怎么保证可见性的呢?
相比synchronized的加锁方式来解决共享变量的内存可见性问题,volatile就是更轻量的选择,它没有上下文切换的额外开销成本。
volatile可以确保对某个变量的更新对其他线程马上可见,一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存,当其它线程读取该共享变量,会从主内存重新获取最新值,而不是使用当前线程的本地内存中的值。
例如,我们声明一个volatile变量volatile int x= 0,线程A修改x=1,修改完之后就会把新的值刷新回主内存,线程B读取x的时候,就会清空本地内存变量,然后再从主内存获取最新值。
volatile怎么保证有序性的呢?
重排序可以分为编译器重排序和处理器重排序,valatile保证有序性,就是通过分别限制这两种类型的重排序。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
storestore
屏障storeLoad
屏障 LoadLoad
屏障LoadStore
屏障synchronized经常用的,用来保证代码的原子性。
synchronized主要有三种用法:
synchronized void method() {
//业务代码
}
修饰静态方法:也就是给当前类加锁,会作用于类的所有对象实例,进入同步代码前要获得当前class的锁。因为静态成员不属于任何一个实例对象,是类成员(static表明这是该类的一个静态资源,不管new了多少个对象,只有一份)。
如果一个线程A调用一个实例对象的非静态synchronized方法,而线程B需要调用这个实例对象所属类的静态synchronized方法,是允许的,不会发生互斥现象,因为访问静态synchronized方法占用的锁是当前类的锁,而访问非静态synchronized方法占用的锁是当前实例对象锁。
synchronized void staic method() {
//业务代码
}
synchronized(this) {
//业务代码
}
synchronized是怎么加锁的呢?
我们使用synchronized的时候,发现不用自己去lock和unlock,是因为JVM帮我们把这个事情做了。
synchronized修饰代码块时,JVM采用 monitorenter
、monitorexit
两个指令来实现同步,monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指向同步代码块的结束位置。
反编译一段synchronized修饰代码块代码,javap -c -s -v -l SynchronizedDemo.class
,可以看到相应的字节码指令。
synchronized修饰同步方法时,JVM采用ACC_SYNCHRONIZED
标记符来实现同步,这个标识指明了该方法是一个同步方法。
同样可以写段代码反编译看一下。
synchronized锁住的是什么呢?
monitorenter、monitorexit或者ACC_SYNCHRONIZED都是基于Monitor实现的。
实例对象结构里有对象头,对象头里面有一块结构叫Mark Word,Mark Word指针指向了monitor。
所谓的Monitor其实是一种同步工具,也可以说是一种同步机制。在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,可以叫做内部锁,或者Monitor锁。
ObjectMonitor的工作原理:
_owner
,获取Monitor对象的线程进入_owner
区时,_count +1
。如果线程调用了wait()
方法,此时会释放Monitor对象,_owner
恢复为空,_count-1
。同时该等待线程进入_WaitSet
中,等待被唤醒。ObjectMonitor() {
_header = NULL;
_count = 0; // 记录线程获取锁的次数
_waiters = 0,
_recursions = 0; //锁的重入次数
_object = NULL;
_owner = NULL; // 指向持有ObjectMonitor对象的线程
_WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
可以类比一个去医院就诊的例子:
这个过程就和Monitor机制比较相似:
所以我们就知道了,同步是锁住的什么东西:
synchronized怎么保证可见性?
synchronized怎么保证有序性?
synchronized同步的代码块,具有排他性,一次只能被一个线程拥有,所以synchronized保证同一时刻,代码是单线程执行的。
因为as-if-serial语义的存在,单线程的程序能保证最终结果是有序的,但是不保证不会指令重排。
所以synchronized保证的有序是执行结果的有序性,而不是防止指令重排的有序性。
synchronized怎么实现可重入的呢?
synchronized是可重入锁,也就是说,允许一个线程二次请求自己持有对象锁的临界资源,这种情况称为可重入锁。
synchronized锁对象的时候有个计数器,他会记录下线程获取锁的次数,在执行完对应的代码块之后,计数器就会-1,直到计数器清零,就释放锁了。
之所以,是可重入的。是因为synchronized锁对象有个计数器,会随着线程获取锁后+1计数,当线程执行完毕后-1,直到清零释放锁。
了解锁升级,得先知道,不同锁的状态是什么样的。这个状态指的是什么呢?
Java对象头里,有一块结构,叫Mark word
标记字段,这块结构会随着锁的状态变化而变化。
64位虚拟机Mark Word是 64bit,我们来看看它的状态变化:
Mark Word存储对象自身的运行数据,如**哈希码、GC分代年龄、锁状态标志、偏向时间戳(Epoch)**等。
synchronized做了哪些优化?
在JDK1.6之前,synchronized的实现直接调用ObjectMonitor的enter和exit,这种锁被称之为重量级锁。从JDK6开始,HotSpot虚拟机开发团队对Java中的锁进行优化,如增加了适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等优化策略,提升了synchronized的性能。
锁升级的过程是什么样的?
锁升级方向:无锁–>偏向锁—>轻量级锁---->重量级锁,这个方向基本上是不可逆的。
偏向锁的获取:
偏向锁的撤销:
轻量级锁的获取:
可以从锁的实现、功能特点、性能等几个维度去回答这个问题:
lock()
和unlock()
方法配合try/finally
语句块来完成。)lock.lockInterruptibly()
来实现这个机制下面的表格列出出了两种锁之间的区别:
区别 | sychronized | ReetrantLock |
---|---|---|
锁实现机制 | 对象头监视器模式 | 依赖AQS |
灵活性 | 不灵活 | 支持响应中断、超时、尝试获取锁 |
释放锁形式 | 自动释放锁 | 显示调用unlock() |
支持锁类型 | 非公平锁 | 公平锁&非公平锁 |
条件队列 | 单条件队列 | 多个条件队列 |
可重入支持 | 支持 | 支持 |
AbstractQueuedSynchronizer 抽象同步队列,简称AQS,它是Java并发包的根基,并发包中的锁就是基于AQS实现的。
AQS 中的队列是 CLH变体的虚拟双向队列,通过将每条请求共享资源的线程封装成一个节点来实现锁的分配:
AQS 中的 CLH 变体等待队列拥有以下特性:
ReentrantLock是可重入的独占锁,只能有一个线程可以获取该锁,其它获取该锁的线程会被阻塞而被放入该锁的阻塞队列里面。
看看ReentrantLock的加锁操作:
// 创建非公平锁
ReentrantLock lock = new ReentrantLock();
// 获取锁操作
lock.lock();
try {
// 执行代码逻辑
} catch (Exception ex) {
// ...
} finally {
// 解锁操作
lock.unlock();
}
new ReentrantLock()
构造函数默认创建的是非公平锁 NonfairSync。
公平锁 FairSync
非公平锁NonfairSync
默认创建的对象lock()时候:
new ReentrantLock()
构造函数默认创建的是非公平锁NonfairSynd
public ReentrantLock() {
sync = new NonfairSync();
}
同时也可以在创建锁构造函数中传入具体参数创建公平锁 FairSync
ReentrantLock lock = new ReentrantLock(true);
--- ReentrantLock
// true 代表公平锁,false 代表非公平锁
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
FairSync、NonfairSync 代表公平锁和非公平锁,两者都是 ReentrantLock 静态内部类,只不过实现不同锁语义。
非公平锁和公平锁的两处不同:
相对来说,非公平锁会有更好的性能,因为它的吞吐量比较大。当然,非公平锁让获取锁的时间变得更加不确定,可能会导致在阻塞队列中的线程长期处于饥饿状态。
CAS叫做CompareAndSwap,比较并交换,主要是通过处理器的指令来保证操作的原子性的。
CAS 指令包含3个参数:共享变量的内存地址A、预期的值B和共享变量的新值C。
只有当内存中地址A处的值等于B时,才能将内存中地址A处的值更新为新值C。作为一条CPU指令,CAS指令本身是能够保证原子性的。
ABA问题
并发环境下,假设初始条件是A,去修改数据时,发现是A就会执行修改。但是看到的虽然是A,中间可能发生了A变B,B又变回A的情况。此时A已经非彼A,数据即使成功修改,也可能有问题。
怎么解决ABA问题?
每次修改变量,都在这个变量的版本号上加1,这样,刚刚A->B->A,虽然A的值没变,但是它的版本号已经变了,再判断版本号就会发现此时的A已经被改过了。参考乐观锁的版本号,这种做法可以给数据带上了一种实效性的检验。
Java提供了AtomicStampReference类,它的compareAndSet方法首先检查当前的对象引用值是否等于预期引用,并且当前印戳(Stamp)标志是否等于预期标志,如果全部相等,则以原子方式将引用值和印戳标志的值更新为给定的更新值。
循环性能开销
自旋CAS,如果一直循环执行,一直不成功,会给CPU带来非常大的执行开销。
怎么解决循环性能开销问题?
在Java中,很多使用自旋CAS的地方,会有一个自旋次数的限制,超过一定次数,就停止自旋。
只能保证一个变量的原子操作
CAS保证的是对一个变量执行操作的原子性,如果对多个变量操作时,CAS目前无法直接保证操作的原子性的。
怎么解决只能保证一个变量的原子操作问题?
当程序更新一个变量时,如果多线程同时更新这个变量,可能得到期望之外的值,比如变量i=1,A线程更新i计1,B线程也更新i+1,经过两个线程操作之后可能i不等于3,而是等于2。因为A和B线程在更新变量i的时候拿到的都是1,这就是线程不安全的更新操作,一般我们会使用synchronized来解决这个问题,synchronized会保证多线程不会同时更新变量i。
其实除此之外,还有更轻量级的选择,Java从WDK 1.5开始提供了java.util.concurrent.atomic包,这个包中的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。
因为变量的类型有很多种,所以在Atomic包里一共提供了13个类,属于4种类型的原子更新方式,分别是原子更新基本类型、原子更新数组、原子更新引用和原子更新属性(字段)。
Atomic包里的类基本都是使用Unsafe实现的包装类。
使用原子的方式更新基本类型,Atomic包提供了以下3个类:
通过原子的方式更新数组里的某个元素,Atomic包提供了以下4个类:
原子更新基本类型的Atomiclnteger,只能更新一个变量,如果要原子更新多个变量,就需要使用这个原子更新引用类型提供的类。Atomic包提供了以下3个类:
如果需原子地更新某个类里的某个字段时,就需要使用原子更新字段类,Atomic包提供了以下3个类进行原子字段更新:
一句话概括:使用CAS实现。
以Atomiclnteger的添加方法为例:
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
通过Unsafe
类的实例来进行添加操作,来看看具体的CAS操作:
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
compareAndSwaplnt是一个native方法,基于CAS来操作int类型变量。其它的原子操作类基本都是大同小异。
死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去。
那么为什么会产生死锁呢?死锁的产生必须具备以下四个条件:
该如何避免死锁呢?答案是至少破坏死锁发生的一个条件。
可以使用jdk自带的命令行工具排查:
jps -l
jstack -l 进程id
基本就可以看到死锁的信息。
还可以利用图形化工具,比如如Console。出现线程死锁以后,点击Console线程面板的检测到死锁
按钮,将会看到线程的死锁信息。
CountDownLatch,倒计数器,有两个常见的应用场景:
场景1:协调子线程结束动作:等待所有子线程运行结束
CountDownLatch允许一个或多个线程等待其他线程完成操作。
例如,我们很多人喜欢玩的王者荣耀,开黑的时候,得等所有人都上线之后,才能开打。
CountDownLatch模仿这个场景:
创建大乔、兰陵王、安其拉、哪吒和铠等五个玩家,主线程必须在他们都完成确认后,才可以继续运行。
在这段代码中,new CountDownLatch(5)
用户创建初始的latch数量,各玩家通过countDownLatch.countDown()
完成状态确认,主线程通过countpownLatch.await()
等待。
场景2.协调子线程开始动作:统一各线程动作开始的时机
王者游戏中也有类似的场景,游戏开始时,各玩家的初始状态必须一致。不能有的玩家都出完装了,有的才降生。
所以大家得一块出生,在这个场景中,仍然用五个线程代表大乔、兰陵王、安其拉、哪吒和铠等五个玩家。需要注意的是,各玩家虽然都调用了start()线程,但是它们在运行时都在等待countpownLatch的信号,在信号未收到前,它们不会往下执行。
CountDownLatch的核心方法也不多:
CyclicBarrier的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续运行。
它和CountDownLatch类似,都可以协调多线程的结束动作,在它们结束后都可以执行特定动作,但是为什么要有CyclicBarrier,自然是它有和CountDownLatch不同的地方。
我们拿代码模拟这一场景,发现CountDownLatch无能为力了,因为CountDownLatch的使用是一次性的,无法重复利用,而这里等待了两次。此时,我们用CyclicBarrier就可以实现,因为它可以重复利用。
运行结果:
CyclicBarrier最最核心的方法,仍然是await():
上面的例子抽象一下,本质上它的流程就是这样就是这样:
两者最核心的区别:
它们区别用一个表格整理:
Semaphore(信号量)是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源。
听起来似乎很抽象,现在汽车多了,开车出门在外的一个老大难问题就是停车。停车场的车位是有限的,只能允许若干车辆停泊,如果停车场还有空位,那么显示牌显示的就是绿灯和剩余的车位,车辆就可以驶入;如果停车场没位了,那么显示牌显示的就是红灯和数字0,车辆就得等待。如果满了的停车场有车离开,那么显示牌就又变绿,显示空车位数量,等待的车辆就能进停车场。
我们把这个例子类比一下,车辆就是线程,进入停车场就是线程在执行,离开停车场就是线程执行完毕,看见红灯就表示线程被阻塞,不能执行,Semaphore的本质就是协调多个线程对共享资源的获取。
我们再来看一个Semaphore的用途:它可以用于做流量控制,特别是公用资源有限的应用场景,比如数据库连接。
假如有一个需求,要读取几万个文件的数据,因为都是IO密集型任务,我们可以启动几十个线程并发地读取,但是如果读到内存后,还需要存储到数据库中,而数据库的连接数只有10个,这时我们必须控制只有10个线程同时获取数据库连接保存数据,否则会报错无法获取数据库连接。这个时候,就可以使用Semaphore来做流量控制,如下:
public class SemaphoreTest {
private static final int THREAD_COUNT = 30;
private static ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_COUNT);
private static Semaphore s = new Semaphore(10);
public static void main(String[] args) {
for (int i = 0; i < THREAD_COUNT; i++) {
threadPool.execute(new Runnable() {
@Override
public void run() {
try {
s.acquire();
System.out.println("save data");
s.release();
} catch (InterruptedException e) {
}
}
});
}
threadPool.shutdown();
}
}
在代码中,虽然有30个线程在执行,但是只允许10个并发执行。Semaphore的构造方法 semaphore(int permits)
接受一个整型的数字,表示可用的许可证数量。Semaphore(10)
表示允许10个线程获取许可证,也就是最大并发数是10,Semaphore的用法也很简单,首先线程使用Semaphore的acquire()
方法获取一个许可证,使用完之后调用release()
方法归还许可证。还可以用tryAcquire()
方法尝试获取许可证。
Exchanger(交换者)是一个用于线程间协作的工具类。Exchanger用于进行线程间的数据交换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。
这两个线程通过exchange方法交换数据,如果第一个线程先执行exchange()方法,它会一直等待第二个线程也执行exchange方法,当两个线程都到达同步点时,这两个线程就可以交换数据,将本线程生产出来的数据传递给对方。
Exchanger可以用于遗传算法,遗传算法里需要选出两个人作为交配对象,这时候会交换两人的数据,并使用交叉规则得出2个交配结果。Exchanger也可以用于校对工作,比如我们需要将纸制银行流水通过人工的方式录入成电子银行流水,为了避免错误,采用AB岗两人进行录入,录入到Excel之后,系统需要加载这两个Excel,并对两个Excel数据进行校对,看看是否录入一致。
public class ExchangerTest {
private static final Exchanger<String> exgr = new Exchanger<String>();
private static ExecutorService threadPool = Executors.newFixedThreadPool(2);
public static void main(String[] args) {
threadPool.execute(new Runnable() {
@Override
public void run() {
try {
String A = "银行流水A"; // A录入银行流水数据
exgr.exchange(A);
} catch (InterruptedException e) {
}
}
});
threadPool.execute(new Runnable() {
@Override
public void run() {
try {
String B = "银行流水B"; // B录入银行流水数据
String A = exgr.exchange(B);
System.out.println("A和B数据是否一致:" + A.equals(B) + ",A录入的是:"
+ A + ",B录入是:" + B);
} catch (InterruptedException e) {
}
}
});
threadPool.shutdown();
}
}
假如两个线程有一个没有执行exchange()方法,则会一直等待,如果担心有特殊情况发生,避免一直等待,
可以使用exchange(V x, long timeOut, TimeUnit unit)
设置最大等待时长。
线程池:简单理解,它就是一个管理线程的池子。
之前我们有一个和第三方对接的需求,需要向第三方推送数据,引入了多线程来提升数据推送的效率,其中用到了线程池来管理线程。
完整可运行代码地址:https://gitee.com/fighter3/thread-demo.git
线程池的参数如下:
synchronized (PushProcessServiceImpl.class) {}
ps:这个例子只是简单地进行了数据推送,实际上还可以结合其他的业务,像什么数据清洗啊、数据统计啊,都可以套用。
用一个通俗的比喻:
有一个营业厅,总共有六个窗口,现在开放了三个窗口,现在有三个窗口坐着三个营业员小姐姐在营业。
我去办业务,可能会遇到什么情况呢?
- 我们银行系统已经瘫痪
- 谁叫你来办的你找谁去
- 看你比较急,去队里加个塞
- 今天没办法,不行你看改一天
上面的这个流程几乎就跟JDK 线程池的大致流程类似,
- 营业中的 3个窗口对应核心线程池数:corePoolSize
- 总的营业窗口数6对应:maximumPoolSize
- 打开的临时窗口在多少时间内无人办理则关闭对应:unit
- 排队区就是等待队列:workQueue
- 无法办理的时候银行给出的解决方法对应:RejectedExecutionHandler
- threadFactory该参数在JDK 中是 线程工厂,用来创建线程对象,一般不会动。
所以我们线程池的工作流程也比较好理解了:
线程池有七大参数,需要重点关注corePoolSize
、maximumPoolSize
、workQueue
、handler
这四个。
此值是用来初始化线程池中核心线程数,当线程池中线程池数 < corePoolSize
时,系统默认是添加一个任务才创建一个线程池。
当线程数 = corePoolSize
时,新任务会追加到workQueue中。
maximumPoolSize
表示允许的最大线程数等于(非核心线程数+核心线程数),当BlockingQueue
也满了,但线程池中总线程数 < maximumPoolSize时候就会再次创建新的线程。
非核心线程 =(maximumPoolSize - corePoolSize),非核心线程闲置下来不干活最多存活时间。
线程池中非核心线程保持存活的时间的单位
线程池等待队列,维护着等待执行的Runnable
对象。当运行当线程数 = corePoolSize时,新的任务会被添加到workQueue
中,如果workQueue
也满了则尝试用非核心线程执行任务,等待队列应该尽量用有界的。
创建一个新线程时使用的工厂,可以用来设定线程名、是否为daemon线程等等。
corePoolSize
、workQueue
、maximumPoolSize
都不可用的时候执行的饱和策略。
类比前面的例子,无法办理业务时的处理方式,帮助记忆:
想实现自己的拒绝策略,实现RejectedExecutionHandler
接口即可。
常用的阻塞队列主要有以下几种:
threadsPool.execute(new Runnable() {
@Override public void run() {
// TODO Auto-generated method stub }
});
Future<Object> future = executor.submit(harReturnValuetask);
try { Object s = future.get(); } catch (InterruptedException e) {
// 处理理中断异常
} catch (ExecutionException e) {
// 处理理⽆无法执⾏行行任务异常
} finally {
// 关闭线程池 executor.shutdown();
}
可以通过调用线程池的shutdown
或shutdownNow
方法来关闭线程池。它们的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务可能永远无法终止。
shutdown() 将线程池状态置为shutdown,并不会立即停止:
shutdownNow()将线程池状态置为stop。一般会立即停止,事实上不一定:
shutdown和shutdownnow简单来说区别如下:
线程在Java中属于稀缺资源,线程池不是越大越好也不是越小越好。任务分为计算密集型、IO密集型、混合型。
一般的经验,不同类型线程池的参数配置:
Runtime.getRuntime().availableProcessors();
面试常问,主要有四种,都是通过工具类Excutors创建出来的,需要注意,阿里巴巴《Java开发手册》里禁止使用这种方式来创建线程池。
前三种线程池的构造直接调用ThreadPoolExecutor的构造方法。
public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(),
threadFactory));
}
线程池特点
工作流程:
适用场景:
适用于串行执行任务的场景,一个任务一个任务地执行。
public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(),
threadFactory);
}
线程池特点:
工作流程:
使用场景:
FixedThreadPool适用于处理CPU密集型的任务,确保CPU在长期被工作线程使用的情况下,尽可能的少的分配线程,即适用执行长期的任务。
public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>(),
threadFactory);
}
线程池特点:
当提交任务的速度大于处理任务的速度时,每次提交一个任务,就必然会创建一个线程。极端情况下会创建过多的线程,耗尽CPU和内存资源。由于空闲60秒的线程会被终止,长时间保持空闲的CachedThreadPool不会占用任何资源。
工作流程:
适用场景:
用于并发执行大量短期的小任务。
public ScheduledThreadPoolExecutor(int corePoolSize) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue());
}
线程池特点
最大线程数为Integer.MAX_VALUE,也有OOM的风险
阻塞队列是DelayedWorkQueue
keepAliveTime为0
scheduleAtFixedRate():按某种速率周期执行
scheduleWithFixedDelay():在某个延迟后执行
工作机制
使用场景
周期性执行任务的场景,需要限制线程数量的场景
使用无界队列的线程池会导致什么问题吗?
例如newFixedThreadPool使用了无界的阻塞队列LinkedBlockingQueue,如果线程获取一个任务后,任务的执行时间比较长,会导致队列的任务越积越多,导致机器内存使用不停飙升,最终导致OOM。
在使用线程池处理任务的时候,任务代码可能抛出RuntimeException,抛出异常后,线程池可能捕获它,也可能创建一个新的线程来代替异常的线程,我们可能无法感知任务出现了异常,因此我们需要考虑线程池异常情况。
常见的异常处理方式:
线程池有这几个状态:RUNNING,SHUTDOWN,STOP,TIDYING,TERMINATED。
//线程池状态
private static final int RUNNING = -1 << COUNT_BITS;
private static final int SHUTDOWN = 0 << COUNT_BITS;
private static final int STOP = 1 << COUNT_BITS;
private static final int TIDYING = 2 << COUNT_BITS;
private static final int TERMINATED = 3 << COUNT_BITS;
RUNNING
SHUTDOWN
STOP
TIDYING
TERMINATED
线程池提供了几个setter方法来设置线程池的参数。
这里主要有两个思路:
线程池配置没有固定的公式,通常事前会对线程池进行一定评估,常见的评估方案如下:
上线之前也要进行充分的测试,上线之后要建立完善的线程池监控机制。
事中结合监控告警机制,分析线程池的问题,或者可优化点,结合线程池动态参数配置机制来调整配置。
事后要注意仔细观察,随时调整。
这道题在阿里的面试中出现频率比较高
线程池实现原理可以查看要是以前有人这么讲线程池,我早就该明白了!,当然,我们自己实现,只需要抓住线程池的核心流程:
我们自己的实现就是完成这个核心流程:
我们可以对正在处理和阻塞队列的任务做事务管理或者对阻塞队列中的任务持久化处理,并且当断电或者系统崩溃,操作无法继续下去的时候,可以通过回溯日志的方式来撤销正在处理的已经执行成功的操作。然后重新执行整个阻塞队列。
也就是说,对阻塞队列持久化正在处理任务事务控制;断电之后正在处理任务的回滚,通过日志恢复该次操作;服务器重启后阻塞队列中的数据再加载。
Fork/Join框架是Java7提供的一个用于并行执行任务的框架,是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。
要想掌握Fork/Join框架,首先需要理解两个点,分而治之和工作窃取算法。
工作窃取算法
大任务拆成了若干个小任务,把这些小任务放到不同的队列里,各自创建单独线程来执行队列里的任务。
那么问题来了,有的线程干活快,有的线程干活慢。干完活的线程不能让它空下来,得让它去帮没干完活的线程干活。它去其它线程的队列里窃取一个任务来执行,这就是所谓的工作窃取。
工作窃取发生的时候,它们会访问同一个队列,为了减少窃取任务线程和被窃取任务线程之间的竞争,通常任务会使用双端队列,被窃取任务线程永远从双端队列的头部拿,而窃取任务的线程永远从双端队列的尾部拿任务执行。
ForkjoinTask与一般Task的主要区别在于它需要实现compute方法,在这个方法里,首先需要判断任务是否足够小,如果足够小就直接执行任务。如果比较大,就必须分割成两个子任务,每个子任务在调用fork方法时,又会进compute方法,看看当前子任务是否需要继续分割成子任务,如果不需要继续分割,则执行当前子任务并返回结果。使用join方法会等待子任务执行完并得到其结果。
资料来源地址:面渣逆袭:Java并发六十问,图文详解,快来看看你会多少道!