基础
并行跟并发有什么区别?
从操作系统的角度来看,线程是CPU分配的最小单位。
并行就是同一时刻,两个线程都在执行。这就要求有两个CPU去分别执行两个线程。
并发就是同一时刻,只有一个执行,但是一个时间段内,两个线程都执行了。并发的实现依赖于CPU切换线程,因为切换的时间特别短,所以基本对于用户是无感知的。
就好像我们去食堂打饭,并行就是我们在多个窗口排队,几个阿姨同时打菜;并发就是我们挤在一个窗口,阿姨给这个打一勺,又手忙脚乱地给那个打一勺。
同步和异步的区别
在同步操作中,任务必须等待另一个任务完成后才能继续执行。这意味着任务是按顺序执行的,每个任务都必须等待前一个任务完成后才能开始。同步操作通常涉及锁的使用,用于确保同时只有一个任务可以访问共享资源。
相比之下,在异步操作中,任务不必等待另一个任务完成就可以继续执行。这意味着任务可以并发执行,而不必等待前一个任务完成。异步操作通常涉及回调函数的使用,用于在任务完成时通知调用方。
通常情况下,同步操作适用于简单的任务,例如读取文件或计算简单的数学问题,因为这些任务很快就能完成,并且很容易控制执行顺序。异步操作适用于复杂的任务,例如网络通信或图形渲染,因为这些任务可能需要等待外部事件的发生,而无法预测执行时间。
什么是线程和进程?线程和进程有什么区别?
进程是系统进行资源分配和调度的基本单位,是一个程序在执行时占用的内存空间和系统资源的总称,每个进程都有独立的地址空间,不同进程之间不能直接访问对方的内存空间。
线程是操作系统能够进行运算调度的最小单位,是进程的一部分,一个进程中可以包含多个线程。线程之间共享进程的资源,包括内存空间、文件描述符和系统信号等,同一进程中的不同线程之间共享内存,可以方便地实现数据共享和通信。
说说线程有几种创建方式?
Java中创建线程主要有三种方式,分别为继承Thread类、实现Runnable接口、实现Callable接口。
继承Thread类,重写run()方法,调用start()方法启动线程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public class ThreadTest {
/**
* 继承Thread类
*/
public static class MyThread extends Thread {
public void run() {
System.out.println("This is child thread");
}
}
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start();
}
}实现 Runnable 接口,重写run()方法
1
2
3
4
5
6
7
8
9
10public 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();
}
}实现Callable接口,重写call()方法,这种方式可以通过FutureTask获取任务执行的返回值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20public class CallerTask implements Callable<String> {
public String call() {
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 | ExecutionException e) {
e.printStackTrace();
}
}
}
为什么调用**start()方法时会执行run()方法,那怎么不直接调用run()**方法?
JVM执行start方法,会先创建一条线程,由创建出来的新线程去执行thread的run方法,这才起到多线程的效果。
为什么我们不能直接调用**run()**方法?也很清楚, 如果直接调用Thread的run()方法,那么run方法还是运行在主线程中,相当于顺序执行,就起不到多线程的效果。
说说线程的生命周期和状态?
Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:
- NEW: 初始状态,线程被创建出来但没有被调用
start()
。 - RUNNABLE: 运行状态,线程被调用了
start()
等待运行的状态。 - BLOCKED :阻塞状态,需要等待锁释放。
- WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
- TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
- TERMINATED:终止状态,表示该线程已经运行完毕。
线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。
线程有哪些常用的调度方法?
线程等待与通知
在Object类中有一些函数可以用于线程的等待与通知。
wait():当一个线程A调用一个共享变量的 wait()方法时, 线程A会被阻塞挂起,发生下面几种情况才会返回 :
(1) 线程A调用了共享对象 notify()或者 notifyAll()方法;
(2)其他线程调用了线程A的 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 不同的是,该方法如果发现当前线程被中断,则会清除中断标志。
什么是线程上下文切换?如何避免线程上下文切换?
使用多线程的目的是为了充分利用CPU,但是我们知道,并发其实是一个CPU来应付多个线程。
为了让用户感觉多个线程是在同时执行的, CPU 资源的分配采用了时间片轮转也就是给每个线程分配一个时间片,线程在时间片内占用 CPU 执行任务。当线程使用完时间片后,就会处于就绪状态并让出 CPU 让其他线程占用,这就是上下文切换。
可以通过以下几种方式来避免线程上下文切换:
- 减少线程的数量。线程数量过多会增加线程上下文切换的开销,因此可以采用线程池等方式来管理线程,避免创建过多的线程。
- 使用非阻塞的IO操作。阻塞的IO操作会使线程进入等待状态,从而引起线程上下文切换,因此可以使用非阻塞的IO操作来避免。
- 使用CAS操作。CAS操作可以实现乐观锁,避免线程阻塞和上下文切换。
守护线程了解吗
Java中的线程分为两类,分别为 daemon 线程(守护线程)和 user 线程(用户线程)。
在JVM 启动时会调用 main 函数,main函数所在的钱程就是一个用户线程。其实在JVM 内部同时还启动了很多守护线程, 比如垃圾回收线程。
那么守护线程和用户线程有什么区别呢?区别之一是当最后一个非守护线程束时,JVM会正常退出,而不管当前是否存在守护线程,也就是说守护线程是否结束并不影响 JVM退出。换而言之,只要有一个用户线程还没结束,正常情况下JVM就不会退出。
线程间有哪些通信方式?
volatile和synchronized关键字
关键字volatile可以用来修饰字段(成员变量),就是告知程序任何对该变量的访问均需要从共享内存中获取,而对它的改变必须同步刷新回共享内存,它能保证所有线程对变量访问的可见性。
关键字synchronized可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性。
等待/通知机制
可以通过Java内置的等待/通知机制(wait()/notify())实现一个线程修改一个对象的值,而另一个线程感知到了变化,然后进行相应的操作。
管道输入/输出流
管道输入/输出流和普通的文件输入/输出流或者网络输入/输出流不同之处在于,它主要用于线程之间的数据传输,而传输的媒介为内存。
管道输入/输出流主要包括了如下4种具体实现:PipedOutputStream、PipedInputStream、 PipedReader和PipedWriter,前两种面向字节,而后两种面向字符。
使用Thread.join()
如果一个线程A执行了thread.join()语句,其含义是:当前线程A等待thread线程终止之后才从thread.join()返回。线程Thread除了提供join()方法之外,还提供了join(long millis)和join(long millis,int nanos)两个具备超时特性的方法。
使用ThreadLocal
ThreadLocal,即线程变量,是一个以ThreadLocal对象为键、任意对象为值的存储结构。这个结构被附带在线程上,也就是说一个线程可以根据一个ThreadLocal对象查询到绑定在这个线程上的一个值。
可以通过set(T)方法来设置一个值,在当前线程下再通过get()方法获取到原先设置的值。
ThreadLocal
ThreadLocal是什么
ThreadLocal,也就是线程本地变量。如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个本地拷贝,多个线程操作这个变量的时候,实际是操作自己本地内存里面的变量,从而起到线程隔离的作用,避免了线程安全问题。
你在工作中用到过ThreadLocal吗?
有用到过的,用来做用户信息上下文的存储。
我们的系统应用是一个典型的MVC架构,登录后的用户每次访问接口,都会在请求头中携带一个token,在控制层可以根据这个token,解析出用户的基本信息。那么问题来了,假如在服务层和持久层都要用到用户信息,比如rpc调用、更新用户获取等等,那应该怎么办呢?
一种办法是显式定义用户相关的参数,比如账号、用户名……这样一来,我们可能需要大面积地修改代码,多少有点瓜皮,那该怎么办呢?
这时候我们就可以用到ThreadLocal,在控制层拦截请求把用户信息存入ThreadLocal,这样我们在任何一个地方,都可以取出ThreadLocal中存的用户数据。
很多其它场景的cookie、session等等数据隔离也都可以通过ThreadLocal去实现。
我们常用的数据库连接池也用到了ThreadLocal:
- 数据库连接池的连接交给ThreadLoca进行管理,保证当前线程的操作都是同一个Connnection。
ThreadLocal怎么实现的呢?
我们看一下ThreadLocal的set(T)方法,发现先获取到当前线程,再获取ThreadLocalMap ,然后把元素存到这个map中。
1 | public void set(T value) { |
ThreadLocal实现的秘密都在这个 ThreadLocalMap 了,可以Thread类中定义了一个类型为 ThreadLocal.ThreadLocalMap 的成员变量 threadLocals 。
1 | ThreadLocal.ThreadLocalMap threadLocals = null; |
ThreadLocalMap既然被称为Map,那么毫无疑问它是<key,value>型的数据结构。我们都知道map的本质是一个个<key,value>形式的节点组成的数组,那ThreadLocalMap的节点是什么样的呢?
1 | static class Entry extends WeakReference<ThreadLocal<?>> { |
这里的节点,key可以简单低视作ThreadLocal,value为代码中放入的值,当然实际上key并不是ThreadLocal本身,而是它的一个弱引用,可以看到Entry的key继承了WeakReference(弱引用),再来看一下key怎么赋值的:
1 | public WeakReference(T referent) { |
key的赋值,使用的是WeakReference的赋值。
所以,怎么回答ThreadLocal原理?要答出这几个点:
Thread类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals,每个线程都有一个属于自己的ThreadLocalMap。
ThreadLocalMap内部维护着Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal的弱引用,value是ThreadLocal的泛型值。
每个线程在往ThreadLocal里设置值的时候,都是往自己的ThreadLocalMap里存,读也是以某个ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离。
ThreadLocal本身不存储值,它只是作为一个key来让线程往ThreadLocalMap里存取值。
ThreadLocal 内存泄露是怎么回事?
我们先来分析一下使用ThreadLocal时的内存,我们都知道,在JVM中,栈内存线程私有,存储了对象的引用,堆内存线程共享,存储了对象实例。
所以呢,栈中存储了ThreadLocal、Thread的引用,堆中存储了它们的具体实例。
ThreadLocalMap中使用的 key 为 ThreadLocal 的弱引用。
“弱引用:只要垃圾回收机制一运行,不管JVM的内存空间是否充足,都会回收该对象占用的内存。”
那么现在问题就来了,弱引用很容易被回收,如果ThreadLocal(ThreadLocalMap的Key)被垃圾回收器回收了,但是ThreadLocalMap生命周期和Thread是一样的,它这时候如果不被回收,就会出现这种情况:ThreadLocalMap的key没了,value还在,这就会造成了内存泄漏问题。
那怎么解决内存泄漏问题呢?
很简单,使用完ThreadLocal后,及时调用remove()方法释放内存空间。
1 | try { |
那为什么key还要设计成弱引用?
key设计成弱引用同样是为了防止内存泄漏。
假如key被设计成强引用,如果ThreadLocal Reference被销毁,此时它指向ThreadLoca的强引用就没有了,但是此时key还强引用指向ThreadLoca,就会导致ThreadLocal不能被回收,这时候就发生了内存泄漏的问题。
ThreadLocalMap的结构了解吗?
ThreadLocalMap虽然被叫做Map,其实它是没有实现Map接口的,但是结构还是和HashMap比较类似的,主要关注的是两个要素: 元素数组 和 散列方法 。
元素数组
一个table数组,存储Entry类型的元素,Entry是ThreaLocal弱引用作为key,Object作为value的结构。
1
private Entry[] table;
散列方法
散列方法就是怎么把对应的key映射到table数组的相应下标,ThreadLocalMap用的是哈希取余法,取出key的threadLocalHashCode,然后和table数组长度减一&运算(相当于取余)。
1
int i = key.threadLocalHashCode & (table.length - 1);
这里的threadLocalHashCode计算有点东西,每创建一个ThreadLocal对象,它就会新增 0x61c88647 ,这个值很特殊,它是斐波那契数 也叫 黄金分割数。 hash 增量为 这个数字,带来的好处就是 hash 分布非常均匀。
1
2
3
4
5
6
7
8private static final int HASH_INCREMENT = 0x61c88647;
/**
* Returns the next hash code.
*/
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
ThreadLocalMap怎么解决Hash冲突的?
我们可能都知道HashMap使用了链表来解决冲突,也就是所谓的链地址法。ThreadLocalMap没有使用链表,自然也不是用链地址法来解决冲突了,它用的是另外一种方式——开放定址法。开放定址法是什么意思呢?简单来说,就是这个坑被人占了,那就接着去找空着的坑。
如上图所示,如果我们插入一个value=27的数据,通过 hash计算后应该落入第 4 个槽位中,而槽位 4 已经有了 Entry数据,而且Entry数据的key和当前不相等。此时就会线性向后查找,一直找到 Entry为 null的槽位才会停止查找,把元素放到空的槽中。
在get的时候,也会根据ThreadLocal对象的hash值,定位到table中的位置,然后判断该槽位Entry对象中的key是否和get的key一致,如果不一致,就判断下一个位置。
ThreadLocalMap扩容机制了解吗?
在ThreadLocalMap.set()方法的最后,如果执行完启发式清理工作后,未清理到任何数据,且当前散列数组中 Entry 的数量已经达到了列表的扩容阈值 (len*2/3) ,就开始执行 rehash() 逻辑:
1 | if (!cleanSomeSlots(i, sz) && sz >= threshold) |
再着看rehash()具体实现:这里会先去清理过期的Entry,然后还要根据条件判断size >= threshold - threshold / 4 也就是 size >= threshold* 3/4 来决定是否需要扩容。
1 | /** |
接着看看具体的 resize() 方法,扩容后的 newTab 的大小为老数组的两倍,然后遍历老的table数组,散列方法重新计算位置,开放地址解决冲突,然后放到新的newTab ,遍历完成之后, oldTab 中所有的 entry 数据都已经放入到 newTab 中了,然后table引用指向 newTab
1 | /** |
父子线程怎么共享数据?
父线程能用ThreadLocal来给子线程传值吗?毫无疑问,不能。那该怎么办?
这时候可以用到另外一个类—— InheritableThreadLocal 。
使用起来很简单,在主线程的InheritableThreadLocal实例设置值,在子线程中就可以拿到了。
1 | package org.thread; |
那原理是什么呢?
原理很简单,在Thread类里还有另外一个变量:
1 | ThreadLocal.ThreadLocalMap inheritableThreadLocals = null; |
在Thread.init的时候,如果父线程的 inheritableThreadLocals 不为空,就把它赋给当前线程(子线程)的 inheritableThreadLocals 。
1 | if (inheritThreadLocals && parent.inheritableThreadLocals != null) |
Java内存模型
说一下你对Java内存模型(JMM)的理解?
Java内存模型(Java Memory Model,JMM),是一种抽象的模型,被定义出来屏蔽各种硬件和操作系统的内存访问差异。
JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在 主内存(Main Memory)中,每个线程都有一个私有的 本地内存 (Local Memory),本地内存中存储了该线程以读/写共享变量的副本。
图里面的是一个双核 CPU 系统架构 ,每个核有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器执行算术逻辅运算。每个核都有自己的一级缓存,在有些架构里面还有一个所有 CPU 共享的二级缓存。 那么 Java 内存模型里面的工作内存,就对应这里的 Ll 缓存或者 L2 缓存或者 CPU 寄存器。
说说你对原子性、可见性、有序性的理解?
原子性、有序性、可见性是并发编程中非常重要的基础概念,JMM的很多技术都是围绕着这三大特性展开。
原子性 :原子性指的是一个操作是不可分割、不可中断的,要么全部执行并且执行的过程不会被任何因素打断,要么就全不执行。
可见性 :可见性指的是一个线程修改了某一个共享变量的值时,其它线程能够立即知道这个修改。
有序性 :有序性指的是对于一个线程的执行代码,从前往后依次执行,单线程下可以认为程序是有序的,但是并发时有可能会发生指令重排。
原子性、可见性、有序性都应该怎么保证呢?
原子性:JMM只能保证基本的原子性,如果要保证一个代码块的原子性,需要使用 synchronized 。
可见性:Java是利用 volatile 关键字来保证可见性的,除此之外, final 和synchronized 也能保证可见性。
有序性: synchronized 或者 volatile 都可以保证多线程之间操作的有序性。
那说说什么是指令重排?
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3
种类型。
编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应 机器指令的执行顺序。
内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序,如图:
我们比较熟悉的双重校验单例模式就是一个经典的指令重排的例子, Singleton instance=new Singleton();
对应的JVM指令分为三步:分配内存空间–>初始化对象—>对象指向分配的内存空间,但是经过了编译器的指令重排序,第二步和第三步就可能会重排序。
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
指令重排有限制吗?happens-before了解吗?
指令重排也是有一些限制的,有两个规则 happens-before 和 as-if-serial 来约束。
happens-before的定义:
如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按
照 happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按
happens-before关系来执行的结果一致,那么这种重排序并不非法。
happens-before和我们息息相关的有六大规则:
程序顺序规则 :一个线程中的每个操作,happens-before于该线程中的任意后续操作。
监视器锁规则 :对一个锁的解锁,happens-before于随后对这个锁的加锁。
volatile变量规则 :对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
传递性 :如果A happens-before B,且B happens-before C,那么A happens-before C。
start()规则 :如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的
ThreadB.start()操作happens-before于线程B中的任意操作。
join()规则 :如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作 happens-before于线程A从ThreadB.join()操作成功返回。
as-if-serial又是什么?单线程的程序一定是顺序的吗?
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例。
1 | double pi = 3.14; // A |
上面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有两个作用,保证可见性和有序性。
volatile怎么保证可见性的呢?
相比synchronized的加锁方式来解决共享变量的内存可见性问题,volatile就是更轻量的选择,它没有上下文切换的额外开销成本。
volatile可以确保对某个变量的更新对其他线程马上可见,一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存 当其它线程读取该共享变量 ,会从主内存重新获取最新值,而不是使用当前线程的本地内存中的值。
例如,我们声明一个 volatile 变量 volatile int x = 0,线程A修改x=1,修改完之后就会把新的值刷新回主内存,线程B读取x的时候,就会清空本地内存变量,然后再从主内存获取最新值。
volatile怎么保证有序性的呢?
重排序可以分为编译器重排序和处理器重排序,valatile保证有序性,就是通过分别限制这两种类型的重排序。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
1. 在每个volatile写操作的前面插入一个 StoreStore 屏障
2. 在每个volatile写操作的后面插入一个 StoreLoad 屏障
3. 在每个volatile读操作的后面插入一个 LoadLoad 屏障
4. 在每个volatile读操作的后面插入一个 LoadStore 屏障
锁
synchronized用过吗?怎么使用?
synchronized经常用的,用来保证代码的原子性。
synchronized主要有三种用法:
修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁
1
2
3synchronized void method() {
//业务代码
}修饰静态方法:也就是给当前类加锁,会作⽤于类的所有对象实例 ,进⼊同步代码前要获得当前 class 的锁。因为静态成员不属于任何⼀个实例对象,是类成员( static 表明这是该类的⼀个静态资源,不管 new 了多少个对象,只有⼀份)。
如果⼀个线程 A 调⽤一个实例对象的非静态 synchronized 方法,⽽线程 B 需要调⽤这个实例对象所属类的静态 synchronized 方法,是允许的,不会发⽣互斥现象,因为访问静态 synchronized ⽅法占⽤的锁是当前类的锁,而访问非静态synchronized 方法占用的锁是当前实例对象锁。
1
2
3synchronized void staic method() {
//业务代码
}修饰代码块 :指定加锁对象,对给定对象/类加锁。 synchronized(this|object) 表示进⼊同步代码库前要获得给定对象的锁。 synchronized(类.class) 表示进入同步代码前要获得 当前 class 的锁
1
2
3synchronized(this) {
//业务代码
}
synchronized的实现原理?
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的工作原理:
ObjectMonitor有两个队列: WaitSet、 EntryList,用来保存ObjectWaiter 对象列表。
_owner,获取 Monitor 对象的线程进入 _owner 区时, _count + 1。如果线程调用了 wait() 方法,此时会释放 Monitor 对象, _owner 恢复为空, _count - 1。同时该等待线程进入 _WaitSet 中,等待被唤醒。
1 | ObjectMonitor() { |
可以类比一个去医院就诊的例子:
首先,患者在门诊大厅前台或自助挂号机进行挂号;
随后,挂号结束后患者找到对应的诊室就诊:
- 诊室每次只能有一个患者就诊;
- 如果此时诊室空闲,直接进入就诊;
- 如果此时诊室内有其它患者就诊,那么当前患者进入 候诊室 ,等待叫号;
就诊结束后,走出就诊室,候诊室的下一位候诊患者进入就诊室。
这个过程就和Monitor机制比较相似:
门诊大厅 :所有待进入的线程都必须先在 入口Entry Set 挂号才有资格;
就诊室 :就诊室 _Owner 里里只能有一个线程就诊,就诊完线程就自行离开
候诊室 :就诊室繁忙时,进入 等待区(Wait Set) ,就诊室空闲的时候就从 等待区(Wait Set) 叫新的线程
所以我们就知道了,同步是锁住的什么东西:
monitorenter,在判断拥有同步标识 ACC_SYNCHRONIZED 抢先进入此方法的线程会优先拥有 Monitor 的 owner ,此时计数器 +1。
monitorexit,当执行完退出后,计数器 -1,归 0 后被其他进入的线程获得。
除了原子性,synchronized可见性,有序性,可重入性怎么实现?
synchronized怎么保证可见性?
线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值。
线程加锁后,其它线程无法获取主内存中的共享变量。
线程解锁前,必须把共享变量的最新值刷新到主内存中。
synchronized怎么保证有序性?
synchronized同步的代码块,具有排他性,一次只能被一个线程拥有,所以synchronized保证同一时刻,代码是单线程执行的。
因为as-if-serial语义的存在,单线程的程序能保证最终结果是有序的,但是不保证不会指令重排。
所以synchronized保证的有序是执行结果的有序性,而不是防止指令重排的有序性。
synchronized怎么实现可重入的呢?
synchronized 是可重入锁,也就是说,允许一个线程二次请求自己持有对象锁的临界资源,这种情况称为可重入锁。
synchronized 锁对象的时候有个计数器,他会记录下线程获取锁的次数,在执行完对应的代码块之后,计数器就会-1,直到计数器清零,就释放锁了。
之所以,是可重入的。是因为 synchronized 锁对象有个计数器,会随着线程获取锁后 +1 计数,当线程执行完毕后 -1,直到清零释放锁。
锁升级?synchronized优化了解吗?
了解锁升级,得先知道,不同锁的状态是什么样的。这个状态指的是什么呢?
Java对象头里,有一块结构,叫 Mark Word 标记字段,这块结构会随着锁的状态变化而变化。
64 位虚拟机 Mark Word 是 64bit,我们来看看它的状态变化:
Mark Word存储对象自身的运行数据,如哈希码、GC分代年龄、锁状态标志、偏向时间戳(Epoch) 等。
synchronized做了哪些优化?
在JDK1.6之前,synchronized的实现直接调用ObjectMonitor的enter和exit,这种锁被称之为重量级锁。从JDK6开始,HotSpot虚拟机开发团队对Java中的锁进行优化,如增加了适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等优化策略,提升了synchronized的性能。
偏向锁:在无竞争的情况下,只是在Mark Word里存储当前线程指针,CAS操作都不做。
轻量级锁:在没有多线程竞争时,相对重量级锁,减少操作系统互斥量带来的性能消耗。但是,如果存在锁竞争,除了互斥量本身开销,还额外有CAS操作的开销。
自旋锁:减少不必要的CPU上下文切换。在轻量级锁升级为重量级锁时,就使用了自旋加锁的方式
锁粗化:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
锁消除:虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。
锁升级的过程是什么样的?
锁升级方向:无锁–>偏向锁—> 轻量级锁—->重量级锁,这个方向基本上是不可逆的。
我们看一下升级的过程:
偏向锁:
偏向锁的获取:
1. 判断是否为可偏向状态–MarkWord中锁标志是否为‘01’,是否偏向锁是否为‘1’
2. 如果是可偏向状态,则查看线程ID是否为当前线程,如果是,则进入步骤’5’,否则进入步骤‘3’
3. 通过CAS操作竞争锁,如果竞争成功,则将MarkWord中线程ID设置为当前线程ID,然后执行‘5’;竞争失败,则执行‘4’
4. CAS获取偏向锁失败表示有竞争。当达到safepoint时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁 ,然后被阻塞在安全点的线程继续往下执行同步代码块
5. 执行同步代码
偏向锁的撤销:
1. 偏向锁不会主动释放(撤销),只有遇到其他线程竞争时才会执行撤销,由于撤销需要知道当前持有该偏向锁的线程栈状态,因此要等到safepoint时执行,此时持有该偏向锁的线程(T)有‘2’,‘3’两种情况;
2. 撤销—-T线程已经退出同步代码块,或者已经不再存活,则直接撤销偏向锁,变成无锁状态—-该状态达到阈值20则执行批量重偏向
3. 升级—-T线程还在同步代码块中,则将T线程的偏向锁 升级为轻量级锁 ,当前线程执行轻量级锁状态下的锁获取步骤—-该状态达到阈值40则执行批量撤销
轻量级锁:
轻量级锁的获取:
1. 进行加锁操作时,jvm会判断是否已经时重量级锁,如果不是,则会在当前线程栈帧中划出一块空间,作为该锁的锁记录,并且将锁对象MarkWord复制到该锁记录中
2. 复制成功之后,jvm使用CAS操作将对象头MarkWord更新为指向锁记录的指针,并将锁记录里的owner指针指向对象头的MarkWord。如果成功,则执行‘3’,否则执行‘4’
3. 更新成功,则当前线程持有该对象锁,并且对象MarkWord锁标志设置为‘00’,即表示此对象处于轻量级锁状态
4. 更新失败,jvm先检查对象MarkWord是否指向当前线程栈帧中的锁记录,如果是则执行‘5’,否则执行‘4’
5. 表示锁重入;然后当前线程栈帧中增加一个锁记录第一部分(Displaced Mark Word)为null,并指向Mark Word的锁对象,起到一个重入计数器的作用。
6. 表示该锁对象已经被其他线程抢占,则进行 自旋等待 (默认10次),等待次数达到阈值仍未获取到锁,则 升级为重量级锁
大体上省简的升级过程:
完整的升级过程:
。。。
说说synchronized和ReentrantLock的区别?
锁的实现: synchronized是Java语言的关键字,基于JVM实现。而ReentrantLock是基于JDK的API层面实现的(一般是lock()和unlock()方法配合try/finally 语句块来完成。)
性能: 在JDK1.6锁优化以前,synchronized的性能比ReenTrantLock差很多。但是JDK6开始,增加了适应性自旋、锁消除等,两者性能就差不多了。
功能特点: ReentrantLock 比 synchronized 增加了一些高级功能,如等待可中断、可实现公平锁、可实现选择性通知。
ReentrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制
ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。
synchronized与wait()和notify()/notifyAll()方法结合实现等待/通知机制,ReentrantLock类借助Condition接口与newCondition()方法实现。
ReentrantLock需要手工声明来加锁和释放锁,一般跟finally配合释放锁。而synchronized不用手动释放锁。
下面的表格列出出了两种锁之间的区别:
AQS了解多少?
AbstractQueuedSynchronizer 抽象同步队列,简称 AQS ,它是Java并发包的根基,并发包中的锁就是基于AQS实现的。
AQS是基于一个FIFO的双向队列,其内部定义了一个节点类Node,Node 节点内部的 SHARED 用来标记该线程是获取共享资源时被阻挂起后放入AQS 队列的,EXCLUSIVE 用来标记线程是 取独占资源时被挂起后放入AQS 队列
AQS 使用一个 volatile 修饰的 int 类型的成员变量 state 来表示同步状态,修改同步状态成功即为获得锁,volatile 保证了变量在多线程之间的可见性,修改 State值时通过 CAS 机制来保证修改的原子性
获取state的方式分为两种,独占方式和共享方式,一个线程使用独占方式获取了资源,其它线程就会在获取失败后被阻塞。一个线程使用共享方式获取了资源,另外一个线程还可以通过CAS的方式进行获取。
如果共享资源被占用,需要一定的阻塞等待唤醒机制来保证锁的分配,AQS 中会将竞争共享资源失败的线程添加到一个变体的 CLH 队列中。
先简单了解一下CLH:Craig、Landin and Hagersten 队列,是 单向链表实现的队列。申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现 前驱节点释放了锁就结束自旋
AQS 中的队列是 CLH 变体的虚拟双向队列,通过将每条请求共享资源的线程封装成一个节点来实现锁的分配:
AQS 中的 CLH 变体等待队列拥有以下特性:
AQS 中队列是个双向链表,也是 FIFO 先进先出的特性
通过 Head、Tail 头尾两个节点来组成队列结构,通过 volatile 修饰保证可见性
Head 指向节点为已获得锁的节点,是一个虚拟节点,节点本身不持有具体线程
获取不到同步状态,会将节点进行自旋获取锁,自旋一定次数失败后会将线程阻塞,相对于 CLH 队列性能较好
ReentrantLock实现原理?
ReentrantLock 是可重入的独占锁,只能有一个线程可以获取该锁,其它获取该锁的线程会被阻塞而被放入该锁的阻塞队列里面。
看看ReentrantLock的加锁操作:
1 | // 创建非公平锁 |
new ReentrantLock() 构造函数默认创建的是非公平锁 NonfairSync。
公平锁 FairSync
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁
公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU 唤醒阻塞线程的开销比非公平锁大
非公平锁 NonfairSync
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁
非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU 不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁
默认创建的对象lock()的时候:
如果锁当前没有被其它线程占用,并且当前线程之前没有获取过该锁,则当前线程会获取到该锁,然后设置当前锁的拥有者为当前线程,并设置 AQS 的状态值为1 ,然后直接返回。如果当前线程之前己经获取过该锁,则这次只是简单地把AQS 的状态值加1后返回。
如果该锁己经被其他线程持有,非公平锁会尝试去获取锁,获取失败的话,则调用该方法线程会被放入 AQS 队列阻塞挂起。
ReentrantLock怎么实现公平锁的?
CAS呢?CAS了解多少?
CAS叫做CompareAndSwap,比较并交换,主要是通过处理器的指令来保证操作的原子性的。
CAS 指令包含 3 个参数:共享变量的内存地址 A、预期的值 B 和共享变量的新值C。
只有当内存中地址 A 处的值等于 B 时,才能将内存中地址 A 处的值更新为新值C。作为一条 CPU 指令,CAS 指令本身是能够保证原子性的 。
CAS 有什么问题?如何解决?
Java有哪些保证原子性的方法?如何保证多线程下i++ 结果正确?
使用循环原子类,例如AtomicInteger,实现i++原子操作
使用juc包下的锁,如ReentrantLock ,对i++操作加锁lock.lock()来实现原子性
使用synchronized,对i++操作加锁
原子操作类了解多少?
AtomicInteger 的原理?
一句话概括:使用CAS实现。
线程死锁了解吗?该如何避免?
死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去。
那么为什么会产生死锁呢? 死锁的产生必须具备以下四个条件:
互斥条件:指线程对己经获取到的资源进行它性使用,即该资源同时只由一个线程占用。如果此时还有其它线程请求获取获取该资源,则请求者只能等待,直至占有资源的线程释放该资源。
请求并持有条件:指一个 线程己经持有了至少一个资源,但又提出了新的资源请求,而新资源己被其它线程占有,所以当前线程会被阻塞,但阻塞 的同时并不释放自己已经获取的资源。
不可剥夺条件:指线程获取到的资源在自己使用完之前不能被其它线程抢占,只有在自己使用完毕后才由自己释放该资源。
环路等待条件:指在发生死锁时,必然存在一个线程——资源的环形链,即线程集合 {T0,T1,T2,…… ,Tn} 中 T0 正在等待一 T1 占用的资源,Tl1正在等待T2用的资源,…… Tn 在等待己被 T0占用的资源。
该如何避免死锁呢?答案是至少破坏死锁发生的一个条件。
其中,互斥这个条件我们没有办法破坏,因为用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?
对于“请求并持有”这个条件,可以一次性请求所有的资源。
对于“不可剥夺”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
对于“环路等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后就不存在环路了。
那死锁问题怎么排查呢?
可以使用jdk自带的命令行工具排查:
1. 使用jps查找运行的Java进程:jps -l
2. 使用jstack查看线程堆栈信息:jstack -l 进程id
基本就可以看到死锁的信息。
并发工具类
线程池
什么是线程池
简单理解,它就是一个管理线程的池子。
它帮我们管理线程,避免增加创建线程和销毁线程的资源损耗 。因为线程其实也是一个对象,创建一个对象,需要经过类加载过程,销毁一个对象,需要走GC垃圾回收流程,都是需要资源开销的。
提高响应速度。 如果任务到达了,相对于从线程池拿线程,重新去创建一条线程执行,速度肯定慢很多。
重复利用。 线程用完,再放回池子,可以达到重复利用的效果,节省资源。
能说说工作中线程池的应用吗?
跟第三方对接
如何使用线程池?
线程池是一种用来管理线程的技术,它可以实现线程的复用和管理,避免线程频繁创建和销毁的开销。在Java中,可以通过ThreadPoolExecutor类来创建线程池,使用execute()方法将任务提交给线程池,由线程池来分配线程执行任务。线程池的大小可以根据实际情况进行调整,以达到最佳的性能。
Executors 返回线程池对象的弊端如下:
- FixedThreadPool 和 SingleThreadExecutor : 允许请求的队列长度为 Integer.MAX_VALUE ,可能堆积大量的请求,从而导致 OOM。
- CachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。
线程池的参数?
ThreadPoolExecutor
3 个最重要的参数:
corePoolSize
: 核心线程数定义了最小可以同时运行的线程数量。maximumPoolSize
: 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。workQueue
: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
ThreadPoolExecutor
其他常见参数:
keepAliveTime
:当线程池中的线程数量大于corePoolSize
的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime
才会被回收销毁;unit
:keepAliveTime
参数的时间单位。threadFactory
:executor 创建新线程的时候会用到。handler
:饱和策略。关于饱和策略下面单独介绍一下。
线程池的饱和策略有哪些?
如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolTaskExecutor
定义一些策略:
ThreadPoolExecutor.AbortPolicy
: 抛出RejectedExecutionException
来拒绝新任务的处理。ThreadPoolExecutor.CallerRunsPolicy
: 调用执行自己的线程运行任务,也就是直接在调用execute
方法的线程中运行(run
)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。ThreadPoolExecutor.DiscardPolicy
: 不处理新任务,直接丢弃掉。ThreadPoolExecutor.DiscardOldestPolicy
: 此策略将丢弃最早的未处理的任务请求。
线程池有哪几种工作队列?
ArrayBlockingQueue:ArrayBlockingQueue(有界队列)是一个用数组实现的有界阻塞队列,按FIFO排序量。
LinkedBlockingQueue:LinkedBlockingQueue(可设置容量队列)是基于链表结构的阻塞队列,按FIFO排序任务,容量可以选择进行设置,不设置的话,将是一个无边界的阻塞队列,最大长度为Integer.MAX_VALUE,吞吐量通常要高于ArrayBlockingQuene;newFixedThreadPool线程池使用了这个队列
DelayQueue:DelayQueue(延迟队列)是一个任务定时周期的延迟执行的队列。根据指定的执行时间从小到大排序,否则根据插入到队列的先后排序。newScheduledThreadPool线程池使用了这个队列。
PriorityBlockingQueue:PriorityBlockingQueue(优先级队列)是具有优先级的无界阻塞队列
SynchronousQueue:SynchronousQueue(同步队列)是一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQuene,newCachedThreadPool线程池使用了这个队列。
线程池的原理(ThreadPoolExecutor的实现原理)
- 线程池的初始化。当我们创建ThreadPoolExecutor对象时,会指定线程池的核心线程数、最大线程数、任务队列等参数。在初始化时,线程池会创建指定数量的核心线程,并将它们放入工作队列中。
- 线程的调度与执行。当线程池接收到任务后,会根据任务类型和线程池的状态,选择相应的策略来执行任务。如果线程池中有空闲的核心线程,则会将任务交给它们执行;如果核心线程都在工作且工作队列未满,则将任务放入工作队列;如果工作队列已满且线程池中线程数量未达到最大线程数,则会创建新的线程执行任务;如果线程池已经达到最大线程数,则根据指定的拒绝策略来处理任务。
- 线程池的管理。线程池中的线程在执行完任务后,会根据指定的线程存活时间来决定是否继续保留在线程池中。线程池还提供了一些方法来管理线程池的状态,如动态修改核心线程数、最大线程数和工作队列大小等。
- 线程池的关闭。线程池在关闭时,会按照一定的顺序停止接收新的任务,并等待线程池中的所有任务执行完毕。在等待过程中,线程池会根据指定的拒绝策略处理新的任务。当所有任务执行完毕后,线程池中的所有线程都会被中断并销毁,线程池的状态也会被置为TERMINATED。
线程池提交execute和submit有什么区别?
线程池怎么关闭知道吗?
线程池的线程数应该怎么配置?
有哪几种常见的线程池?
能说一下四种常见线程池的原理吗?
线程池异常怎么处理知道吗?
能说一下线程池有几种状态吗?
什么是线程死锁?如何避免线程死锁?
线程死锁是指两个或多个线程互相持有对方所需的资源而无法继续执行的状态。为避免线程死锁,可以采用以下几种方式:
- 避免嵌套锁。在使用多个锁的情况下,避免嵌套使用,从而避免死锁的发生。
- 使用定时锁。如果一个线程无法获取到所需的锁,可以设置一个超时时间,等待一段时间后再重新尝试获取锁。
- 按顺序获取锁。如果多个线程需要获取多个锁,可以按照固定的顺序获取锁,从而避免出现循环等待的情况。
什么是线程间通信?如何实现线程间通信?
线程间通信是指在多线程程序中,两个或多个线程之间交换信息或数据的过程。在Java中,可以通过wait()、notify()、notifyAll()等方法来实现线程间的通信。
什么是线程安全的集合类?如何使用线程安全的集合类?
线程安全的集合类是指在多线程环境下使用的集合类,它们保证了多个线程同时访问集合时的线程安全性。在Java中,常用的线程安全的集合类包括ConcurrentHashMap、ConcurrentSkipListMap、ConcurrentSkipListSet等。使用线程安全的集合类可以通过创建实例对象,然后在多个线程中同时访问该对象来实现。
如何防止线程安全问题?
防止线程安全问题可以采用以下几种方式:
- 使用线程安全的集合类。线程安全的集合类可以保证多个线程同时访问集合时的线程安全性,从而避免线程安全问题。
- 使用同步机制。使用synchronized关键字、ReentrantLock等同步机制来保证线程安全,从而避免线程安全问题。
- 使用原子类。原子类可以保证单个操作的原子性,从而避免多个线程同时操作同一个变量时的线程安全问题。
- 避免共享变量。将共享变量封装成私有变量,通过提供公共方法来进行访问和修改,从而避免线程安全问题。
什么是线程调度?Java中如何实现线程调度?
线程调度是指操作系统或虚拟机如何分配CPU资源给各个线程的过程。在Java中,可以使用Thread类的yield()、sleep()、join()等方法来实现线程调度。同时,还可以使用线程优先级来控制线程的调度顺序,高优先级的线程会先获得CPU资源执行。、
什么是线程组?如何使用线程组?
线程组是一组相关线程的集合,它们共享相同的特征和属性。线程组可以通过ThreadGroup类来创建,并使用Thread类的构造方法来将线程添加到线程组中。线程组可以用于对一组线程进行统一管理,如设置线程组的优先级、中断线程组中所有线程等操作。
讲一下悲观锁和乐观锁
悲观锁和乐观锁是并发编程中用于处理多线程访问共享资源的两种不同的锁机制。
悲观锁:
悲观锁假定在任何时候都会发生冲突,因此需要在访问共享资源之前先获取锁。悲观锁通过在访问共享资源之前获取锁来确保同一时刻只有一个线程能够访问共享资源。悲观锁的特点是在锁被释放之前,其他线程都不能访问被锁定的共享资源,因此可能会导致其他线程出现长时间的等待,从而影响系统的性能。
Java 中的 synchronized 关键字和 ReentrantLock 类都是悲观锁的实现方式。
乐观锁:
乐观锁假定冲突很少发生,因此不需要在访问共享资源之前获取锁。相反,每个线程都会在访问共享资源之前先获取当前资源的版本号或者标识,然后进行读取和修改操作。如果在修改操作期间发现资源的版本号或者标识与之前不同,说明其他线程已经修改了该资源,那么当前线程就会放弃修改操作并重新读取资源,再次尝试修改。乐观锁的特点是多个线程可以同时访问共享资源,不会造成线程阻塞,从而提高了系统的并发性能。
Java 中的 Atomic 原子类和版本号机制都是乐观锁的实现方式。
总的来说,悲观锁和乐观锁的选择取决于具体的应用场景和需求。在并发量较高且资源竞争比较激烈的情况下,悲观锁可能更适合,而在并发量较低且资源竞争不激烈的情况下,乐观锁可能更适合。
synchronized 是什么?有什么用?
synchronized
是 Java 中用于实现线程同步的关键字,它可以用来控制对共享资源的访问,保证在同一时间只有一个线程能够访问共享资源,从而避免线程间的竞争和冲突。
synchronized
可以用在方法、代码块等位置,具体使用方式如下:
- 修饰实例方法:将
synchronized
关键字放在方法声明前,表示该方法是一个同步方法。当一个线程进入同步方法时,会自动获取该方法所属对象的锁,其他线程无法访问该方法,直到该线程释放锁。 - 修饰静态方法:将
synchronized
关键字放在静态方法的声明前,表示该方法是一个静态同步方法。静态同步方法会自动获取该方法所属类的锁,其他线程无法访问该方法,直到该线程释放锁。 - 修饰代码块:将
synchronized
关键字放在代码块前,表示该代码块是一个同步块。当一个线程进入同步块时,会自动获取该同步块所在对象的锁,其他线程无法访问该同步块,直到该线程释放锁。
synchronized
的作用是保证多个线程对共享资源的安全访问,避免了数据的脏读、并发访问等问题。但是,使用 synchronized
会引入线程阻塞和线程切换等开销,因此需要谨慎使用。另外,Java 中还提供了一些其他的同步机制,例如 Lock
接口和 ReentrantLock
类等,它们相比于 synchronized
更加灵活、可定制化,可以满足更加复杂的同步需求。
ReentrantLock 类是什么?有什么用?和synchronized 有什么区别?
ReentrantLock
是 Java 提供的一种可重入锁(也称为独占锁),它可以用来实现多线程之间的同步。与 synchronized 相比,ReentrantLock 提供了更多的扩展功能,例如可中断的锁、超时的锁等等,同时也提供了更细粒度的控制。
与 synchronized 相比,ReentrantLock 的主要区别包括:
- 可重入性:ReentrantLock 允许一个线程多次获取同一个锁,而不会导致死锁,这是因为每次获取锁时,ReentrantLock 都会记录当前线程及获取锁的次数。
- 可中断性:如果一个线程在等待锁的过程中,调用了 interrupt() 方法中断了自己,ReentrantLock 可以捕获到中断信号,从而让线程有机会停止等待锁,并执行其他任务。
- 公平锁:ReentrantLock 提供了公平锁和非公平锁两种方式,公平锁会优先给等待时间最长的线程分配锁,而非公平锁则允许某些线程“插队”获取锁。
- 条件变量:ReentrantLock 还提供了条件变量(Condition)的机制,可以让线程在特定条件下等待、唤醒。
总的来说,ReentrantLock 相比 synchronized 提供了更多的功能和更细粒度的控制,但是使用也更加复杂,需要手动进行加锁和解锁操作。因此,在一些简单的场景下,还是建议使用 synchronized 来实现线程同步。而在一些复杂的场景下,ReentrantLock 可以提供更好的灵活性和控制力。
Lock的作用
Lock 是 Java 并发编程中用来实现同步的一种机制。与 synchronized 关键字不同,Lock 可以在不同的线程中进行加锁和解锁操作,并且提供更加灵活的锁定方式。Lock 接口定义了以下常用方法:
- lock():获取锁,如果锁不可用则阻塞当前线程;
- tryLock():尝试获取锁,如果锁不可用则立即返回 false,否则返回 true;
- unlock():释放锁。
常见的 Lock 实现类包括 ReentrantLock、ReentrantReadWriteLock、StampedLock 等。
Lock 的使用与 synchronized 相似,都是用来实现线程安全的。但是相比于 synchronized,Lock 提供了更多的功能和灵活性,例如可以实现公平锁、非阻塞锁、可重入锁等。此外,由于 synchronized 关键字是由 JVM 实现的,因此不同 JVM 实现可能存在不同的行为。而 Lock 则由用户显式调用相关方法,因此更加可控。
需要注意的是,Lock 在使用时需要手动进行加锁和解锁操作,如果不慎出现死锁、线程中断等问题,可能会导致程序运行异常。因此,使用 Lock 需要谨慎并且遵循相关的最佳实践。
下面是一个使用 ReentrantLock 实现线程安全的例子:
1 | import java.util.concurrent.locks.Lock; |
在上面的例子中,我们定义了一个 Counter 类来表示一个计数器,该计数器在多线程环境下需要实现线程安全。我们使用 ReentrantLock 来实现线程安全的加锁和解锁操作,其中 increment() 方法使用 lock.lock() 获取锁,执行完 count++ 操作后使用 lock.unlock() 释放锁。这样,在多线程环境下,同一时刻只有一个线程可以获得锁,从而实现线程安全。
Lock 相比于 synchronized 关键字提供了更加灵活的锁定方式,因此可以应用于更多的场景中。此外,Lock 还提供了一些高级特性,如条件变量等,可以用来实现更加复杂的同步机制。但是,使用 Lock 需要注意以下几点:
- Lock 操作需要手动加锁和解锁,如果不慎出现死锁或忘记解锁等问题,可能会导致程序出现异常或死锁等情况。
- Lock 操作通常比 synchronized 更加复杂,使用时需要谨慎考虑是否真正需要使用 Lock,以及如何正确使用 Lock。
- Lock 通常比 synchronized 更加消耗系统资源,因此在高并发场景下可能会导致系统性能下降。
另外,相比于 synchronized,Lock 的锁定方式更加灵活,例如可以实现公平锁和非公平锁、可重入锁等等。具体来说,Lock 接口提供了以下常用的实现类:
- ReentrantLock:可重入锁,支持公平锁和非公平锁,允许多个线程同时访问。
- ReentrantReadWriteLock:读写锁,支持多个读线程同时访问,但只允许一个写线程访问。
- StampedLock:乐观锁,允许多个线程同时读,但在写时会独占锁,不支持可重入性。
- Condition:条件变量,可以让线程在满足某个条件时阻塞等待,或者唤醒处于等待状态的线程。
Lock 和 synchronized 的区别主要有以下几点:
- Lock 可以实现公平锁和非公平锁,synchronized 只能实现非公平锁。
- Lock 可以中断线程等待锁,synchronized 无法中断线程等待锁。
- Lock 支持多个条件变量,synchronized 只能支持一个条件变量。
- Lock 可以提高吞吐量,在高并发情况下比 synchronized 更加高效。
在实际应用中,选择使用 Lock 还是 synchronized 取决于具体场景和需求。一般来说,如果只是简单的同步操作,使用 synchronized 更加方便和简单;如果需要更加灵活的锁定方式,或者需要使用更加高级的同步机制,可以选择使用 Lock。
ThreadLocal是什么?有什么用?原理是什么?为什么会内存泄漏?
ThreadLocal 是 Java 中的一个线程局部变量工具类,可以用来实现线程间数据的隔离。它为每个线程提供一个独立的变量副本,以便线程可以独立地操作自己的变量副本,而不会相互影响。
ThreadLocal 的作用是提供了一种线程私有的变量存储方式,通过它可以将数据隔离在不同的线程中,每个线程中的数据互不干扰。
ThreadLocal 的原理是:每个 Thread 对象中都有一个 ThreadLocalMap 类型的成员变量 threadLocals,它用于存储每个 ThreadLocal 对象的变量副本。ThreadLocalMap 中的键是 ThreadLocal 对象,值是对应的变量副本。当使用 ThreadLocal 的 get() 方法获取变量时,ThreadLocal 会根据当前线程获取其对应的 ThreadLocalMap 对象,并以自己作为键获取对应的变量副本。
ThreadLocal 的内存泄漏问题主要是因为 ThreadLocalMap 中的 Entry 对象没有及时清理。由于 ThreadLocalMap 中的 Entry 对象的生命周期与 ThreadLocal 对象本身的生命周期不同,当线程结束后,ThreadLocal 对象的引用被 GC 回收,但对应的 Entry 对象没有被清理,从而导致内存泄漏。为了解决这个问题,可以使用 ThreadLocal 的 remove() 方法手动清除变量副本,或者使用类似于 Spring 的 ThreadLocal 清理器来自动清除变量副本。
需要注意的是,由于 ThreadLocal 会为每个线程创建一个变量副本,因此在多线程高并发的情况下,使用过多的 ThreadLocal 变量也会导致内存占用过大的问题。此外,由于 ThreadLocal 的使用会隐藏线程间的数据依赖关系,因此在使用时需要格外注意线程安全问题。