线程间间的通信方式大体有五种:
①管道
②消息队列
③信号量
④共享内存
⑤套接字与rpc, rpc是实现跨机器间通信的一种方式
进程是操作系统资源分配的基本单位,线程是cpu调度的基本单位。一个进程里可以有多个线程,多个线程共享进程的内存地址空间,从这点上线程是比进程轻量级的。
ThreadLocal在当前线程退出任务或者完成业务任务后,未进行正确的移除,即调用remove方法,那么可能造成内存泄露,导致与该线程版本的本地变量信息一致存在于线程的ThreadLocalMap中。
线程的本质(操作系统与CPU是如何执行线程的)
并发的本质是在多线程执行环境下,cpu对线程进行调度,线程调度的时机是线程cpu时间片使用完或者线程主动让出cpu。由于cpu时间片比较短,所以多个线程通过cpu来回不断的切换执行,产生“同时执行的效果”,与并行不同的是,并发不是真正的同时执行线程,并行的条件是多核cpu上多个线程在不同的cpu上同时执行。
锁的本质是对临界资源的保护,临界资源即多线程环境下线程竞争的资源。为保证程序的正确性和安全性,需要对临界资源进行保护,这就是锁。
synchronized可以作用在方法或方法局部。作用在方法上时,方法的访问标识会多一个ACC_SYNCHRONIZED;作用在局部方法时,字节码原语对应monitorenter和monitorexit,一个monitorentor可以对应多个monitorexit。因为在锁加锁以及释放的过程中,程序正常退出锁是一种锁情况;另一方面,也要保证在锁范围内的代码异常时,也能保证锁的退出。
代码片段及对应的字节码:
private static final Object lock = new Object();
public void m2(int a) {
synchronized (lock) {}
}
0: getstatic #2 // Field lock:Ljava/lang/Object;
3: dup // 复制引用到栈顶
4: astore_2 // 存到局部变量表2的位置, 0-this, 1-a, 2-lock
5: monitorenter
6: aload_2 // 读取局部变量表2的元素,即lock
7: monitorexit // 释放锁
8: goto 16 // 以上是正常退出锁的流程
11: astore_3 // 异常时exception
12: aload_2 // lock
13: monitorexit // 这两步保证异常时仍有exit
14: aload_3 // exception
15: athrow
16: return
对象锁的状态信息存放在对象头的MarkWord中。初始化状态下,即无锁状态(001)。偏向锁适用于请求锁都是一个线程的情况(无竞争),线程在获取偏向锁时,首先尝试将对象头的线程id更新为自身的线程id并更改锁状态标志(101)。更新成功后,则获取偏向锁成功。在更新失败后,如果当前对象的锁标识是一个偏向锁101,那么尝试将偏向锁的线程id改为线程本身。偏向锁采用等到竞争出现时才解除偏向锁的机制,此时原持有偏向锁的线程与竞争线程一同自旋竞争锁(这是与轻量锁的区别之一,轻量锁在碰到竞争时竞争失败的将进入entryList),所以当其他想成尝试获取偏向锁时,持有偏向锁的线程才会释放偏向锁。出现锁竞争时,并不是立刻膨胀锁,而是需要等到一个全局的安全点才膨胀锁。
偏向锁的启动延迟通过jvm参数-XX:BiasedLockingStartupDelay来配置,偏向锁启动延迟的目的猜测是jvm启动时会启动多个线程来准备环境,这种情况下锁竞争是比较激烈的,如果开启偏向锁,那么反而会降低锁的性能。
偏向锁和重量级锁的应用场景
偏向锁适用于无竞争或者竞争非常少的情况,重量级锁则应用于竞争比较激烈的场景,重量级锁是轻量级锁的升级,在竞争比较激烈的情况下,通过不采用自旋的方式直接升级为重量级锁,提高吞吐。
自旋锁(CAS)的底层实现
自旋锁即轻量级锁,线程在进入contentionList前,会尝试进行一次CAS操作来竞争获取锁,CAS的对象的对象头中得锁记录,如果更新成功,那么自旋锁获取成功,否则进入entrySet。这是自旋锁的触发时机。另一个关键点是自悬周期的选择,自悬周期的选择不能过长,不然严重降低锁的性能。在自悬周期的选择上,hotspot有以下几个优化:
①如果平均负载低于CPUs,则一直自旋;
②如果负载大于CPUs/2,那么直接进入entryList;
③如果正在自旋的线程发现当前锁的ower发生变化,则延迟自旋时间或者阻塞;
④CPU节电模式则放弃自旋;
⑤自旋会放弃线程优先级的差异;
以上CPUs,指的是CPU核数。
自旋锁在竞争的一方竞争失败后,将会进入entryList,并膨胀锁。
每一个线程在准备获取共享资源时: 第一步,检查MarkWord里面是不是放的自己的ThreadId ,如果是,表示当前线程是处于 “偏向锁” 第二步,如果MarkWord不是自己的ThreadId,锁升级,这时候,用CAS来执行切换,新的线程根据MarkWord里面现有的ThreadId,通知之前线程暂停,
之前线程将Markword的内容置为空。 第三步,两个线程都把对象的HashCode复制到自己新建的用于存储锁的记录空间,接着开始通过CAS操作,
把共享对象的MarKword的内容修改为自己新建的记录空间的地址的方式竞争MarkWord, 第四步,第三步中成功执行CAS的获得资源,失败的则进入自旋 第五步,自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态,如果自旋失败 第六步,进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己
用JOL手工观察锁升级的过程
CPU缓存是介于CPU与内存间的高速缓存区,目的在于进一步提高CPU与内存的交换速度。一般的,分三级。速度由快到慢一次是L1,L2,L3,大小也是越来越大,一般滴,L1的大小介于32~4096KB之间。CPU在读取数据时优先从L1读取,读不到时再从L2读,以此类推。可以这么理解,L1是CPU与L2间的缓存,L2是L1与CPU的缓存...
CPU由多个缓存行组成,CPU与内存的数据交换并不是要一个拿一个,而是以缓存行位单位进行数据交换。比如long[] 数组,当读取0位置的数据时,也会把1~7的数据读入缓存行,这样有利于提高存取速度。
32~256b不等,保证是2的幂次方即可。一般滴,64b
缓存行如何影响Java编程
ArrayBlockingQueue的伪共享,ForkJoin如何避免伪共享(@Contented的使用)
伪共享解决方案
①在jdk6下,通过额外定义几个对象来填满64字节;
②在jdk7中,由于采用jdk6的方式可能额外定义的对象可能直接被优化掉,因此改用继承的形式实现;
③@Contented
经典的DCL写法:
private static volatile DCL instance = null;
private static final Object lock = new Object();
public static DCL newInstance() {
if (instance == null) {
synchronized (lock) {
if (instance == null) {
instance = new DCL();
}
}
}
return instance;
}
在对象的创建过程new DCL(),对应的字节码为:
new #2 //1
invokespecial <init> //2
astore_1 //3
在不禁止指令重排序的情况下,3可能会提前到2之前执行,导致对象未被初始化就被暴露,造成引用逃逸。这也从侧面证明了CPU的乱序执行。
Q:进程和线程的区别
进程是操作系统资源分配的基本单位,线程是cpu调度的基本单位。进程与进程间资源是隔离的,一个进程中的线程共享进程资源。进程有自己独立的地址空间,线程没有。
Q:写一段this引用逃逸的代码
/**
* 解决this逃逸的最好方法就是不要在构造方法中将this传递出去
*
*/
public class ThisEscape {
final String a;
static ThisEscape t;
//这里将语句1写在最后也无效,因为可能会发生重排序,仍会发生逃逸
public ThisEscape() {
t = this; //1
//这里延时200ms,模拟构造方法其他字段的初始化
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
a = "ABC"; //2
}
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
new ThisEscape();
}); //t1进行构造对象
Thread t2 = new Thread(() -> {
System.out.println(ThisEscape.t.a);
});//t2观测常量的值
t1.start();
try {
Thread.sleep(10);
} catch (InterruptedException e) {
}
t2.start();
}
}
Q:线程池都有哪几种,具体细节了解多少
下列三种业务,应该如何使用线程池:
①高并发、任务执行时间短
②并发不高、任务执行时间长
③并发高、业务执行时间长
Q:如何发现死锁
死锁形成的条件:互斥条件、等待与保持、不可剥夺、循环等待
通过jps 和 jstack命令查看死锁情况,jstack -l pid|more
volatile作用及使用
禁止指令重排序,以及内存可见,内存可见使用的强制将工作内存刷新到主存的手段来保证内存可见性。
Volatile涉及的知识点有:
- 指令重排序
- 工作内存及缓存一致性
共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存保存了被该线程使用到的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。
- happens-before原则
- 同一个线程中前面的操作先于后续的操作(但是这个并不是绝对的,假如在单线程的环境下,重排序后不会影响结果的准确性,是可以进行重排序,不按代码的顺序执行)。
- Synchronized 规则中解锁操作先于后续的加锁操作。
- volatile 规则中写操作先于后续的读取操作,保证数据的可见性。
- 一个线程的start()方法先于任何该线程的所有后续操作。
- 线程的所有操作先于其他该线程在该线程上调用join返回成功的操作。
- 如果操作a先于操作b,操作b先于操作c,那么操作a先于操作c,传递性原理
- MESI与内存屏障
MESI即Modified,Exclusive,Shared和Invalid的缩写,是缓存一致性中定义的缓存四种状态:
①M(Modified) 这行数据有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。
②E(Exclusive) 这行数据有效,数据和内存中的数据一致,数据只存在于本Cache中。
③S(Shared) 这行数据有效,数据和内存中的数据一致,数据存在于很多Cache中。 ④I(Invalid) 这行数据无效
写缓存Store Buffer与无效化队列Invalid Queue
在MESI协议中,假设一个变量在多个核中存在,即为SHARED状态,那么当一个核A对这个变量进行修改的时候,需要通知其他核缓存无效的消息,其他核在收到消息后设置对应的缓存状态,然后再通知核A设置完成,核A在收到消息后在将该变量的缓存状态设置为Exclusive,进行后续操作。显然,这种方式将使核A一直阻塞到其他核的设置成功消息返回,无疑是低效的。因此引入和Store Buffer和Invalid Queue。
每个CPU核都拥有自己的写缓存,CPU会将自己修改的内容先放入到写缓存中,然后通知其他核对应变量更新的消息,然后继续执行后续的操作,无需等待其他核的返回消息。其他核为了保证快速的响应(可能原因是,Store Buffer容量小,如果响应时间过长,Store Buffer可能会被填满),引入了失效队列,即将无效的操作先放入无效化队列中,然后快速返回,等当前的操作完全后再来处理无效化队列,这样提高了多核情况下的CPU效能。
Store Buffer的引入也带来了新的问题,下面介绍单核和多核情况下的解决方案。以下例子:
x = 0; 内存初始化值(或者说共享缓存)
核A: 核B
x = 2 ----
b = x + 1 b = x + 1
assert b == 3 assert b == 3
①单核:由于核A对x进行了修改,因此x=2的值将放入Store Buffer中。执行b = x + 1,如果此时从共享缓存中读X,那么得到的值将是0,显然这是错误的。解决的方案是核A再读取x时先从Store Buffer中读取,看是否有,如果有,那么直接从Store Buffer中读取,这样能保证值时最新的。
②多核:多核环境下,假设核A执行了x=2,剩下的由核B执行,由于此时核A的x的值修改还在Store Buffer中,因此此时核B的结果一定是错误的。多核情况下就需要引入内存屏障来解决。
内存屏障分两种,读屏障和写屏障。读屏障即强制将无效化队列中的内容处理完毕;写屏障即强制将Store Buffer中的内容刷新到共享缓存或者将指令后的操作写入Store Buffer知道Store Buffer被更新到共享缓存。
在Volatile语义下,对应四种内存屏障。
①LL:指令前的读操作一定先于指令后的读操作发生
②LS:指令前的读操作一定先与指令后的写操作发生
③SS:指令前的写操作一定先于指令后的写操作发生
④SL:指令前的写操作一定先指令后的读操作发生,该指令兼具其他三种的效果,开销最大。
实际操作中,在每个volatile的写操作前插入SS,之后插入SL。在volatile的读操作前插入LL,读操作之后插入LS.
https://blog.csdn.net/weixin_44936828/article/details/89430358
Thread类几个主要方法
①yield()
会让当前线程放弃剩余时间片,进入相同优先级线程队列的队尾,只有排在前面的所有同优先级线程完成调度后,它才能再次获执行的机会。yield在不同的平台上可能有不同的结果,依赖于平台对优先级的定义。yield的等价效果可看成是sleep(0),与sleep类似,yield会让线程从运行状态running变为可运行状态,runnable,且不释放锁;wait是让线程从运行状态running变为阻塞(block)状态,且wait会释放锁。
②join()
join方法有三个版本:
v1.void join(long millis, int nanos)
v2.void join(long millis)
v3.void join()
v1和v3版本最终都会调用到2。v1会对nanos进行四舍五入取整,换算成ms数调用v2版本的方法。v3直接调用的是join(0)。
v2内部实际调用的wait()方法,对于v3而言,则调用的是wait(0),即方法是不超时的,只能等待notify()或者notifyAll()唤醒。当传入的millis大于0时,v2要么被唤醒返回,要么超时返回。由于内部实际是调用的wait()方法,因此需要获取对应对象的监视器锁,因此v2版本是被synchronize关键字修饰的。那么,join又是被谁唤醒的呢?先看下jvm内部join是如何wait的,典型如下:
while(isActive()) {wait(0);}
isActive用来判断当前线程是否还存活,该条件会被不断的校验,避免虚假唤醒。因此join是阻塞在wait(0)上的,那么看下如何被唤醒。
static void ensure_join(JavaThread* thread) {
Handle threadObj(thread, thread->threadObj());
assert(threadObj.not_null(), "java thread object must exist");
ObjectLocker lock(threadObj, thread);
thread->clear_pending_exception();
java_lang_Thread::set_stillborn(threadObj());
java_lang_Thread::set_thread_status(threadObj(), java_lang_Thread::TERMINATED);
java_lang_Thread::set_thread(threadObj(), NULL);
lock.notify_all(thread);
thread->clear_pending_exception();
}
以上方法的执行时机是线程退出时,主要的逻辑是设置现成的状态以及唤醒阻塞在当前线程对象的所有线程。这里就会唤醒join。
③sleep()
sleep方法对应两个版本:
v1:void sleep(long millis, int nanos)
v2:void sleep(long millis)
v2是最终程序的入口,与join类似,v1会对时间进行四舍五入后换算为millis走v2版本的方法。sleep同wait的区别正如yield同wait的区别,需要关注下sleep(0)的使用,在hotspot虚拟机中,在配置开启的情况下,sleep(0)同yield效果类似,当前线程放弃cpu时间片,加入到同优先级线程等待队列的末尾。
lock和synchronize区别
-- 作用域不同: synchronize作用于类,实例、方法或方法块内,lock只作用于方法块内;
-- synchronize自动释放(代码执行完释放,或异常释放), lock须手动释放
-- Lock可以获取到当前线程,synchronize不能
-- synchronize可重入,非公平,不可中断,Lock可重入,公平非公平,可中断,还有超时版本
synchronize 和 lock内存可见性的实现
happens-before内存语义中:
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
- 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作;
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;
锁在实现可见性主要利用了2,3规则。在retreenlock的实现中,state是volatile的,在加锁和释放锁的操作中,都有对state进行更新。在volatile的内存语义中,使用了lock关键字,即
lock cmpxchg, 保证了一次内存读和一次内存写。按照规则2、3进行理解,那么对state更新之前的操作(线程A加锁到释放锁这个过程), 对之后的读操作都是可见的(不仅仅是state变量,线程B也能感知到其他的操作)。需要注意的是,volatile在内存语义实现中,不是仅仅将一个变量同步到主存,而是一个缓存行(此处待具体看)。
在reentrantLock中,多线程竞争锁是采用类似死循环的方式,会不会造成cpu100?
线程在cas操作失败后,会进行阻塞。如果当前竞争的线程确认能够被唤醒,则进行阻塞,让出cpu。
重排序与内存屏障
重排序是基于数据依赖性来保证重排序后的指令结果是正确的。即A+B=C, 或者B+A=C 都是正确的,这种重排序只是保证了单一线程执行结果的正确性,多线程环境下可能出现问题。
内存屏障主要保证了2个功能:
--指令重排序,禁止屏障两侧的指令重排序
--内存可见性,强制将缓冲区/工作内存中数据写入主存,是缓冲区数据失效
内存屏障主要分两种:
-- Load Barrier 读屏障,在指令前插入,即强制更新工作内存的数据;
-- Store Barrier 写屏障,在指令后插入,即强制将工作内存数据写入主存。
JAVA中常用的有四种:
**LoadLoad屏障:**对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
**StoreStore屏障:**对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
**LoadStore屏障:**对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
**StoreLoad屏障:**对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能