java并发编程(已完结)
2020-09-03 10:52:50 3 举报
AI智能生成
最全的java并发编程
作者其他创作
大纲/内容
SimpleDateFormat是线程不安全的
SimpleDateFormat是线程不安全的类,定义为static对象,会有数据同步风险。通过源码可以看出,SimpleDateFormat内部有一个Calendar对象,在日期转字符串或字符串转日期的过程中,多线程共享时有非常高的概率产生错误,推荐的方式之一时使用ThreadLocal,让每个线程单独拥有这个对象。
解决办法
每次使用时new一个SimpleDateFormat 的 实例,这样可以保证每个实例使用自己的Calendar实例,但是每次使用都需要new一个对象,并且使用后由于没有其他引用,又需要回收,开销会很大。
可以使用syncheronized 对SimpleDtaFormat实例进行同步
使用ThreadLocl,这样每个线程只需要使用一个SimpleDateFormate实例,这相比第一种方式 节省了对象的创建销毁开销,并且不需要使多个线程同步。
ThreadLocalRandom
它解决了Random类在多线程下多个线程竞争内部唯一的原子性种子变量而导致大量线程自旋重试的不足
ThreadLocalRandom使用ThreadLocal的原理,让每个线程内持有一个本地的种子变量,该种子变量只有在使用随机数时候才会被初始化,多线程下计算新种子时候是根据自己线程内维护的种子变量进行更新,从而避免了竞争
并发编程基本概念
进程与线程的一个简单解释
进程是程序 运行资源(CPU、内存空间、磁盘IO等)分配的最小单位
线程是CPU调度的最小单位,必须依赖于进程而存在
CPU核心数和线程数的关系
CPU时间片轮转机制
并发和并行的区别
侧重于多个任务交替执行,而多个任务有可能还是串行的
并行才是真正意义上的同时执行
图示
上下文切换
导致原因
自发性切换
Thread.sleep
wait
Thread,yield
Thread,.join
LockSupport.park
发起了IO操作如读文件
等待其他线程持有的锁
非自发性切换
线程由于调度器的原因被迫切出
被切出的时间片用完
比被切出线程的优先级更高的线程需要被运行
开销
直接开销
操作系统保存和恢复上下文所需的开销
线程调度器 进行线程调度的开销
间接开销
处理器高速缓存重新加载的开销
上下文切换也可能导致一级高速缓存中的内容被冲刷
如何减少上下文 切换
无锁并发编程
CAS
使用最少线程
协程
死锁
并发的优势与风险
优势
速度
同时处理多个请求,响应更快
复杂的操作可以分成多个进程同时进行
设计
程序设计在某些情况下更简单,也可以由更多的选择
资源利用
CPU能够在等待IO的时候做一些其他的 事情
风险
安全性
多个线程共享数据时可能会产生期望不相符的结果
活跃性
某个操作无法继续进行下去时,就会发生活跃性问题,比如死锁、饥饿问题
性能
线程过多时会使得
CPU频繁切换,调度时间增多;同步机制;消耗过多内存
线程概念
线程(Thread)创建方式
继承Thread
实现Runnable
实现Callable
线程的状态
NEW
没有调用start方法
RUNNABLE
BLOCKED
等待阻塞(wait)
同步阻塞(synchronized)
其他阻塞 sleep、join
WAITING
TIMED_WAITING
TERMAINAL
线程的停止
interrupt(线程中断)
Thread.interrupt(中断线程)
Thread.isInterrupted() 判断是否被中断
Thread.interrupted() 判断是否被中断,并清除当前中断状态
通过指令的方式
volatile boolean isStop = false
守护线程和非守护线程
用户线程会阻止java虚拟机的正常停止,即一个java虚拟机只有在其所有用户线程都运行结束的情况下才能正常停止
守护线程不会影响java虚拟机的 正常停止,即应用程序中有守护线程在运行也不影响 java虚拟机的正常停止。
守护线程通常用于执行一些重要性不很高的任务,例如用户监视其他线程的运行情况
如果java虚拟机是被强制停止的,比如在linux系统下使用kill命令强制终止一个java虚拟机线程,那么即使是用户线程也无法阻止java虚拟机停止
子主题
常用API
run
start
join
等待相应线程结束
sleep
线程的监视
获取与查看线程转储(Thread Dump)
工具
jvisualvm
jstack
kill -3 PID
CTRL + Break
线程间的协作
等待/通知机制
等待和通知的标准范式
等待方遵循如下原则
获取对象的锁
如果条件不满足,那么调用对象的wait方法,被 通知后让仍要检查条件。
条件满足,则执行对应的逻辑
通知方遵循如下原则
获取对象的锁
改变条件
通知所有等待在对象上的线程
notify和notifyAll应该用谁
尽可能用notifyall(),谨慎使用notify(),因为notify()只会唤醒一个线程,我们无法确保被唤醒的这个线程一定就是我们需要唤醒的线程,具体表现参见代码。
等待超时模式实现一个连接池
线程的安全性问题
原子性
可见性
当一个线程修改一个共享变量时,另外一个线程能 读到这个修改的值
有序性
导致原因
引用了高速缓存的概念
多核心CPU技术的出现
解决方案
硬件方面:CPU高速缓存
总线锁
缓存锁(MESI)
应用层面:JMM
什么是伪共享(false sharing)
显示锁 和AQS
AQS
以下3个方法来 修改同步 状态
getState():获取当前同步状态
setState(int newState):设置当前同步状态
compareAndSetState(int expext,int update):使用CAS设置当前状态,该方法能够保证状态设置的原子性
同步状态state
state = 0 表示无锁
state > N 表示有锁状态
队列同步器的实现和分析
基于AQS实现自定义同步器
对条件变量的支持
提供多种锁模式
独占、共享
独占模式
public final void acquire(int arg);
public final boolean release(int arg);
共享模式
public final void acquireShared(int arg);
public final boolean releaseShared(int arg);
可中断、不可中断
共享
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException;
throws InterruptedException;
独占
public final void acquireInterruptibly(int arg)
throws InterruptedException;
throws InterruptedException;
待超时时间的
独占
public final boolean tryAcquireNanos(int arg, long nanosTimeout);
共享
public final boolean tryAcquireSharedNanos(int arg, long nanosTimeout) throws InterruptedException;
设计模式
学习AQS的必要性
队列同步器AbstractQueuedSynchronizer(以下简称同步器或AQS),是用来构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。并发包的大师(Doug Lea)期望它能够成为实现大部分同步需求的基础。
AQS使用方式和其中的设计模式
AQS的主要使用方式是继承,子类通过继承AQS并实现它的抽象方法来管理同步状态,在AQS里由一个int型的state来代表这个状态,在抽象方法的实现过程中免不了要对同步状态进行更改,这时就需要使用同步器提供的3个方法(getState()、setState(int newState)和compareAndSetState(int expect,int update))来进行操作,因为它们能够保证状态的改变是安全的。
在实现上,子类推荐被定义为自定义同步组件的静态内部类,AQS自身没有实现任何同步接口,它仅仅是定义了若干同步状态获取和释放的方法来供自定义同步组件使用,同步器既可以支持独占式地获取同步状态,也可以支持共享式地获取同步状态,这样就可以方便实现不同类型的同步组件(ReentrantLock、ReentrantReadWriteLock和CountDownLatch等)。
同步器是实现锁(也可以是任意同步组件)的关键,在锁的实现中聚合同步器。可以这样理解二者之间的关系:
锁是面向使用者的,它定义了使用者与锁交互的接口(比如可以允许两个线程并行访问),隐藏了实现细节;
同步器面向的是锁的实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。锁和同步器很好地隔离了使用者和实现者所需关注的领域。
实现者需要继承同步器并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用同步器提供的模板方法,而这些模板方法将会调用使用者重写的方法。
模板方法模式
同步器的设计基于模板方法模式。模板方法模式的意图是,定义一个操作中的算法的骨架,而将一些步骤的实现延迟到子类中。模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。我们最常见的就是Spring框架里的各种Template。
实际例子
我们开了个蛋糕店,蛋糕店不能只卖一种蛋糕呀,于是我们决定先卖奶油蛋糕,芝士蛋糕和慕斯蛋糕。三种蛋糕在制作方式上一样,都包括造型,烘焙和涂抹蛋糕上的东西。所以可以定义一个抽象蛋糕模型
然后就可以批量生产三种蛋糕
子主题
这样一来,不但可以批量生产三种蛋糕,而且如果日后有扩展,只需要继承抽象蛋糕方法就可以了,十分方便,我们天天生意做得越来越赚钱。突然有一天,我们发现市面有一种最简单的小蛋糕销量很好,这种蛋糕就是简单烘烤成型就可以卖,并不需要涂抹什么食材,由于制作简单销售量大,这个品种也很赚钱,于是我们也想要生产这种蛋糕。但是我们发现了一个问题,抽象蛋糕是定义了抽象的涂抹方法的,也就是说扩展的这种蛋糕是必须要实现涂抹方法,这就很鸡儿蛋疼了。怎么办?我们可以将原来的模板修改为带钩子的模板。
AQS中的方法
模板方法
实现自定义同步组件时,将会调用同步器提供的模板方法,
这些模板方法同步器提供的模板方法基本上分为3类:独占式获取与释放同步状态、共享式获取与释放、同步状态和查询同步队列中的等待线程情况。
可重写的方法
访问或修改同步状态的方法
重写同步器指定的方法时,需要使用同步器提供的如下3个方法来访问或修改同步状态。
getState():获取当前同步状态。
setState(int newState):设置当前同步状态。
compareAndSetState(int expect,int update):使用CAS设置当前状态,该方法能够保证状态设置的原子性。
实现一个自己的独占锁
深入源码
AQS中的数据结构-节点和同步队列
既然说Java中的AQS是CLH队列锁的一种变体实现,毫无疑问,作为队列来说,必然要有一个节点的数据结构来保存我们前面所说的各种域,比如前驱节点,节点的状态等,这个数据结构就是AQS中的内部类Node。作为这个数据结构应该关心些什么信息?
线程信息,肯定要知道我是哪个线程;
队列中线程状态,既然知道是哪一个线程,肯定还要知道线程当前处在什么状态,是已经取消了“获锁”请求,还是在“”等待中”,或者说“即将得到锁”
前驱和后继线程,因为是一个等待队列,那么也就需要知道当前线程前面的是哪个线程,当前线程后面的是哪个线程(因为当前线程释放锁以后,理当立马通知后继线程去获取锁)
所以这个Node类是这么设计的:
线程的2种等待模式:
SHARED:表示线程以共享的模式等待锁(如ReadLock)
EXCLUSIVE:表示线程以互斥的模式等待锁(如ReetrantLock),互斥就是一把锁只能由一个线程持有,不能同时存在多个线程使用同一个锁
线程在队列中的状态枚举:
CANCELLED:值为1,表示线程的获锁请求已经“取消”
SIGNAL:值为-1,表示该线程一切都准备好了,就等待锁空闲出来给我
CONDITION:值为-2,表示线程等待某一个条件(Condition)被满足
PROPAGATE:值为-3,当线程处在“SHARED”模式时,该字段才会被使用上
初始化Node对象时,默认为0
成员变量:
waitStatus:该int变量表示线程在队列中的状态,其值就是上述提到的CANCELLED、SIGNAL、CONDITION、PROPAGATE
prev:该变量类型为Node对象,表示该节点的前一个Node节点(前驱)
next:该变量类型为Node对象,表示该节点的后一个Node节点(后继)
thread:该变量类型为Thread对象,表示该节点的代表的线程
nextWaiter:该变量类型为Node对象,表示等待condition条件的Node节点
当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。同步队列中的节点(Node)用来保存获取同步状态失败的线程引用、等待状态以及前驱和后继节点。
head和tail
AQS还拥有首节点(head)和尾节点(tail)两个引用,一个指向队列头节点,而另一个指向队列尾节点。
节点在同步队列中的增加和移出
当一个线程成功地获取了同步状态(或者锁),其他线程将无法获取到同步状态,也就是获取同步状态失败,AQS会将这个线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列的尾部。而这个加入队列的过程必须要保证线程安全,因此同步器提供了一个基于CAS的设置尾节点的方法:compareAndSetTail(Node expect,Nodeupdate),它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联。
首节点的变化
首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点。设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够成功获取到同步状态,因此设置头节点的方法并不需要使用CAS来保证,它只需要将首节点设置成为原首节点的后继节点并断开原首节点的next引用即可。
独占式同步状态获取与释放
获取
通过调用同步器的acquire(int arg)方法可以获取同步状态,主要完成了同步状态获取、节点构造、加入同步队列以及在同步队列中自旋等待的相关工作,其主要逻辑是
首先调用自定义同步器实现的tryAcquire(int arg)方法,该方法需要保证线程安全的获取同步状态。
如果同步状态获取失败(tryAcquire返回false),则构造同步节点(独占式Node.EXCLUSIVE,同一时刻只能有一个线程成功获取同步状态)并通过addWaiter(Node node)方法将该节点加入到同步队列的尾部,
最后调用acquireQueued(Node node,int arg)方法,使得该节点以“死循环”的方式获取同步状态。如果获取不到则阻塞节点中的线程,而被阻塞线程的唤醒主要依靠前驱节点的出队或阻塞线程被中断来实现。
addWaiter(Node node)方法中
将当前线程包装成Node后,队列不为空的情况下,先尝试把当前节点加入队列并成为尾节点,如果不成功或者队列为空进入enq(final Node node)方法。
在enq(final Node node)方法中,同步器通过“死循环”来保证节点的正确添加,这个死循环中,做了两件事,第一件,如果队列为空,初始化队列,new出一个空节点,并让首节点(head)和尾节点(tail)两个引用都指向这个空节点;第二件事,把当前节点加入队列。
在“死循环”中只有通过CAS将节点设置成为尾节点之后,当前线程才能从该方法返回,否则,当前线程不断地尝试设置。
节点进入同步队列之后,观察acquireQueued(Node node,int arg)方法
其实就是一个自旋的过程,每个节点(或者说每个线程)都在自省地观察,当条件满足,获取到了同步状态,就可以从这个自旋过程中退出,否则依旧留在这个自旋过程中(并会阻塞节点的线程)。
在acquireQueued(final Node node,int arg)方法中,当前线程在“死循环”中尝试获取同步状态,而只有前驱节点是头节点才能够尝试获取同步状态,这是为什么?原因有两个。
第一,头节点是成功获取到同步状态的节点,而头节点的线程释放了同步状态之后,将会唤醒其后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点。
第二,维护同步队列的FIFO原则。
当前线程获取到同步状态后,让首节点(head)这个引用指向自己所在节点。当同步状态获取成功后,当前线程就从acquire方法返回了。如果同步器实现的是锁,那就代表当前线程获得了锁。
当前线程获取到同步状态后,让首节点(head)这个引用指向自己所在节点。当同步状态获取成功后,当前线程就从acquire方法返回了。如果同步器实现的是锁,那就代表当前线程获得了锁。
释放
当前线程获取同步状态并执行了相应逻辑之后,就需要释放同步状态,使得后续节点能够继续获取同步状态。通过调用同步器的release(int arg)方法可以释放同步状态,该方法在释放了同步状态之后,会唤醒其后继节点(进而使后继节点重新尝试获取同步状态)。
该方法执行时,会唤醒首节点(head)所指向节点的后继节点线程,unparkSuccessor(Node node)方法使用LockSupport来唤醒处于等待状态的线程。
而在unparkSuccessor中
这段代码的意思,一般情况下,被唤醒的是head指向节点的后继节点线程,如果这个后继节点处于被cancel状态,(我推测开发者的思路这样的:后继节点处于被cancel状态,意味着当锁竞争激烈时,队列的第一个节点等了很久(一直被还未加入队列的节点抢走锁),包括后续的节点cancel的几率都比较大,所以)先从尾开始遍历,找到最前面且没有被cancel的节点。
总结
在获取同步状态时,同步器维护一个同步队列,获取状态失败的线程都会被加入到队列中并在队列中进行自旋;移出队列(或停止自旋)的条件是前驱节点为头节点且成功获取了同步状态。在释放同步状态时,同步器调用tryRelease(int arg)方法释放同步状态,然后唤醒head指向节点的后继节点。
共享式同步状态获取与释放
共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状态。以读写为例,如果一个程序在进行读操作,那么这一时刻写操作均被阻塞,而读操作能够同时进行。写操作要求对资源的独占式访问,而读操作可以是共享式访问。
在acquireShared(int arg)方法中,同步器调用tryAcquireShared(int arg)方法尝试获取同步状态,tryAcquireShared(int arg)方法返回值为int类型,当返回值大于等于0时,表示能够获取到同步状态。因此,在共享式获取的自旋过程中,成功获取到同步状态并退出自旋的条件就是tryAcquireShared(int arg)方法返回值大于等于0。可以看到,在doAcquireShared(int arg)方法的自旋过程中,如果当前节点的前驱为头节点时,尝试获取同步状态,如果返回值大于等于0,表示该次获取同步状态成功并从自旋过程中退出。
该方法在释放同步状态之后,将会唤醒后续处于等待状态的节点。对于能够支持多个线程同时访问的并发组件(比如Semaphore),它和独占式主要区别在于tryReleaseShared(int arg)方法必须确保同步状态(或者资源数)线程安全释放,一般是通过循环和CAS来保证的,因为释放同步状态的操作会同时来自多个线程。
设计一个同步工具:该工具在同一时刻,只允许至多3个线程同时访问,超过3个线程的访问将被阻塞。
首先,确定访问模式。TrinityLock能够在同一时刻支持多个线程的访问,这显然是共享式访问,因此,需要使用同步器提供的acquireShared(int args)方法等和Shared相关的方法,这就要求TwinsLock必须重写tryAcquireShared(int args)方法和tryReleaseShared(int args)方法,这样才能保证同步器的共享式同步状态的获取与释放方法得以执行。
其次,定义资源数。TrinityLock在同一时刻允许至多三个线程的同时访问,表明同步资源数为3,这样可以设置初始状态status为3,当一个线程进行获取,status减1,该线程释放,则status加1,状态的合法范围为0、1和2,3,其中0表示当前已经有3个线程获取了同步资源,此时再有其他线程对同步状态进行获取,该线程只能被阻塞。在同步状态变更时,需要使用compareAndSet(int expect,int update)方法做原子性保障。
最后,组合自定义同步器。前面的章节提到,自定义同步组件通过组合自定义同步器来完成同步功能,一般情况下自定义同步器会被定义为自定义同步组件的内部类。
Lock的标准用法
Lock的常用API
重入锁
支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁
实现重进入需要解决的2个问题
线程再次获取锁
锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取
锁的最终释放
乐观锁和悲观锁
悲观锁
对数据被外界修改保持保守态度,认为数据很容易就会被其他线程修改,所以修改在数据被修改前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态。
乐观锁
认为数据在一般情况下不会造成冲突,所以在访问记录前不会加排他锁
公平锁和非公平锁的区别
公平锁
表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的
ReentrantLock pairLock = new ReentrantLock(true);
非公平锁
先来不一定先得
ReentrantLock pairLock = new ReentrantLock(false);如果不传,默认为非公平的
独占锁与共享锁
独占锁
保证任何时候都只有一个线程能获得锁,ReentrantLock就是以独占方式实现的
共享锁
可以同时由多个线程持有,ReadWriteLock读写锁,它允许一个资源可以被多线程同时 进行读操作
读写锁
实现缓存
实现分析
读写状态的设计
写锁的获取与释放
读锁的获取与释放
锁降级
Condition
理解 Condition 和 条件变量
使用方法
Condition使用范式
实现分析
ReentrantLock
锁的可重入
重进入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞,该特性的实现需要解决以下两个问题。
1)线程再次获取锁。锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。
2)锁的最终释放。线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于0时表示锁已经成功释放
nonfairTryAcquire方法增加了再次获取同步状态的处理逻辑:通过判断当前线程是否为获取锁的线程来决定获取操作是否成功,如果是获取锁的线程再次请求,则将同步状态值进行增加并返回true,表示获取同步状态成功。同步状态表示锁被一个线程重复获取的次数。
如果该锁被获取了n次,那么前(n-1)次tryRelease(int releases)方法必须返回false,而只有同步状态完全释放了,才能返回true。可以看到,该方法将同步状态是否为0作为最终释放的条件,当同步状态为0时,将占有线程设置为null,并返回true,表示释放成功。
公平和非公平锁
ReentrantLock的构造函数中,默认的无参构造函数将会把Sync对象创建为NonfairSync对象,这是一个“非公平锁”;而另一个构造函数ReentrantLock(boolean fair)传入参数为true时将会把Sync对象创建为“公平锁”FairSync。
nonfairTryAcquire(int acquires)方法,对于非公平锁,只要CAS设置同步状态成功,则表示当前线程获取了锁,而公平锁则不同。tryAcquire方法,该方法与nonfairTryAcquire(int acquires)比较,唯一不同的位置为判断条件多了hasQueuedPredecessors()方法,即加入了同步队列中当前节点是否有前驱节点的判断,如果该方法返回true,则表示有线程比当前线程更早地请求获取锁,因此需要等待前驱线程获取并释放锁之后才能继续获取锁。
ReentrantReadWriteLock的实现
读写状态的设计
读写锁同样依赖自定义同步器来实现同步功能,而读写状态就是其同步器的同步状态
回想ReentrantLock中自定义同步器的实现,同步状态表示锁被一个线程重复获取的次数,而读写锁的自定义同步器需要在同步状态(一个整型变量)上维护多个读线程和一个写线程的状态,使得该状态的设计成为读写锁实现的关键。
如果在一个整型变量上维护多种状态,就一定需要“按位切割使用”这个变量,读写锁将变量切分成了两个部分,高16位表示读,低16位表示写,读写锁是如何迅速确定读和写各自的状态呢?
答案是通过位运算。假设当前同步状态值为S,写状态等于S&0x0000FFFF(将高16位全部抹去),读状态等于S>>>16(无符号补0右移16位)。当写状态增加1时,等于S+1,当读状态增加1时,等于S+(1<<16),也就是S+0x00010000。根据状态的划分能得出一个推论:S不等于0时,当写状态(S&0x0000FFFF)等于0时,则读状态(S>>>16)大于0,即读锁已被获取。
写锁的获取与释放
写锁是一个支持重进入的排它锁。如果当前线程已经获取了写锁,则增加写状态。如果当前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程,则当前线程进入等待状态。
写锁是一个支持重进入的排它锁。如果当前线程已经获取了写锁,则增加写状态。如果当前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程,则当前线程进入等待状态。
该方法除了重入条件(当前线程为获取了写锁的线程)之外,增加了一个读锁是否存在的判断。如果存在读锁,则写锁不能被获取,原因在于:读写锁要确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。
写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放,从而等待的读写线程能够继续访问读写锁,同时前次写线程的修改对后续读写线程可见。
读锁的获取与释放
读锁是一个支持重进入的共享锁,它能够被多个线程同时获取,在没有其他写线程访问(或者写状态为0)时,读锁总会被成功地获取,而所做的也只是(线程安全的)增加读状态。如果当前线程已经获取了读锁,则增加读状态。
如果当前线程在获取读锁时,写锁已被其他线程获取,则进入等待状态。读状态是所有线程获取读锁次数的总和,而每个线程各自获取读锁的次数只能选择保存在ThreadLocal中,由线程自身维护。在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态,成功获取读锁。读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态。
锁的升降级
锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。
锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。
RentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。目的是保证数据可见性,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的。
synchronized
具体表现三种形式
对于普通同步方法,锁是当前实例对象
对于静态同部分昂发,锁是当前类的Class 对象
对于同步代码块,锁是Synchronized括号里配置的对象
可以解决原子性、可见性、排序性
synchronized在JVM中实现的原理
锁存在哪个地方
对象头
锁的实现过程
偏向锁->轻量级锁->重量级锁
支持重入,非公平锁
wait notify
获得锁
__CXQ-EntryList->CAS->park->unpark
了解LockSupport
CLH队列锁
CLH队列锁即Craig, Landin, and Hagersten (CLH) locks。
CLH队列锁也是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程仅仅在本地变量上自旋,它不断轮询前驱的状态,假设发现前驱释放了锁就结束自旋。
CLH队列锁也是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程仅仅在本地变量上自旋,它不断轮询前驱的状态,假设发现前驱释放了锁就结束自旋。
当一个线程需要获取锁时
1.创建一个的QNode,将其中的locked设置为true表示需要获取锁,myPred表示对其前驱结点的引用
2.线程A对tail域调用getAndSet方法,使自己成为队列的尾部,同时获取一个指向其前驱结点的引用myPred
线程B需要获得锁,同样的流程再来一遍
3.线程就在前驱结点的locked字段上旋转,直到前驱结点释放锁(前驱节点的锁值 locked == false)
4.当一个线程需要释放锁时,将当前结点的locked域设置为false,同时回收前驱结点
如上图所示,前驱结点释放锁,线程A的myPred所指向的前驱结点的locked字段变为false,线程A就可以获取到锁。
CLH队列锁的优点是空间复杂度低(如果有n个线程,L个锁,每个线程每次只获取一个锁,那么需要的存储空间是O(L+n),n个线程有n个myNode,L个锁有L个tail)。CLH队列锁常用在SMP体系结构下。
Java中的AQS是CLH队列锁的一种变体实现。
CLH队列锁的优点是空间复杂度低(如果有n个线程,L个锁,每个线程每次只获取一个锁,那么需要的存储空间是O(L+n),n个线程有n个myNode,L个锁有L个tail)。CLH队列锁常用在SMP体系结构下。
Java中的AQS是CLH队列锁的一种变体实现。
了解Condition的实现
Condition的数据结构
等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用,该线程就是在Condition对象上等待的线程,如果一个线程调用了Condition.await()方法,那么该线程将会释放锁、构造成节点加入等待队列并进入等待状态。事实上,节点的定义复用了同步器中节点的定义,也就是说,同步队列和等待队列中节点类型都是同步器的静态内部类。
一个Condition包含一个等待队列,Condition拥有首节点(firstWaiter)和尾节点(lastWaiter)。当前线程调用Condition.await()方法,将会以当前线程构造节点,并将节点从尾部加入等待队列。Condition拥有首尾节点的引用,而新增节点只需要将原有的尾节点nextWaiter指向它,并且更新尾节点即可。上述节点引用更新的过程并没有使用CAS保证,原因在于调用await()方法的线程必定是获取了锁的线程,也就是说该过程是由锁来保证线程安全的。
Lock(更确切地说是同步器)拥有一个同步队列和多个等待队列。
调用Condition的await()方法(或者以await开头的方法),会使当前线程进入等待队列并释放锁,同时线程状态变为等待状态。当从await()方法返回时,当前线程一定获取了Condition相关联的锁。
如果从队列(同步队列和等待队列)的角度看await()方法,当调用await()方法时,相当于同步队列的首节点(获取了锁的节点)移动到Condition的等待队列中。调用该方法的线程成功获取了锁的线程,也就是同步队列中的首节点,该方法会将当前线程构造成节点并加入等待队列中,然后释放同步状态,唤醒同步队列中的后继节点,然后当前线程会进入等待状态。当等待队列中的节点被唤醒,则唤醒节点的线程开始尝试获取同步状态。如果不是通过其他线程调用Condition.signal()方法唤醒,而是对等待线程进行中断,则会抛出InterruptedException。
如图所示,同步队列的首节点并不会直接加入等待队列,而是通过addConditionWaiter()方法把当前线程构造成一个新的节点并将其加入等待队列中。
调用Condition的signal()方法,将会唤醒在等待队列中等待时间最长的节点(首节点),在唤醒节点之前,会将节点移到同步队列中。
调用该方法的前置条件是当前线程必须获取了锁,可以看到signal()方法进行了isHeldExclusively()检查,也就是当前线程必须是获取了锁的线程。接着获取等待队列的首节点,将其移动到同步队列并使用LockSupport唤醒节点中的线程。
通过调用同步器的enq(Node node)方法,等待队列中的头节点线程安全地移动到同步队列。当节点移动到同步队列后,当前线程再使用LockSupport唤醒该节点的线程。
被唤醒后的线程,将从await()方法中的while循环中退出(isOnSyncQueue(Node node)方法返回true,节点已经在同步队列中),进而调用同步器的acquireQueued()方法加入到获取同步状态的竞争中。
成功获取同步状态(或者说锁)之后,被唤醒的线程将从先前调用的await()方法返回,此时该线程已经成功地获取了锁。
Condition的signalAll()方法,相当于对等待队列中的每个节点均执行一次signal()方法,效果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程。
原子操作类
CAS
什么是CAS
CAS存在问题的解决
ABA问题
循环时间长开销大
自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。
只能保证一个共享变量的原子操作
原子更新基本 类型
AtomicBoolean
AtomicInteger
int addAndGet(int delta):以原子方式将输入的数值与实例中的值(AtomicInteger里的value)相加,并返回结果。
boolean compareAndSet(int expect,int update):如果输入的数值等于预期值,则以原子方式将该值设置为输入的值。
•int getAndIncrement():以原子方式将当前值加1,注意,这里返回的是自增前的值
int getAndSet(int newValue):以原子方式设置为newValue的值,并返回旧值。
AtomicLong
原子更新数组
AtomicIntegerArray
主要是提供原子的方式更新数组里的整型
int addAndGet(int i,int delta):以原子方式将输入值与数组中索引i的元素相加
boolean compareAndSet(int i,int expect,int update):如果当前值等于预期值,则以原子方式将数组位置i的元素设置成update值
需要注意的是,数组value通过构造方法传递进去,然后AtomicIntegerArray会将当前数组复制一份,所以当AtomicIntegerArray对内部的数组元素进行修改时,不会影响传入的数组。
AtomicLongArray
AtomicReferenceArray
AtomicIntegerArray
原子更新引用类型
AtomicReference
原子更新引用类型
AtomicReferenceFieldUpdater
原子更新引用类型里的字段
AtomicMarkableReference
原子更新带有标记位的引用类型。可以原子更新一个布尔类
型的标记位和引用类型
型的标记位和引用类型
原子更新字段类
AtomicIntegerFieldUpdater
原子更新整型的字段的更新器。
AtomicLongFieldUpdater
原子更新长整型字段的更新器
AtomicStampedReference
原子更新带有版本号的引用类型
LongAdder
克服在高并发下使用AtomicLong的缺点
把一个变量分成多个变量
AtomicLong
AtomicLong的实现方式是内部有个value 变量,当多线程并发自增,自减时,均通过CAS 指令从机器指令级别操作保证并发的原子性。
并发工具类
LockSupport 工具类
作用:挂起和唤醒 线程
是一个简单的代理类,里面的代码都是使用 Unsafe 类里面的方法。
park()
阻塞线程
unpark()
解除阻塞线程
与 wait()、notify() 的区别
LockSupport 主要是针对 Thread 进进行阻塞处理,可以指定阻塞队列的目标对象,每次可以指定具体的线程唤醒
Object.wait() 是以对象为纬度,阻塞当前的线程和唤醒单个(随机)或者所有线程。
LockSupport 阻塞和解除阻塞线程直接操作的是 Thread。而 Object 的 wait/notify 并不是直接对线程操作,是被动的方法,需要一个 Object 来进行线程的挂起或唤醒。
Thead 在调用 wait 之前,当前线程必须先获得该对象的监视器(syschronized),被唤醒之后需要重新获取到监视器才能继续执行。而 LockSupport 可以随意进行 park 或者 unpark。
Unsafe
功能介绍
数组相关
返回数组元素内存大小
返回数组首元素偏移地址
内存屏障
禁止load、strore重排序
public native void loadFence()
内存屏障,禁止load操作重排序。屏障前的load操作不能被重排序到屏
障后,屏障后的load操作不能被重排序到屏障前
障后,屏障后的load操作不能被重排序到屏障前
public native void storeFence();
内存屏障,禁止store操作重排序。屏障前的store操作不能被重排序到屏障后,
屏障后的store操作不能被重排序到屏障前
屏障后的store操作不能被重排序到屏障前
public native void fullFence();
内存屏障,禁止load、store操作重排序
系统相关
返回内存页大小
返回系统指针大小
线程调度
线程挂起、恢复
//取消阻塞线程
public native void unpark(Object thread);
public native void unpark(Object thread);
//阻塞线程
public native void park(boolean isAbsolute, long time)
public native void park(boolean isAbsolute, long time)
获取、释放锁
//获得对象锁(可重入锁)
public native void monitorEnter(Object o);
public native void monitorEnter(Object o);
//释放对象锁
public native void monitorExit(Object o);
public native void monitorExit(Object o);
//尝试获取对象锁
public native boolean tryMonitorEnter(Object o);
public native boolean tryMonitorEnter(Object o);
对象操作
获取对象成员属性的内存偏移量
非常规对象实例化
存储、获取指定偏移地址的变量值(包含延迟生效、volatile语义)
Class相关
动态创建类(普通类&匿名类)
获取field的内存地址偏移量
监测、确保类初始化
CAS
public final native boolean compareAndSwapObject(Object var1, long
var2, Object var4, Object var5);
var2, Object var4, Object var5);
public final native boolean compareAndSwapInt(Object var1, long
var2, int var4, int var5);
var2, int var4, int var5);
public final native boolean compareAndSwapLong(Object var1, long
var2, long var4, long var6);
var2, long var4, long var6);
内存操作
分配、拷贝、扩充、释放对外内存
//分配内存, 相当于C++的malloc函数
public native long allocateMemory(long bytes);
public native long allocateMemory(long bytes);
//扩充内存
public native long reallocateMemory(long address, long bytes);
public native long reallocateMemory(long address, long bytes);
//释放内存
public native void freeMemory(long address);
public native void freeMemory(long address);
//内存拷贝
public native void copyMemory(Object srcBase, long srcOffset,
Object destBase, long destOffset, long bytes);
public native void copyMemory(Object srcBase, long srcOffset,
Object destBase, long destOffset, long bytes);
设置、获得给定地址中的值
//在给定的内存块中设置值
public native void setMemory(Object o, long offset, long bytes,
byte value);
public native void setMemory(Object o, long offset, long bytes,
byte value);
//获取给定地址值,忽略修饰限定符的访问限制。与此类似操作还有: getInt,
getDouble,getLong,getChar等
public native Object getObject(Object o, long offset);
getDouble,getLong,getChar等
public native Object getObject(Object o, long offset);
//为给定地址设置byte类型的值(当且仅当该内存地址为
allocateMemory分配 时,此方法结果才是确定的)
public native void putByte(long address, byte x);
allocateMemory分配 时,此方法结果才是确定的)
public native void putByte(long address, byte x);
堆外内存
使用对外内存的原因
对垃圾回收停顿的改善
提升程序IO操作的性能
典型应用
DirectByteBuffer
在通信过程中做缓冲池
DirectByteBuffer对于堆外内存的创建、使用、销毁等逻辑均由Unsafe提供的堆外neicun API 实现
应用解析
如何获取Unsafe类??
CountDownLatch
允许一个或多个线程等待其他线程完成操作。
用途
确保某个计算在其需要的所有资源都被初始化之后才执行
确保某个服务在其依赖的所有其他服务都已经启动之后才启动
等待直到每个操作的所有参与者都就绪再执行(比如打麻将时需要等待四个玩家就绪)
案例介绍:
主线程中启动多个线程去执行任务,
并且主线程需要等待所有子线程执行完毕后去汇总
并且主线程需要等待所有子线程执行完毕后去汇总
使用join
使用CounDownLatch
应用场景
实现最大的并行性:有时我们想同时启动多个线程,实现最大程度的并行性。例如,我们想测试一个单例类。如果我们创建一个初始计数为1的CountDownLatch,并让所有线程都在这个锁上等待,那么我们可以很轻松地完成测试。我们只需调用 一次countDown()方法就可以让所有的等待线程同时恢复执行。
CyclicBarrier
它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续运行
代码演示
应用场景
可以用于多线程计算数据,最后合并计算结果的场景
CyclicBarrier和CountDownLatch的区别
CountDownLatch的计数器只能使用一次
CyclicBarrier的计数器可以使用reset方法重置,适合更为复杂的 场景
Semaphore
用来控制同时访问特定资源的线程数量,它通过协调各个线程,以
保证合理的使用公共资源
保证合理的使用公共资源
方法
•intavailablePermits():返回此信号量中当前可用的许可证数。
•intgetQueueLength():返回正在等待获取许可证的线程数。
•booleanhasQueuedThreads():是否有线程正在等待获取许可证。
void reducePermits(int reduction):减少reduction个许可证,是个protected方法。
Collection getQueuedThreads():返回所有等待获取许可证的线程集合,是个protected方法。
应用场景
流量控制
Fork/Join
java下多线程的开发可以我们自己启用多线程,线程池,还可以使用forkjoin,forkjoin可以让我们不去了解诸如Thread,Runnable等相关的知识,只要遵循forkjoin的开发模式,就可以写出很好的多线程并发程序,
分而治之
分治法的设计思想是:将一个难以直接解决的大问题,分割成一些规模较小的相同问题,以便各个击破,分而治之。
分治策略是:对于一个规模为n的问题,若该问题可以容易地解决(比如说规模n较小)则直接解决,否则将其分解为k个规模较小的子问题,这些子问题互相独立且与原问题形式相同(子问题相互之间有联系就会变为动态规范算法),递归地解这些子问题,然后将各子问题的解合并得到原问题的解。这种算法设计策略叫做分治法。
Fork-Join原理
工作密取
即当前线程的Task已经全被执行完毕,则自动取到其他线程的Task池中取出Task继续执行。
ForkJoinPool中维护着多个线程(一般为CPU核数)在不断地执行Task,每个线程除了执行自己职务内的Task之外,还会根据自己工作线程的闲置情况去获取其他繁忙的工作线程的Task,如此一来就能能够减少线程阻塞或是闲置的时间,提高CPU利用率。
Fork/Join实战
Fork/Join使用的标准范式
我们要使用ForkJoin框架,必须首先创建一个ForkJoin任务。它提供在任务中执行fork和join的操作机制,通常我们不直接继承ForkjoinTask类,只需要直接继承其子类。
1. RecursiveAction,用于没有返回结果的任务
2. RecursiveTask,用于有返回值的任务
task要通过ForkJoinPool来执行,使用submit 或 invoke 提交,两者的区别是:invoke是同步执行,调用之后需要等待任务完成,才能执行后面的代码;submit是异步执行。
join()和get方法当任务完成的时候返回计算结果。
在我们自己实现的compute方法里,首先需要判断任务是否足够小,如果足够小就直接执行任务。如果不足够小,就必须分割成两个子任务,每个子任务在调用invokeAll方法时,又会进入compute方法,看看当前子任务是否需要继续分割成孙任务,如果不需要继续分割,则执行当前子任务并返回结果。使用join方法会等待子任务执行完并得到其结果。
Exchange
Exchanger(交换者)是一个用于线程间协作的工具类。Exchanger用于进行线程间的数据交换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过exchange方法交换数据,如果第一个线程先执行exchange()方法,它会一直等待第二个线程也执行exchange方法,当两个线程都到达同步点时,这两个线程就可以交换数据,将本线程生产出来的数据传递给对方。
代码演示
Callable、Future 和 Futuretask
Runnable是一个接口,在它里面只声明了一个run()方法,由于run()方法返回值为void类型,所以在执行完任务之后无法返回任何结果。
Callable位于java.util.concurrent包下,它也是一个接口,在它里面也只声明了一个方法,只不过这个方法叫做call(),这是一个泛型接口,call()函数返回的类型就是传递进来的V类型。
Future就是对于具体的Runnable或者Callable任务的执行结果进行取消、查询是否完成、获取结果。必要时可以通过get方法获取执行结果,该方法会阻塞直到任务返回结果。
因为Future只是一个接口,所以是无法直接用来创建对象使用的,因此就有了下面的FutureTask。
FutureTask类实现了RunnableFuture接口,RunnableFuture继承了Runnable接口和Future接口,而FutureTask实现了RunnableFuture接口。所以它既可以作为Runnable被线程执行,又可以作为Future得到Callable的返回值。
因此我们通过一个线程运行Callable,但是Thread不支持构造方法中传递Callable的实例,所以我们需要通过FutureTask把一个Callable包装成Runnable,然后再通过这个FutureTask拿到Callable运行后的返回值。
要new一个FutureTask的实例,有两种方法
线程池
作用
利用线程池管理并复用线程、控制最大并发数等。
实现任务线程队列缓存策略和拒绝机制
实现某些与时间相关的功能,如定时功能、周期执行等
隔离线程环境
ThreadPoolExecutor
参数
CorePoolSize
常驻核心线程数
maximumPoolSize
线程池能够容纳同时执行的最大线程数
keepAliveTime
线程池中的线程空闲时间
TimeUnit
时间单位,通常为TimeUnit.SECONDS
workQueue
缓存队列
workBLockingQueue
ArrayBlockingQueue
基于数组的阻塞队列实现
内部没有实现读写分离
有界队列
使用一把锁实现
LinkedBlockingQueue
基于链表的阻塞队列
锁分离(两把锁)
可以有界也可以无界
PriorityBlockingQueue
基于优先级的阻塞队列,传入对象必须传comparable接口
内部线程采用的是公平锁
无界队列
DelayQueue
带有延迟的Queue,其中的元素只有当其指定的延迟时间到了,才能够从队列中获取到该元素
元素必须实现Delayed接口
没有大小限制的队列
应用场景
对缓存超时的数据进行移除
当向缓存中添加key-value对时,如果这个key在缓存中存在并且还没有过期,需要用这个key对应的新过期时间
为了能够让DelayQueue将其已保存的key删除,需要重写实现Delayed接口添加到DelayQueue的DelayedItem的hashCode函数和equals函数
当缓存关闭,监控程序也应关闭,因而监控线程应当用守护线程
任务超时处理(例如订单超时,
饿了么订餐:下单后60s之后给用户发送短信通知))
饿了么订餐:下单后60s之后给用户发送短信通知))
实现方式
使用数据库定时任务,每隔几秒扫描订单表,找出超时订单后关闭
使用spring的@Scheduled注解启动定时任务或者使用Quartz任务管理器,定时触发任务,处理超时订单
使用消息中间件,Active或者RocketMQ提供了延迟消息队列,下单后往延迟消息队列中发消息,超时后,消费端会接收到一条延迟的 订单消息,并做相应处理
使用DelayQueue来实现
其他方式
空闲连接的关闭
SynchronousQueue
一种没有缓冲的队列,生产者产生的数据直接会被消费者获取并消费
LinkedTransferQueue
一个由链表结构组成的无界阻塞队列
LinkedBlockingDeque
一个由链表结构组成的双向阻塞队列
threadFactory
可以通过线程工厂给每个创建出来的线程设置更有意义的名字。线程池的命名时通过给这个factory增加组前缀来实现的。在虚拟机栈分析时,就可以知道线程任务由哪个线程工厂产生的。
使用Guava设置线程名字
自定义实现:ThreadFactory
rejectHandler
AbortPolicy
丢弃并抛出RejectedExecutionException
CallerRunsPolicy
只用调用者所在线程来运行任务
DiscardOldestPolicy
丢弃队列里最近的一个任务,并执行当前任务
DiscardPolicy
不处理,丢弃掉
自定义实现:实现接口RejectedExecutionHandler
友好的拒绝策略
保存到数据库进行削峰填谷。在 空闲时再提取出来执行
转向某个提示页
打印日志
流程
1、如果当前运行的线程少于corePoolSize,则创建新线程来执行任务(执行这一步骤要获取全局锁)
2、如果运行的线程等于或多于,则将 任务加入BlockingQueue
3、如果无法将任务将入BlockingQueue(队列已满),则创建新的线程来处理任务(需获取全局锁)
4、如果创建新线程将使当前运行的线程超出maximumPoolSize,任务将被拒绝,并调用RejectedExecutionHandler.rejectedExecution()方法。
源码详解
线程池的监控
可以监控以下属性
taskCount
线程池需要执行的任务数量
completedTaskCount
线程池在运行过程中已完成的任务数量
largestPoolSize
线程池里曾经创建过的最大线程数量
getPoolSize
线程池的线程数量
getActiveCount
获取活动的线程数
实现方法
重写以下方法
beforeExecute
afterExecute
terminated
使用线程池注意事项
合理设置各类参数,应根据实际业务场景来设置合理的工作线程数
任务的性质
CPU密集型任务
尽可能小
IO 密集型任务
按照CPU核心数的倍数去设置
混合型任务
任务的优先级
高
中
低
任务的时间执行长短
长
中
短
任务的依赖性
是否依赖其他系统资源,如数据库连接
获取CPU个数
线程资源必须通过线程池提供,不允许在应用中自行显示创建线程
创建线程或线程池时请指定有意义的线程名字,方便出错时回溯
建议使用有界队列
并发容器
预备知识
Hash
就是把任意长度的输入(又叫做预映射, pre-image),通过散列算法,变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。常用HASH函数:直接取余法、乘法取整法、平方取中法。
处理冲突方法:
开放寻址法
再散列法
链地址法(拉链法)
常用hash算法的介绍:
MD4
MD5它对输入仍以512位分组,其输出是4个32位字的级联
SHA-1及其他
位运算
二进制
常用位运算
位与 & (1&1=1 0&0=0 1&0=0)
位或 | (1|1=1 0|0=0 1|0=1)
位非 ~(~1=0 ~0=1)
位异或 ^ (1^1=0 1^0=1 0^0=0)
有符号右移>>(若正数,高位补0,负数,高位补1)
有符号左移<<
无符号右移>>>(不论正负,高位均补0)
有趣的取模性质:取模a % (2^n) 等价于 a & (2^n - 1),所以在map里的数组个数一定是2的乘方数,计算key值在哪个元素中的时候,就用位运算来快速定位。
位运算运用场景
Java中的类修饰符、成员变量修饰符、方法修饰符,比如Class类中
Java容器中的HashMap和ConcurrentHashMap的实现
权限控制或者商品属性
简单可逆加密,比如异或运算(1^1=0 ; 0^1=1 )
为什么要使用ConcurrentHashMap
1.7中HashMap死循环分析
在多线程环境下,使用HashMap进行put操作会引起死循环,导致CPU利用率接近100%,HashMap在并发执行put操作时会引起死循环,是因为多线程会导致HashMap的Entry链表
形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry。那么这个死循环是如何生成的呢?我们来仔细分析下。
形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry。那么这个死循环是如何生成的呢?我们来仔细分析下。
HashMap扩容流程
引发死循环,是在HashMap的扩容操作中。
正常的扩容操作是这个流程。HashMap的扩容在put操作中会触发扩容,主要是三个方法:
综合来说,HashMap一次扩容的过程:
1、取当前table的2倍作为新table的大小
2、根据算出的新table的大小new出一个新的Entry数组来,名为newTable
3、轮询原table的每一个位置,将每个位置上连接的Entry,算出在新table上的位置,并以链表形式连接
4、原table上的所有Entry全部轮询完毕之后,意味着原table上面的所有Entry已经移到了新的table上,HashMap中的table指向newTable
单线程的扩容
现在hashmap中有三个元素,Hash表的size=2, 所以key = 3, 7, 5,在mod 2以后都冲突在table[1]这里了。
按照方法中的代码
对table[1]中的链表来说,进入while循环,此时e=key(3),那么next=key(7),经过计算重新定位e=key(3)在新表中的位置,并把e=key(3)挂在newTable[3]的位置
子主题
这样循环下去,将table[1]中的链表循环完成后,于是HashMap就完成了扩容
多线程的扩容
初始的HashM还是:
我们现在假设有两个线程并发操作,都进入了扩容操作
回顾我们的扩容代码,我们假设,线程1执行到Entry next = e.next;时被操作系统调度挂起了,而线程2执行完成了扩容操作
于是,在线程1,2看来,就应该是这个样子
接下来,线程1被调度回来执行:
(1)
(2)
(3))
(4)
(5)
(6)
(7)
循环列表产生后,一旦线程1调用get(11,15之类的元素)时,就会进入一个死循环的情况,将CPU的消耗到100%。
总结
HashMap之所以在并发下的扩容造成死循环,是因为,多个线程并发进行时,因为一个线程先期完成了扩容,将原Map的链表重新散列到自己的表中,并且链表变成了倒序,后一个线程再扩容时,又进行自己的散列,再次将倒序链表变为正序链表。于是形成了一个环形链表,当get表中不存在的元素时,造成死循环。
ConcurrentHashMap
使用
除了Map系列应该有的线程安全的get,put等方法外,ConcurrentHashMap还提供了一个在并发下比较有用的方法 putIfAbsent,如果传入key对应的value已经存在,就返回存在的value,不进行替换。如果不存在,就添加key和value,返回null。在代码层面它的作用类似于:
synchronized(map){
if (map.get(key) == null){
return map.put(key, value);
} else{
return map.get(key);
}
}
if (map.get(key) == null){
return map.put(key, value);
} else{
return map.get(key);
}
}
它让上述代码的整个操作是线程安全的。
ConcurrentHashMap实现分析
在1.7下的实现
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁(ReentrantLock),在ConcurrentHashMap里扮演锁的角色;HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组。Segment的结构和HashMap类似,是一种数组和链表结构。一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得与它对应的Segment锁。
构造方法和初始化
ConcurrentHashMap初始化方法是通过initialCapacity、loadFactor和concurrencyLevel(参数concurrencyLevel是用户估计的并发级别,就是说你觉得最多有多少线程共同修改这个map,根据这个来确定Segment数组的大小concurrencyLevel默认是DEFAULT_CONCURRENCY_LEVEL = 16;)等几个参数来初始化segment数组、段偏移量segmentShift、段掩码segmentMask和每个segment里的HashEntry数组来实现的。
并发级别可以理解为程序运行时能够同时更新ConccurentHashMap且不产生锁竞争的最大线程数,实际上就是ConcurrentHashMap中的分段锁个数,即Segment[]的数组长度。ConcurrentHashMap默认的并发度为16,但用户也可以在构造函数中设置并发度。当用户设置并发度时,ConcurrentHashMap会使用大于等于该值的最小2幂指数作为实际并发度(假如用户设置并发度为17,实际并发度则为32)。
如果并发度设置的过小,会带来严重的锁竞争问题;如果并发度设置的过大,原本位于同一个Segment内的访问会扩散到不同的Segment中,CPU cache命中率会下降,从而引起程序性能下降。(文档的说法是根据你并发的线程数量决定,太多会导性能降低)
segments数组的长度ssize是通过concurrencyLevel计算得出的。为了能通过按位与的散列算法来定位segments数组的索引,必须保证segments数组的长度是2的N次方(power-of-two size),所以必须计算出一个大于或等于concurrencyLevel的最小的2的N次方值来作为segments数组的长度。假如concurrencyLevel等于14、15或16,ssize都会等于16,即容器里锁的个数也是16。
在默认情况下, ssize = 16,initialCapacity = 16,loadFactor = 0.75f,那么cap = 1,threshold = (int) cap * loadFactor = 0。
既然ConcurrentHashMap使用分段锁Segment来保护不同段的数据,那么在插入和获取元素的时候,必须先通过散列算法定位到Segment。ConcurrentHashMap会首先使用Wang/Jenkins hash的变种算法对元素的hashCode进行一次再散列。
ConcurrentHashMap完全允许多个读操作并发进行,读操作并不需要加锁。ConcurrentHashMap实现技术是保证HashEntry几乎是不可变的以及volatile关键字。
get操作
get操作先经过一次再散列,然后使用这个散列值通过散列运算定位到Segment(使用了散列值的高位部分),再通过散列算法定位到table(使用了散列值的全部)。整个get过程,没有加锁,而是通过volatile保证get总是可以拿到最新值。
put操作
ConcurrentHashMap 初始化的时候会初始化第一个槽 segment[0],对于其他槽,在插入第一个值的时候再进行初始化。
ensureSegment方法考虑了并发情况,多个线程同时进入初始化同一个槽 segment[k],但只要有一个成功就可以了。
具体实现是
put方法会通过tryLock()方法尝试获得锁,获得了锁,node为null进入try语句块,没有获得锁,调用scanAndLockForPut方法自旋等待获得锁。
scanAndLockForPut方法里在尝试获得锁的过程中会对对应hashcode的链表进行遍历,如果遍历完毕仍然找不到与key相同的HashEntry节点,则为后续的put操作提前创建一个HashEntry。当tryLock一定次数后仍无法获得锁,则通过lock申请锁。
在获得锁之后,Segment对链表进行遍历,如果某个HashEntry节点具有相同的key,则更新该HashEntry的value值,
否则新建一个HashEntry节点,采用头插法,将它设置为链表的新head节点并将原头节点设为新head的下一个节点。新建过程中如果节点总数(含新建的HashEntry)超过threshold,则调用rehash()方法对Segment进行扩容,最后将新建HashEntry写入到数组中。
rehash操作
扩容是新创建了数组,然后进行迁移数据,最后再将 newTable 设置给属性 table。
为了避免让所有的节点都进行复制操作:由于扩容是基于2的幂指来操作,假设扩容前某HashEntry对应到Segment中数组的index为i,数组的容量为capacity,那么扩容后该HashEntry对应到新数组中的index只可能为i或者i+capacity,因此很多HashEntry节点在扩容前后index可以保持不变。
假设原来table长度为4,那么元素在table中的分布是这样的
扩容后table长度变为8,那么元素在table中的分布变成:
可以看见 hash值为34和56的下标保持不变,而15,23,77的下标都是在原来下标的基础上+4即可,可以快速定位和减少重排次数。
该方法没有考虑并发,因为执行该方法之前已经获取了锁。
该方法没有考虑并发,因为执行该方法之前已经获取了锁。
remove操作
与put方法类似,都是在操作前需要拿到锁,以保证操作的线程安全性。
ConcurrentHashMap的弱一致性
对链表遍历判断是否存在key相同的节点以及获得该节点的value。但由于遍历过程中其他线程可能对链表结构做了调整,因此get和containsKey返回的可能是过时的数据,这一点是ConcurrentHashMap在弱一致性上的体现。如果要求强一致性,那么必须使用Collections.synchronizedMap()方法。
size、containsValue
这些方法都是基于整个ConcurrentHashMap来进行操作的,他们的原理也基本类似:首先不加锁循环执行以下操作:循环所有的Segment,获得对应的值以及所有Segment的modcount之和。在 put、remove 和 clean 方法里操作元素前都会将变量 modCount 进行变动,如果连续两次所有Segment的modcount和相等,则过程中没有发生其他线程修改ConcurrentHashMap的情况,返回获得的值。
当循环次数超过预定义的值时,这时需要对所有的Segment依次进行加锁,获取返回值后再依次解锁。所以一般来说,应该避免在多线程环境下使用size和containsValue方法。
当循环次数超过预定义的值时,这时需要对所有的Segment依次进行加锁,获取返回值后再依次解锁。所以一般来说,应该避免在多线程环境下使用size和containsValue方法。
在1.8下的实现
改进一:取消segments字段,直接采用transient volatile HashEntry[] table保存数据,采用table数组元素作为锁,从而实现了对缩小锁的粒度,进一步减少并发冲突的概率,并大量使用了采用了 CAS + synchronized 来保证并发安全性。
改进二:将原先table数组+单向链表的数据结构,变更为table数组+单向链表+红黑树的结构。对于hash表来说,最核心的能力在于将key hash之后能均匀的分布在数组中。如果hash之后散列的很均匀,那么table数组中的每个队列长度主要为0或者1。但实际情况并非总是如此理想,虽然ConcurrentHashMap类默认的加载因子为0.75,但是在数据量过大或者运气不佳的情况下,还是会存在一些队列长度过长的情况,如果还是采用单向列表方式,那么查询某个节点的时间复杂度为O(n);因此,对于个数超过8(默认值)的列表,jdk1.8中采用了红黑树的结构,那么查询的时间复杂度可以降低到O(logN),可以改进性能。
使用 Node(1.7 为 Entry) 作为链表的数据结点,仍然包含 key,value,hash 和 next 四个属性。 红黑树的情况使用的是 TreeNode(extends Node)。
根据数组元素中,第一个结点数据类型是 Node 还是 TreeNode 可以判断该位置下是链表还是红黑树。
用于判断是否需要将链表转换为红黑树的阈值
用于判断是否需要将红黑树转换为链表的阈值
核心数据结构和属性
Node
Node是最核心的内部类,它包装了key-value键值对
定义基本和1.7中的HashEntry相同。而这个map本身所持有的也是一个Node型的数组
增加了一个find方法来用以辅助map.get()方法。其实就是遍历链表,子类中会覆盖这个方法。
在map中还定义了Segment这个类,不过只是为了向前兼容而已,不做过多考虑。
TreeNode
树节点类,另外一个核心的数据结构。当链表长度过长的时候,会转换为TreeNode。
与1.8中HashMap不同点:
1、它并不是直接转换为红黑树,而是把这些结点放在TreeBin对象中,由TreeBin完成对红黑树的包装。
2、TreeNode在ConcurrentHashMap扩展自Node类,而并非HashMap中的扩展自LinkedHashMap.Entry类,也就是说TreeNode带有next指针。
TreeBin
负责TreeNode节点。它代替了TreeNode的根节点,也就是说在实际的ConcurrentHashMap“数组”中,存放的是TreeBin对象,而不是TreeNode对象。另外这个类还带有了读写锁机制。
特殊的ForwardingNode
一个特殊的 Node 结点,hash 值为 -1,其中存储 nextTable 的引用。有 table 发生扩容的时候,ForwardingNode 发挥作用,作为一个占位符放在 table 中表示当前结点为 null 或者已经被移动。
sizeCtl
用来控制 table 的初始化和扩容操作。
负数代表正在进行初始化或扩容操作
-1代表正在初始化
-N 表示有N-1个线程正在进行扩容操作
0为默认值,代表当时的table还没有被初始化
正数表示初始化大小或Map中的元素达到这个数量时,需要进行扩容了。
核心方法
构造方法
可以发现,在new出一个map的实例时,并不会创建其中的数组等等相关的部件,只是进行简单的属性设置而已,同样的,table的大小也被规定为必须是2的乘方数。
真正的初始化在放在了是在向ConcurrentHashMap中插入元素的时候发生的。如调用put、computeIfAbsent、compute、merge等方法的时候,调用时机是检查table==null。
get操作
get方法比较简单,给定一个key来确定value的时候,必须满足两个条件 key相同 hash值相同,对于节点可能在链表或树上的情况,需要分别去查找。
put操作
总结来说,put方法就是,沿用HashMap的put方法的思想,根据hash值计算这个新插入的点在table中的位置i,如果i位置是空的,直接放进去,否则进行判断,如果i位置是树节点,按照树的方式插入新的节点,否则把i插入到链表的末尾。
整体流程上,就是首先定义不允许key或value为null的情况放入 对于每一个放入的值,首先利用spread方法对key的hashcode进行一次hash计算,由此来确定这个值在table中的位置。
如果这个位置是空的,那么直接放入,而且不需要加锁操作。
如果这个位置存在结点,说明发生了hash碰撞,首先判断这个节点的类型。如果是链表节点,则得到的结点就是hash值相同的节点组成的链表的头节点。需要依次向后遍历确定这个新加入的值所在位置。如果遇到hash值与key值都与新加入节点是一致的情况,则只需要更新value值即可。否则依次向后遍历,直到链表尾插入这个结点。如果加入这个节点以后链表长度大于8,就把这个链表转换成红黑树。如果这个节点的类型已经是树节点的话,直接调用树节点的插入方法进行插入新的值。
remove
移除方法的基本流程和put方法很类似,只不过操作由插入数据变为移除数据而已,而且如果存在红黑树的情况下,会检查是否需要将红黑树转为链表的步骤。不再重复讲述。
treeifyBin
用于将过长的链表转换为TreeBin对象。但是他并不是直接转换,而是进行一次容量判断,如果容量没有达到转换的要求,直接进行扩容操作并返回;如果满足条件才将链表的结构转换为TreeBin ,这与HashMap不同的是,它并没有把TreeNode直接放入红黑树,而是利用了TreeBin这个小容器来封装所有的TreeNode。
size
在JDK1.8版本中,对于size的计算,在扩容和addCount()方法就已经有处理了,可以注意一下Put函数,里面就有addCount()函数,早就计算好的,然后你size的时候直接给你。JDK1.7是在调用size()方法才去计算,其实在并发集合中去计算size是没有多大的意义的,因为size是实时在变的。
在具体实现上,计算大小的核心方法都是 sumCount()
可以看见,统计数量时使用了 baseCount、和CounterCell 类型的变量counterCells 。其实baseCount就是记录容器数量的,而counterCells则是记录CAS更新baseCounter值时,由于高并发而导致失败的值。这两个变量的变化在addCount() 方法中有体现,大致的流程就是:
1、对 baseCount 做 CAS 自增操作。
2、如果并发导致 baseCount CAS 失败了,则使用 counterCells。
3、如果counterCells CAS 失败了,在 fullAddCount 方法中,会继续死循环操作,直到成功。
HashTable
HashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable的效率非常低下。因为当一个线程访问HashTable的同步方法,其他线程也访问HashTable的同步方法时,会进入阻塞或轮询状态。如线程1使用put进行元素添加,线程2不但不能使用put方法添加元素,也不能使用get方法来获取元素,所以竞争越激烈效率越低。
ConcurrentSkipList系列
ConcurrentSkipListMap 有序Map
ConcurrentSkipListSet 有序Set
TreeMap和TreeSet使用红黑树按照key的顺序(自然顺序、自定义顺序)来使得键值对有序存储,但是只能在单线程下安全使用
多线程下想要使键值对按照key的顺序来存储,则需要使用ConcurrentSkipListMap和ConcurrentSkipListSet,分别用以代替TreeMap和TreeSet,存入的数据按key排序。在实现上,ConcurrentSkipListSet 本质上就是ConcurrentSkipListMap。
了解什么是SkipList?
二分查找和AVL树查找
二分查找要求元素可以随机访问,所以决定了需要把元素存储在连续内存。这样查找确实很快,但是插入和删除元素的时候,为了保证元素的有序性,就需要大量的移动元素了。
如果需要的是一个能够进行二分查找,又能快速添加和删除元素的数据结构,首先就是二叉查找树,二叉查找树在最坏情况下可能变成一个链表。
于是,就出现了平衡二叉树,根据平衡算法的不同有AVL树,B-Tree,B+Tree,红黑树等,但是AVL树实现起来比较复杂,平衡操作较难理解,这时候就可以用SkipList跳跃表结构。
什么是跳表
传统意义的单链表是一个线性结构,向有序的链表中插入一个节点需要O(n)的时间,查找操作需要O(n)的时间。
如果我们使用上图所示的跳跃表,就可以减少查找所需时间为O(n/2),因为我们可以先通过每个节点的最上面的指针先进行查找,这样子就能跳过一半的节点。
比如我们想查找50,首先和20比较,大于20之后,在和40进行比较,然后在和70进行比较,发现70大于50,说明查找的点在40和50之间,从这个过程中,我们可以看出,查找的时候跳过了30。
跳跃表其实也是一种通过“空间来换取时间”的一个算法,令链表的每个结点不仅记录next结点位置,还可以按照level层级分别记录后继第level个结点。此法使用的就是“先大步查找确定范围,再逐渐缩小迫近”的思想进行的查找。跳跃表在算法效率上很接近红黑树。
跳跃表又被称为概率,或者说是随机化的数据结构,目前开源软件 Redis 和 lucence都有用到它。
都是线程安全的Map实现,ConcurrentHashMap的性能和存储空间要优于ConcurrentSkipListMap,但是ConcurrentSkipListMap有一个功能: 它会按照键的顺序进行排序。
ConcurrentSkipListMap和TreeMap的不同
ConcurrentSkipListMap是线程安全的有序的哈希表,适用于高并发的场景
ConcurrentSkipListMap和TreeMap,它们虽然都是有序的哈希表。但是,第一,它们的线程安全机制不同,TreeMap是非线程安全的,而ConcurrentSkipListMap是线程安全的。第二,ConcurrentSkipListMap是通过跳表实现的,而TreeMap是通过红黑树实现的。
在非多线程的情况下,应当尽量使用TreeMap。此外对于并发性相对较低的并行程序可以使用Collections.synchronizedSortedMap将TreeMap进行包装,也可以提供较好的效率。对于高并发程序,应当使用ConcurrentSkipListMap,能够提供更高的并发度。
ConcurrentSkipListMap 和 ConcurrentHashMap 不不同
所以在多线程程序中,如果需要对Map的键值进行排序时,请尽量使用ConcurrentSkipListMap,可能得到更好的并发度。
在4线程1.6万数据的条件下,ConcurrentHashMap 存取速度是ConcurrentSkipListMap 的4倍左右。
但ConcurrentSkipListMap有几个ConcurrentHashMap 不能比拟的优点:
1、ConcurrentSkipListMap 的key是有序的。
2、ConcurrentSkipListMap 支持更高的并发。ConcurrentSkipListMap 的存取时间是log(N),和线程数几乎无关。也就是说在数据量一定的情况下,并发的线程越多,ConcurrentSkipListMap越能体现出他的优势。
所以在多线程程序中,如果需要对Map的键值进行排序时,请尽量使用ConcurrentSkipListMap,可能得到更好的并发度。
注意,调用ConcurrentSkipListMap的size时,由于多个线程可以同时对映射表进行操作,所以映射表需要遍历整个链表才能返回元素个数,这个操作是个O(log(n))的操作。
ConcurrentLinkedQueue
无界非阻塞队列,它是一个基于链表的无界线程安全队列。该队列的元素遵循先进先出的原则。头是最先加入的,尾是最近加入的。插入元素是追加到尾上。提取一个元素是从头提取。
大家可以看成是LinkedList的并发版本,常用方法:
concurrentLinkedQueue.add("c");
concurrentLinkedQueue.offer("d"); // 将指定元素插入到此队列的尾部。
concurrentLinkedQueue.peek(); // 检索并不移除此队列的头,如果此队列为空,则返回 null。
concurrentLinkedQueue.poll(); // 检索并移除此队列的头,如果此队列为空,则返回 null。
写时复制容器
什么是写时复制容器
CopyOnWriteArrayList和CopyOnWriteArraySet
CopyOnWrite容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。
这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。如果读的时候有多个线程正在向CopyOnWriteArrayList添加数据,读还是会读到旧的数据,因为写的时候不会锁住旧的CopyOnWriteArrayList。
CopyOnWrite并发容器用于对于绝大部分访问都是读,且只是偶尔写的并发场景。比如白名单,黑名单,商品类目的访问和更新场景,假如我们有一个搜索网站,用户在这个网站的搜索框中,输入关键字搜索内容,但是某些关键字不允许被搜索。这些不能被搜索的关键字会被放在一个黑名单当中,黑名单每天晚上更新一次。当用户搜索时,会检查当前关键字在不在黑名单当中,如果在,则提示不能搜索。
使用CopyOnWriteMap需要注意两件事情:
1. 减少扩容开销。根据实际需要,初始化CopyOnWriteMap的大小,避免写时CopyOnWriteMap扩容的开销。
2. 使用批量添加。因为每次添加,容器每次都会进行复制,所以减少添加次数,可以减少容器的复制次数。
写时复制容器的问题
性能问题
每次修改都创建一个新数组,然后复制所有内容,如果数组比较大,修改操作又比较频繁,可以想象,性能是很低的,而且内存开销会很大。
数据一致性问题。
CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,不要使用CopyOnWrite容器。
阻塞队列BlockingQueue
队列
队列是一种特殊的线性表,特殊之处在于它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作,和栈一样,队列是一种操作受限制的线性表。进行插入操作的端称为队尾,进行删除操作的端称为队头。
在队列中插入一个队列元素称为入队,从队列中删除一个队列元素称为出队。因为队列只允许在一端插入,在另一端删除,所以只有最早进入队列的元素才能最先从队列中删除,故队列又称为先进先出(FIFO—first in first out)线性表。
什么是阻塞队列
1)支持阻塞的插入方法:意思是当队列满时,队列会阻塞插入元素的线程,直到队列不满。
2)支持阻塞的移除方法:意思是在队列为空时,获取元素的线程会等待队列变为非空。
在并发编程中使用生产者和消费者模式能够解决绝大多数并发问题。该模式通过平衡生产线程和消费线程的工作能力来提高程序整体处理数据的速度。
在线程世界里,生产者就是生产数据的线程,消费者就是消费数据的线程。在多线程开发中,如果生产者处理速度很快,而消费者处理速度很慢,那么生产者就必须等待消费者处理完,才能继续生产数据。同样的道理,如果消费者的处理能力大于生产者,那么消费者就必须等待生产者。
为了解决这种生产消费能力不均衡的问题,便有了生产者和消费者模式。生产者和消费者模式是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通信,而是通过阻塞队列来进行通信,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。
阻塞队列常用于生产者和消费者的场景,生产者是向队列里添加元素的线程,消费者是从队列里取元素的线程。阻塞队列就是生产者用来存放元素、消费者用来获取元素的容器。
抛出异常:当队列满时,如果再往队列里插入元素,会抛出IllegalStateException("Queuefull")异常。当队列空时,从队列里获取元素会抛出NoSuchElementException异常。
·返回特殊值:当往队列插入元素时,会返回元素是否插入成功,成功返回true。如果是移除方法,则是从队列里取出一个元素,如果没有则返回null。
一直阻塞:当阻塞队列满时,如果生产者线程往队列里put元素,队列会一直阻塞生产者线程,直到队列可用或者响应中断退出。当队列空时,如果消费者线程从队列里take元素,队列会阻塞住消费者线程,直到队列不为空。
超时退出:当阻塞队列满时,如果生产者线程往队列里插入元素,队列会阻塞生产者线程一段时间,如果超过了指定的时间,生产者线程就会退出。
常用阻塞队列
ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列。
LinkedBlockingQueue:一个由链表结构组成的有界阻塞队列
PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。
DelayQueue:一个使用优先级队列实现的无界阻塞队列
SynchronousQueue:一个不存储元素的阻塞队列。
LinkedTransferQueue:一个由链表结构组成的无界阻塞队列
LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。
有界无界?
有限队列就是长度有限,满了以后生产者会阻塞,无界队列就是里面能放无数的东西而不会因为队列长度限制被阻塞,当然空间限制来源于系统资源的限制,如果处理不及时,导致队列越来越大越来越大,超出一定的限制致使内存超限,操作系统或者JVM帮你解决烦恼,直接把你 OOM kill 省事了。
ArrayBlockingQueue
是一个用数组实现的有界阻塞队列。此队列按照先进先出(FIFO)的原则对元素进行排序。默认情况下不保证线程公平的访问队列,所谓公平访问队列是指阻塞的线程,可以按照阻塞的先后顺序访问队列,即先阻塞线程先访问队列。非公平性是对先等待的线程是非公平的,当队列可用时,阻塞的线程都可以争夺访问队列的资格,有可能先阻塞的线程最后才访问队列。初始化时有参数可以设置
LinkedBlockingQueue
是一个用链表实现的有界阻塞队列。此队列的默认和最大长度为Integer.MAX_VALUE。此队列按照先进先出的原则对元素进行排序。
Array实现和Linked实现的区别
1. 队列中锁的实现不同
ArrayBlockingQueue实现的队列中的锁是没有分离的,即生产和消费用的是同一个锁;
LinkedBlockingQueue实现的队列中的锁是分离的,即生产用的是putLock,消费是takeLock
2. 在生产或消费时操作不同
ArrayBlockingQueue实现的队列中在生产和消费的时候,是直接将枚举对象插入或移除的;
LinkedBlockingQueue实现的队列中在生产和消费的时候,需要把枚举对象转换为Node进行插入或移除,会影响性能
3. 队列大小初始化方式不同
ArrayBlockingQueue实现的队列中必须指定队列的大小;
LinkedBlockingQueue实现的队列中可以不指定队列的大小,但是默认是Integer.MAX_VALUE
PriorityBlockingQueue
PriorityBlockingQueue是一个支持优先级的无界阻塞队列。默认情况下元素采取自然顺序升序排列。也可以自定义类实现compareTo()方法来指定元素排序规则,或者初始化PriorityBlockingQueue时,指定构造参数Comparator来对元素进行排序。需要注意的是不能保证同优先级元素的顺序。
DelayQueue
是一个支持延时获取元素的无界阻塞队列。队列使用PriorityQueue来实现。队列中的元素必须实现Delayed接口,在创建元素时可以指定多久才能从队列中获取当前元素。只有在延迟期满时才能从队列中提取元素。
DelayQueue非常有用,可以将DelayQueue运用在以下应用场景。
缓存系统的设计:可以用DelayQueue保存缓存元素的有效期,使用一个线程循环查询DelayQueue,一旦能从DelayQueue中获取元素时,表示缓存有效期到了。还有订单到期,限时支付等等
SynchronousQueue
是一个不存储元素的阻塞队列。每一个put操作必须等待一个take操作,否则不能继续添加元素。SynchronousQueue可以看成是一个传球手,负责把生产者线程处理的数据直接传递给消费者线程。队列本身并不存储任何元素,非常适合传递性场景。SynchronousQueue的吞吐量高于LinkedBlockingQueue和ArrayBlockingQueue。
了解阻塞队列的实现原理
使用了等待通知模式实现。所谓通知模式,就是当生产者往满的队列里添加元素时会阻塞住生产者,当消费者消费了一个队列中的元素后,会通知生产者当前队列可用。通过查看JDK源码发现ArrayBlockingQueue使用了Condition来实现。其余队列的实现,大家可以自行查看,队列的实现的代码总体来说,并不复杂。
Executor框架
3大部分组成
任务
Runnable接口
Callable接口
任务的执行
ThreadPoolExecutor
ScheduledThreadPoolExecutor
异步计算的结果
包括接口Future和实现Future接口的FutureTask类。
ThreadPoolExecutor 的类关系
Executor是一个接口,它是Executor框架的基础,它将任务的提交与任务的执行分离开来。
ExecutorService接口继承了Executor,在其上做了一些shutdown()、submit()的扩展,可以说是真正的线程池接口;
AbstractExecutorService抽象类实现了ExecutorService接口中的大部分方法;
hreadPoolExecutor是线程池的核心实现类,用来执行被提交的任务。
ScheduledExecutorService接口继承了ExecutorService接口,提供了带"周期执行"功能ExecutorService;
ScheduledThreadPoolExecutor是一个实现类,可以在给定的延迟后运行命令,或者定期执行命令。ScheduledThreadPoolExecutor比Timer更灵活,功能更强大。
线程池的创建各个参数含义
public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,BlockingQueue workQueue,ThreadFactory threadFactory,RejectedExecutionHandler handler)
corePoolSize
线程池中的核心线程数,当提交一个任务时,线程池创建一个新线程执行任务,直到当前线程数等于corePoolSize;
如果当前线程数为corePoolSize,继续提交的任务被保存到阻塞队列中,等待被执行;
如果执行了线程池的prestartAllCoreThreads()方法,线程池会提前创建并启动所有核心线程。
maximumPoolSize
线程池中允许的最大线程数。如果当前阻塞队列满了,且继续提交任务,则创建新的线程执行任务,前提是当前线程数小于maximumPoolSize
keepAliveTime
线程空闲时的存活时间,即当线程没有任务执行时,继续存活的时间。默认情况下,该参数只在线程数大于corePoolSize时才有用
TimeUnit
keepAliveTime的时间单位
workQueue
用于保存等待执行的任务的阻塞队列,一般来说,我们应该尽量使用有界队列,因为使用无界队列作为工作队列会对线程池带来如下影响。
1)当线程池中的线程数达到corePoolSize后,新任务将在无界队列中等待,因此线程池中的线程数不会超过corePoolSize。
2)由于1,使用无界队列时maximumPoolSize将是一个无效参数。
3)由于1和2,使用无界队列时keepAliveTime将是一个无效参数。
4)更重要的,使用无界queue可能会耗尽系统资源,有界队列则有助于防止资源耗尽,同时即使使用有界队列,也要尽量控制队列的大小在一个合适的范围。
所以我们一般会使用,ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、PriorityBlockingQueue。
threadFactory
创建线程的工厂,通过自定义的线程工厂可以给每个新建的线程设置一个具有识别度的线程名,当然还可以更加自由的对线程做更多的设置,比如设置所有的线程为守护线程。
Executors静态工厂里默认的threadFactory,线程的命名规则是“pool-数字-thread-数字”。
RejectedExecutionHandler
线程池的饱和策略,当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种策略:
(1)AbortPolicy:直接抛出异常,默认策略;
(2)CallerRunsPolicy:用调用者所在的线程来执行任务;
(3)DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
(4)DiscardPolicy:直接丢弃任务;
当然也可以根据应用场景实现RejectedExecutionHandler接口,自定义饱和策略,如记录日志或持久化存储不能处理的任务。
Executor框架基本使用流程
扩展线程池
能扩展线程池的功能吗?比如在任务执行的前后做一点我们自己的业务工作?实际上,JDK 的线程池已经为我们预留的接口,在线程池核心方法中,有2 个方法是空的,就是给我们预留的。还有一个线程池退出时会调用的方法。
可以看到,每个任务执行前后都会调用 beforeExecute和 afterExecute方法。相当于执行了一个切面。而在调用 shutdown 方法后则会调用 terminated 方法。
线程池的工作机制
1)如果当前运行的线程少于corePoolSize,则创建新线程来执行任务(注意,执行这一步骤需要获取全局锁)。
2)如果运行的线程等于或多于corePoolSize,则将任务加入BlockingQueue。
3)如果无法将任务加入BlockingQueue(队列已满),则创建新的线程来处理任务。
4)如果创建新线程将使当前运行的线程超出maximumPoolSize,任务将被拒绝,并调用RejectedExecutionHandler.rejectedExecution()方法。
提交任务
execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功。
submit()方法用于提交需要返回值的任务。线程池会返回一个future类型的对象,通过这个future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。
关闭线程池
可以通过调用线程池的shutdown或shutdownNow方法来关闭线程池。它们的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务可能永远无法终止。但是它们存在一定的区别,shutdownNow首先将线程池的状态设置成STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表,而shutdown只是将线程池的状态设置成SHUTDOWN状态,然后中断所有没有正在执行任务的线程。
只要调用了这两个关闭方法中的任意一个,isShutdown方法就会返回true。当所有的任务都已关闭后,才表示线程池关闭成功,这时调用isTerminaed方法会返回true。至于应该调用哪一种方法来关闭线程池,应该由提交到线程池的任务特性决定,通常调用shutdown方法来关闭线程池,如果任务不一定要执行完,则可以调用shutdownNow方法。
合理地配置线程池
要想合理地配置线程池,就必须首先分析任务特性
要想合理地配置线程池,就必须首先分析任务特性,可以从以下几个角度来分析。
任务的性质:CPU密集型任务、IO密集型任务和混合型任务。
•任务的优先级:高、中和低。
•任务的执行时间:长、中和短。
•任务的依赖性:是否依赖其他系统资源,如数据库连接。
性质不同的任务可以用不同规模的线程池分开处理。
CPU密集型任务应配置尽可能小的线程,如配置Ncpu+1个线程的线程池。由于IO密集型任务线程并不是一直在执行任务,则应配置尽可能多的线程,如2*Ncpu。
混合型的任务,如果可以拆分,将其拆分成一个CPU密集型任务和一个IO密集型任务,只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐量将高于串行执行的吞吐量。如果这两个任务执行时间相差太大,则没必要进行分解。可以通过Runtime.getRuntime().availableProcessors()方法获得当前设备的CPU个数。
对于IO型的任务的最佳线程数,有个公式可以计算
线程数=Ncpu/(1-阻塞系数)
阻塞系数为0.8-0.9之间
优先级不同的任务可以使用优先级队列PriorityBlockingQueue来处理。它可以让优先级高的任务先执行。
执行时间不同的任务可以交给不同规模的线程池来处理,或者可以使用优先级队列,让执行时间短的任务先执行。
依赖数据库连接池的任务,因为线程提交SQL后需要等待数据库返回结果,等待的时间越长,则CPU空闲时间就越长,那么线程数应该设置得越大,这样才能更好地利用CPU。
建议使用有界队列。有界队列能增加系统的稳定性和预警能力,可以根据需要设大一点儿,比如几千。
假设,我们现在有一个Web系统,里面使用了线程池来处理业务,在某些情况下,系统里后台任务线程池的队列和线程池全满了,不断抛出抛弃任务的异常,通过排查发现是数据库出现了问题,导致执行SQL变得非常缓慢,因为后台任务线程池里的任务全是需要向数据库查询和插入数据的,所以导致线程池里的工作线程全部阻塞,任务积压在线程池里。
如果当时我们设置成无界队列,那么线程池的队列就会越来越多,有可能会撑满内存,导致整个系统不可用,而不只是后台任务出现问题。
预定义线程池
FixedThreadPool详解
创建使用固定线程数的FixedThreadPool的API。适用于为了满足资源管理的需求,而需要限制当前线程数量的应用场景,它适用于负载比较重的服务器。FixedThreadPool的corePoolSize和maximumPoolSize都被设置为创建FixedThreadPool时指定的参数nThreads。
当线程池中的线程数大于corePoolSize时,keepAliveTime为多余的空闲线程等待新任务的
最长时间,超过这个时间后多余的线程将被终止。这里把keepAliveTime设置为0L,意味着多余的空闲线程会被立即终止。
FixedThreadPool使用有界队列LinkedBlockingQueue作为线程池的工作队列(队列的容量为Integer.MAX_VALUE)。
SingleThreadExecutor
创建使用单个线程的SingleThread-Executor的API,于需要保证顺序地执行各个任务;并且在任意时间点,不会有多个线程是活动的应用场景。
corePoolSize和maximumPoolSize被设置为1。其他参数与FixedThreadPool相同。SingleThreadExecutor使用有界队列LinkedBlockingQueue作为线程池的工作队列(队列的容量为Integer.MAX_VALUE)。
CachedThreadPool
创建一个会根据需要创建新线程的CachedThreadPool的API。大小无界的线程池,适用于执行很多的短期异步任务的小程序,或者是负载较轻的服务器。
corePoolSize被设置为0,即corePool为空;maximumPoolSize被设置为Integer.MAX_VALUE。这里把keepAliveTime设置为60L,意味着CachedThreadPool中的空闲线程等待新任务的最长时间为60秒,空闲线程超过60秒后将会被终止。
FixedThreadPool和SingleThreadExecutor使用有界队列LinkedBlockingQueue作为线程池的工作队列。CachedThreadPool使用没有容量的SynchronousQueue作为线程池的工作队列,但CachedThreadPool的maximumPool是无界的。这意味着,如果主线程提交任务的速度高于maximumPool中线程处理任务的速度时,CachedThreadPool会不断创建新线程。极端情况下,CachedThreadPool会因为创建过多线程而耗尽CPU和内存资源。
ScheduledThreadPoolExecutor
使用工厂类Executors来创建。Executors可以创建2种类型的ScheduledThreadPoolExecutor,如下。
•ScheduledThreadPoolExecutor。包含若干个线程的ScheduledThreadPoolExecutor。
•SingleThreadScheduledExecutor。只包含一个线程的ScheduledThreadPoolExecutor。
ScheduledThreadPoolExecutor适用于需要多个后台线程执行周期任务,同时为了满足资源管理的需求而需要限制后台线程的数量的应用场景。
SingleThreadScheduledExecutor适用于需要单个后台线程执行周期任务,同时需要保证顺序地执行各个任务的应用场景。
提交定时任务
public ScheduledFuture schedule(Runnable command, long delay, TimeUnit unit)
//向定时任务线程池提交一个延时Runnable任务(仅执行一次)
public ScheduledFuture schedule(Callable callable, long delay, TimeUnit unit);
public
//向定时任务线程池提交一个延时的Callable任务(仅执行一次)
public ScheduledFuture scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit)
public ScheduledFuture scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit)
//向定时任务线程池提交一个固定时间间隔执行的任务
public ScheduledFuture scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit);
public ScheduledFuture scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit);
//向定时任务线程池提交一个固定延时间隔执行的任务
固定时间间隔的任务不论每次任务花费多少时间,下次任务开始执行时间从理论上讲是确定的,当然执行任务的时间不能超过执行周期。
固定延时间隔的任务是指每次执行完任务以后都延时一个固定的时间。由于操作系统调度以及每次任务执行的语句可能不同,所以每次任务执行所花费的时间是不确定的,也就导致了每次任务的执行周期存在一定的波动。
定时任务超时问题
scheduleAtFixedRate中,若任务处理时长超出设置的定时频率时长,本次任务执行完才开始下次任务,下次任务已经处于超时状态,会马上开始执行。
若任务处理时长小于定时频率时长,任务执行完后,定时器等待,下次任务会在定时器等待频率时长后执行。
设置定时任务每60s执行一次,那么从理论上应该第一次任务在第0s开始,第二次任务在第60s开始,第三次任务在120s开始,但实际运行时第一次任务时长80s,第二次任务时长30s,第三次任务时长50s,则实际运行结果为:
第一次任务第0s开始,第80s结束;
第二次任务第80s开始,第110s结束(上次任务已超时,本次不会再等待60s,会马上开始);
第三次任务第120s开始,第170s结束.
第四次任务第180s开始.....
CompletionService
CompletionService实际上可以看做是Executor和BlockingQueue的结合体。CompletionService在接收到要执行的任务时,通过类似BlockingQueue的put和take获得任务执行的结果。
CompletionService的一个实现是ExecutorCompletionService,ExecutorCompletionService把具体的计算任务交给Executor完成。
在实现上,ExecutorCompletionService在构造函数中会创建一个BlockingQueue(使用的基于链表的LinkedBlockingQueue),该BlockingQueue的作用是保存Executor执行的结果。
当提交一个任务到ExecutorCompletionService时,首先将任务包装成QueueingFuture,它是FutureTask的一个子类,然后改写FutureTask的done方法,之后把Executor执行的计算结果放入BlockingQueue中。
与ExecutorService最主要的区别在于submit的task不一定是按照加入时的顺序完成的。CompletionService对ExecutorService进行了包装,内部维护一个保存Future对象的BlockingQueue。只有当这个Future对象状态是结束的时候,才会加入到这个Queue中,take()方法其实就是Producer-Consumer中的Consumer。它会从Queue中取出Future对象,如果Queue是空的,就会阻塞在那里,直到有完成的Future对象加入到Queue中。所以,先完成的必定先被取出。这样就减少了不必要的等待时间。
代码
使用方法一,自己创建一个集合来保存Future存根并循环调用其返回结果的时候,主线程并不能保证首先获得的是最先完成任务的线程返回值。它只是按加入线程池的顺序返回。因为take方法是阻塞方法,后面的任务完成了,前面的任务却没有完成,主程序就那样等待在那儿,只到前面的完成了,它才知道原来后面的也完成了。
使用方法二,使用CompletionService来维护处理线程不的返回结果时,主线程总是能够拿到最先完成的任务的返回值,而不管它们加入线程池的顺序。
CompletableFuture
成员
ThreadPoolExecutor
ScheduledThreadPoolExecutor
用来在给定的延迟之后运行任务,或者定期执行任务
Future接口
Runnable接口
Callable接口
Executors
newFixedThreadPool
newSingleThreadExecutor
newCachedThreadPool
newScheduledThreadPool
Future模式
实现原理
Data
FutureClient
FutureData
RealData
ThreadLocal 内存泄漏问题
ThreadLocal副作用
脏数据
内存泄漏
内存泄漏原因
解决办法
每次使用完ThreadLocal时,必须要及时调用remove()方法清理
ThreadLocal使用
ThreadLocal类接口很简单,只有4个方法,我们先来了解一下:
void set(Object value)
设置当前线程的线程局部变量的值。
public Object get()
该方法返回当前线程所对应的线程局部变量。
public void remove()
将当前线程局部变量的值删除,目的是为了减少内存的占用,该方法是JDK 5.0新增的方法。需要指出的是,当线程结束后,对应该线程的局部变量将自动被垃圾回收,所以显式调用该方法清除线程的局部变量并不是必须的操作,但它可以加快内存回收的速度。
protected Object initialValue()
返回该线程局部变量的初始值,该方法是一个protected的方法,显然是为了让子类覆盖而设计的。这个方法是一个延迟调用方法,在线程第1次调用get()或set(Object)时才执行,并且仅执行1次。ThreadLocal中的缺省实现直接返回一个null。
public final static ThreadLocal RESOURCE = new ThreadLocal();RESOURCE代表一个能够存放String类型的ThreadLocal对象。此时不论什么一个线程能够并发访问这个变量,对它进行写入、读取操作,都是线程安全的。
实现解析
上面先取到当前线程,然后调用getMap方法获取对应的ThreadLocalMap,ThreadLocalMap是ThreadLocal的静态内部类,然后Thread类中有一个这样类型成员,所以getMap是直接返回Thread的成员。
看下ThreadLocal的内部类ThreadLocalMap源码:
可以看到有个Entry内部静态类,它继承了WeakReference,总之它记录了两个信息,一个是ThreadLocal类型,一个是Object类型的值。getEntry方法则是获取某个ThreadLocal对应的值,set方法就是更新或赋值相应的ThreadLocal对应的值。
回顾我们的get方法,其实就是拿到每个线程独有的ThreadLocalMap
然后再用ThreadLocal的当前实例,拿到Map中的相应的Entry,然后就可以拿到相应的值返回出去。当然,如果Map为空,还会先进行map的创建,初始化等工作。
然后再用ThreadLocal的当前实例,拿到Map中的相应的Entry,然后就可以拿到相应的值返回出去。当然,如果Map为空,还会先进行map的创建,初始化等工作。
为什么会发生内存泄漏
引用
强引用就是指在程序代码之中普遍存在的,类似“Object obj=new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象实例。
软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象实例列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2之后,提供了SoftReference类来实现软引用。
弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象实例只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象实例。在JDK 1.2之后,提供了WeakReference类来实现弱引用。
虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象实例是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象实例被收集器回收时收到一个系统通知。在JDK 1.2之后,提供了PhantomReference类来实现虚引用。
分析
根据我们前面对ThreadLocal的分析,我们可以知道每个Thread 维护一个 ThreadLocalMap,这个映射表的 key 是 ThreadLocal实例本身,value 是真正需要存储的 Object,也就是说 ThreadLocal 本身并不存储值,它只是作为一个 key 来让线程从 ThreadLocalMap 获取 value。仔细观察ThreadLocalMap,这个map是使用 ThreadLocal 的弱引用作为 Key 的,弱引用的对象在 GC 时会被回收。
因此使用了ThreadLocal后,引用链如图所示
因此使用了ThreadLocal后,引用链如图所示
图中的虚线表示弱引用。
这样,当把threadlocal变量置为null以后,没有任何强引用指向threadlocal实例,所以threadlocal将会被gc回收。这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value,而这块value永远不会被访问到了,所以存在着内存泄露。
只有当前thread结束以后,current thread就不会存在栈中,强引用断开,Current Thread、Map value将全部被GC回收。最好的做法是不在需要使用ThreadLocal变量后,都调用它的remove()方法,清除数据。
其实考察ThreadLocal的实现,我们可以看见,无论是get()、set()在某些时候,调用了expungeStaleEntry方法用来清除Entry中Key为null的Value,但是这是不及时的,也不是每次都会执行的,所以一些情况下还是会发生内存泄露。只有remove()方法中显式调用了expungeStaleEntry方法。
从表面上看内存泄漏的根源在于使用了弱引用,但是另一个问题也同样值得思考:为什么使用弱引用而不是强引用?
下面我们分两种情况讨论:
key 使用强引用:引用ThreadLocal的对象被回收了,但是ThreadLocalMap还持有ThreadLocal的强引用,如果没有手动删除,ThreadLocal的对象实例不会被回收,导致Entry内存泄漏。
key 使用弱引用:引用的ThreadLocal的对象被回收了,由于ThreadLocalMap持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal的对象实例也会被回收。value在下一次ThreadLocalMap调用set,get,remove都有机会被回收。
比较两种情况,我们可以发现:由于ThreadLocalMap的生命周期跟Thread一样长,如果都没有手动删除对应key,都会导致内存泄漏,但是使用弱引用可以多一层保障。
因此,ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用。
总结
JVM利用设置ThreadLocalMap的Key为弱引用,来避免内存泄露。
JVM利用调用remove、get、set方法的时候,回收弱引用。
当ThreadLocal存储很多Key为null的Entry的时候,而不再去调用remove、get、set方法,那么将导致内存泄漏。
使用线程池+ ThreadLocal时要小心,因为这种情况下,线程是一直在不断的重复运行的,从而也就造成了value可能造成累积的情况。
因此,ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用。
总结
JVM利用设置ThreadLocalMap的Key为弱引用,来避免内存泄露。
JVM利用调用remove、get、set方法的时候,回收弱引用。
当ThreadLocal存储很多Key为null的Entry的时候,而不再去调用remove、get、set方法,那么将导致内存泄漏。
使用线程池+ ThreadLocal时要小心,因为这种情况下,线程是一直在不断的重复运行的,从而也就造成了value可能造成累积的情况。
错误使用ThreadLocal导致线程不安全
为什么每个线程都输出5?难道他们没有独自保存自己的Number副本吗?为什么其他线程还是能够修改这个值?仔细考察ThreadLocal和Thead的代码,我们发现ThreadLocalMap中保存的其实是对象的一个引用,这样的话,当有其他线程对这个引用指向的对象实例做修改时,其实也同时影响了所有的线程持有的对象引用所指向的同一个对象实例。这也就是为什么上面的程序为什么会输出一样的结果:5个线程中保存的是同一Number对象的引用,在线程睡眠的时候,其他线程将num变量进行了修改,而修改的对象Number的实例是同一份,因此它们最终输出的结果是相同的。
而上面的程序要正常的工作,应该的用法是让每个线程中的ThreadLocal都应该持有一个新的Number对象。
而上面的程序要正常的工作,应该的用法是让每个线程中的ThreadLocal都应该持有一个新的Number对象。
并发安全
什么是线程安全性
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在调用代码中不需要任何额外的同步或者协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
不可变对象
不可变对象需要满足的条件
对象创建以后其状态就不能修改
对象所有域都是final类型
对象时正确创建的(在对象创建期间,this引用没有逸出)
final关键字
修饰类、方法、变量
修饰类:不能被继承
修饰方法
方法不能被继承类修改
效率
修饰变量
基本数据类型变量
引用类型变量
JDK自带的不可变对象
Collections.unmodifiableXXX: Collection、List、Set、Map....
Guava
immutableXXX
Collection、List、Set、Map...
线程封闭
实现好的并发是一件困难的事情,所以很多时候我们都想躲避并发。避免并发最简单的方法就是线程封闭。什么是线程封闭呢?
什么是线程封闭
就是把对象封装到一个线程里,只有这一个线程能看到此对象。那么这个对象就算不是线程安全的也不会出现任何安全问题。
实现线程封闭有哪些方法呢?
栈封闭
栈封闭是我们编程当中遇到的最多的线程封闭。什么是栈封闭呢?简单的说就是局部变量。多个线程访问一个方法,此方法中的局部变量都会被拷贝一份到线程栈中。所以局部变量是不被多个线程所共享的,也就不会出现并发问题。所以能用局部变量就别用全局的变量,全局变量容易引起并发问题。
ThreadLocal 线程封闭:特别好的封闭方法
发布对象
发布对象
是一个对象能够被当前范围之外的代码所使用
对象逸出
一种错误的发布。当一个对象还没有构造完成 时,就使它被其他线程所见。
无状态的类
没有任何成员变量的类,就叫无状态的类,这种类一定是线程安全的
如果这个类的方法参数中使用了对象,也是线程安全的吗?比如
当然也是,为何?因为多线程下的使用,固然user这个对象的实例会不正常,但是对于StatelessClass这个类的对象实例来说,它并不持有UserVo的对象实例,它自己并不会有问题,有问题的是UserVo这个类,而非StatelessClass本身。
volatile
并不能保证类的线程安全性,只能保证类的可见性,最适合一个线程写,多个线程读的情景。
加锁和CAS
我们最常使用的保证线程安全的手段,使用synchronized关键字,使用显式锁,使用各种原子变量,修改数据时使用CAS机制等等。
安全的发布
类中持有的成员变量,如果是基本类型,发布出去,并没有关系,因为发布出去的其实是这个变量的一个副本
但是如果类中持有的成员变量是对象的引用,如果这个成员对象不是线程安全的,通过get等方法发布出去,会造成这个成员对象本身持有的数据在多线程下不正确的修改,从而造成整个类线程不安全的问题
这个list发布出去后,是可以被外部线程之间修改,那么在多个线程同时修改的情况下不安全问题是肯定存在的,怎么修正这个问题呢?我们在发布这对象出去的时候,就应该用线程安全的方式包装这个对象。
我们将list用Collections.synchronizedList进行包装以后,无论多少线程使用这个list,就都是线程安全的了。
对于我们自己使用或者声明的类,JDK自然没有提供这种包装类的办法,但是我们可以仿造这种模式或者委托给线程安全的类,当然,对这种通过get等方法发布出去的对象,最根本的解决办法还是应该在实现上就考虑到线程安全问题
TheadLocal
ThreadLocal是实现线程封闭的最好方法。ThreadLocal内部维护了一个Map,Map的key是每个线程的名称,而Map的值就是我们要封闭的对象。每个线程中的对象都对应着Map中一个值,也就是ThreadLocal利用Map实现了对象的线程封闭。
Servlet辨析
不是线程安全的类,为什么我们平时没感觉到:
1、在需求上,很少有共享的需求,2、接收到了请求,返回应答的时候,一般都是由一个线程来负责的。
但是只要Servlet中有成员变量,一旦有多线程下的写,就很容易产生线程安全问题。
死锁
概念
是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。
举个例子:A和B去按摩洗脚,都想在洗脚的时候,同时顺便做个头部按摩,13技师擅长足底按摩,14擅长头部按摩。
这个时候A先抢到14,B先抢到13,两个人都想同时洗脚和头部按摩,于是就互不相让,扬言我死也不让你,这样的话,A抢到14,想要13,B抢到13,想要14,在这个想同时洗脚和头部按摩的事情上A和B就产生了死锁。怎么解决这个问题呢?
第一种,假如这个时候,来了个15,刚好也是擅长头部按摩的,A又没有两个脑袋,自然就归了B,于是B就美滋滋的洗脚和做头部按摩,剩下A在旁边气鼓鼓的,这个时候死锁这种情况就被打破了,不存在了。
第二种,C出场了,用武力强迫A和B,必须先做洗脚,再头部按摩,这种情况下,A和B谁先抢到13,谁就可以进行下去,另外一个没抢到的,就等着,这种情况下,也不会产生死锁。
所以总结一下:
死锁是必然发生在多操作者(M>=2个)情况下,争夺多个资源(N>=2个,且N<=M)才会发生这种情况。很明显,单线程自然不会有死锁,只有B一个去,不要2个,打十个都没问题;单资源呢?只有13,A和B也只会产生激烈竞争,打得不可开交,谁抢到就是谁的,但不会产生死锁。同时,死锁还有一个重要的要求,争夺资源的顺序不对,如果争夺资源的顺序是一样的,也不会产生死锁。
死锁的发生必须具备以下四个必要条件。
1)互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放。
2)请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。
3)不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
4)环路等待条件:指在发生死锁时,必然存在一个进程——资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源。
危害
1、线程不工作了,但是整个程序还是活着的
2、没有任何的异常信息可以供我们检查。
3、一旦程序发生了发生了死锁,是没有任何的办法恢复的,只能重启程序,对生产平台的程序来说,这是个很严重的问题。
实际工作中的死锁
时间不定,不是每次必现;一旦出现没有任何异常信息,只知道这个应用的所有业务越来越慢,最后停止服务,无法确定是哪个具体业务导致的问题;测试部门也无法复现,并发量不够。
解决
定位
要解决死锁,当然要先找到死锁,怎么找?
通过jps 查询应用的 id,再通过jstack id 查看应用的锁的持有情况
修正
关键是保证拿锁的顺序一致
两种解决方式
1、内部通过顺序比较,确定拿锁的顺序;
2、采用尝试拿锁的机制。
其他安全问题
活锁
两个线程在尝试拿锁的机制中,发生多个线程之间互相谦让,不断发生同一个线程总是拿到同一把锁,在尝试拿另一把锁时因为拿不到,而将本来已经持有的锁释放的过程。
解决办法:每个线程休眠随机数,错开拿锁的时间。
线程饥饿
低优先级的线程,总是拿不到执行时间
并发下的性能
使用并发的目标是为了提高性能,引入多线程后,其实会引入额外的开销,如线程之间的协调、增加的上下文切换,线程的创建和销毁,线程的调度等等。过度的使用和不恰当的使用,会导致多线程程序甚至比单线程还要低。
衡量应用的程序的性能:服务时间,延迟时间,吞吐量,可伸缩性等等,其中服务时间,延迟时间(多快),吞吐量(处理能力的指标,完成工作的多少)。多快和多少,完全独立,甚至是相互矛盾的。
对服务器应用来说:多少(可伸缩性,吞吐量)这个方面比多快更受重视。
我们做应用的时候:
1、先保证程序正确,确实达不到要求的时候,再提高速度。(黄金原则)
2、一定要以测试为基准。
线程引入的开销
上下文切换
如果主线程是唯一的线程,那么它基本上不会被调度出去。另一方面,如果可运行的线程数大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU。这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。上下文切换有点像我们同时阅读几本书,在来回切换书本的同时我们需要记住每本书当前读到的页码。
切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和JVM共享的数据结构。应用程序、操作系统以及JVM都使用一组相同的CPU。在JVM和操作系统的代码中消耗越多的CPU时钟周期,应用程序的可用CPU时钟周期就越少。但上下文切换的开销并不只是包含JVM和操作系统的开销。当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。
当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被交换出去。如果线程频繁地发生阻塞,那么它们将无法使用完整的调度时间片。在程序中发生越多的阻塞(包括阻塞IO,等待获取发生竞争的锁,或者在条件变量上等待),与CPU密集型的程序就会发生越多的上下文切换,从而增加调度开销,并因此而降低吞吐量。
上下文切换是计算密集型操作。也就是说,它需要相当可观的处理器时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。上下文切换的实际开销会随着平台的不同而变化,然而按照经验来看:在大多数通用的处理器中,上下文切换的开销相当于50~10000个时钟周期,也就是几微秒。
UNIX系统的 vmstat命令能报告上下文切换次数以及在内核中执行时间所占比例等信息。如果内核占用率较高(超过10%),那么通常表示调度活动发生得很频繁,这很可能是由IO或竞争锁导致的阻塞引起的。
内存同步
同步操作的性能开销包括多个方面。在 synchronized和 volatile提供的可见性保证中可能会使用一些特殊指令,即内存栅栏( Memory Barrier)。
内存栅栏可以刷新缓存,使缓存无效刷新硬件的写缓冲,以及停止执行管道。
内存栅栏可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。在内存栅栏中,大多数操作都是不能被重排序的。
阻塞
引起阻塞的原因:包括阻塞IO,等待获取发生竞争的锁,或者在条件变量上等待等等。
阻塞会导致线程挂起【挂起:挂起进程在操作系统中可以定义为暂时被淘汰出内存的进程,机器的资源是有限的,在资源不足的情况下,操作系统对在内存中的程序进行合理的安排,其中有的进程被暂时调离出内存,当条件允许的时候,会被操作系统再次调回内存,重新进入等待被执行的状态即就绪态,系统在超过一定的时间没有任何动作】。
很明显这个操作至少包括两次额外的上下文切换,还有相关的操作系统级的操作等等。
如何减少锁的竞争
减少锁的粒度
使用锁的时候,锁所保护的对象是多个,当这些多个对象其实是独立变化的时候,不如用多个锁来一一保护这些对象。但是如果有同时要持有多个锁的业务方法,要注意避免发生死锁
缩小锁的范围
对锁的持有实现快进快出,尽量缩短持由锁的的时间。将一些与锁无关的代码移出锁的范围,特别是一些耗时,可能阻塞的操作
避免多余的锁
两次加锁之间的语句非常简单,导致加锁的时间比执行这些语句还长,这个时候应该进行锁粗化—扩大锁的范围。
锁分段
ConcurrrentHashMap就是典型的锁分段。
替换独占锁
在业务允许的情况下:
1、使用读写锁,
2、用自旋CAS
3、使用系统的并发容器
线程安全的单例模式
解决办法,加volatile关键字
解决之道
懒汉式
类初始化模式,也叫延迟占位模式。在单例类的内部由一个私有静态内部类来持有这个单例类的实例。
延迟占位模式还可以用在多线程下实例域的延迟赋值。
延迟占位模式还可以用在多线程下实例域的延迟赋值。
饿汉式
在声明的时候就new这个类的实例,因为在JVM中,对类的加载和类初始化,由虚拟机保证线程安全。
或者使用枚举
或者使用枚举
JMM
JMM 基础-计算机原理
Java内存模型即Java Memory Model,简称JMM。JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。Java1.5版本对其进行了重构,现在的Java仍沿用了Java1.5的版本。Jmm遇到的问题与现代计算机中遇到的问题是差不多的。
子主题
在计算机系统中,寄存器划是L0级缓存,接着依次是L1,L2,L3(接下来是内存,本地磁盘,远程存储)。越往上的缓存存储空间越小,速度越快,成本也更高;越往下的存储空间越大,速度更慢,成本也更低。从上至下,每一层都可以看做是更下一层的缓存,即:L0寄存器是L1一级缓存的缓存,L1是L2的缓存,依次类推;每一层的数据都是来至它的下一层,所以每一层的数据是下一层的数据的子集。
在现代CPU上,一般来说L0, L1,L2,L3都集成在CPU内部,而L1还分为一级数据缓存(Data Cache,D-Cache,L1d)和一级指令缓存(Instruction Cache,I-Cache,L1i),分别用于存放数据和执行数据的指令解码。每个核心拥有独立的运算处理单元、控制器、寄存器、L1、L2缓存,然后一个CPU的多个核心共享最后一层CPU缓存L3
物理内存模型带来的问题
基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。
现代的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致。
子主题
处理器A和处理器B按程序的顺序并行执行内存访问,最终可能得到x=y=0的结果。
处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(步骤A1,B1),然后从内存中读取另一个共享变量(步骤A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(步骤A3,B3)。当以这种时序执行时,程序就可以得到x=y=0的结果。
从内存操作实际发生的顺序来看,直到处理器A执行A3来刷新自己的写缓存区,写操作A1才算真正执行了。虽然处理器A执行内存操作的顺序为:A1→A2,但内存操作实际发生的顺序却是A2→A1。
如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。
伪共享
前面我们已经知道,CPU中有好几级高速缓存。但是CPU缓存系统中是以缓存行(cache line)为单位存储的。目前主流的CPU Cache的Cache Line大小都是64Bytes。Cache Line可以简单的理解为CPU Cache中的最小缓存单位,今天的CPU不再是按字节访问内存,而是以64字节为单位的块(chunk)拿取,称为一个缓存行(cache line)。当你读一个特定的内存地址,整个缓存行将从主存换入缓存。
一个缓存行可以存储多个变量(存满当前缓存行的字节数);而CPU对缓存的修改又是以缓存行为最小单位的,在多线程情况下,如果需要修改“共享同一个缓存行的变量”,就会无意中影响彼此的性能,这就是伪共享(False Sharing)。
为了避免伪共享,我们可以使用数据填充的方式来避免,即单个数据填充满一个CacheLine。这本质是一种空间换时间的做法。但是这种方式在Java7以后可能失效。
Java8中已经提供了官方的解决方案,Java8中新增了一个注解@sun.misc.Contended。
比如JDK的ConcurrentHashMap中就有使用
加上这个注解的类会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在jvm启动时设置-XX:-RestrictContended才会生效。
一个类中,只有一个long类型的变量:
定义一个VolatileLong类型的数组,然后让多个线程同时并发访问这个数组,这时可以想到,在多个线程同时处理数据时,数组中的多个VolatileLong对象可能存在同一个缓存行中。
运行后,可以得到运行时间
花费了39秒多。
我们改用进行了缓存行填充的变量
花费了8.1秒,如果任意注释上下填充行的任何一行,时间表现不稳定,从8秒到20秒都有,但是还是比不填充要快。具体原因目前未知。
再次改用注解标识的变量,同时加入参数-XX:-RestrictContended
花费了7.7秒。
由上述的实验结果表明,伪共享确实会影响应用的性能。
Java内存模型(JMM)
从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
Java内存模型带来的问题
可见性问题
左边CPU中运行的线程从主存中拷贝共享对象obj到它的CPU缓存,把对象obj的count变量改为2。但这个变更对运行在右边CPU中的线程不可见,因为这个更改还没有flush到主存中。
在多线程的环境下,如果某个线程首次读取共享变量,则首先到主内存中获取该变量,然后存入工作内存中,以后只需要在工作内存中读取该变量即可。同样如果对该变量执行了修改的操作,则先将新值写入工作内存中,然后再刷新至主内存中。但是什么时候最新的值会被刷新至主内存中是不太确定,一般来说会很快,但具体时间不知。
要解决共享对象可见性这个问题,我们可以使用volatile关键字或者是加锁。
要解决共享对象可见性这个问题,我们可以使用volatile关键字或者是加锁。
竞争问题
线程A和线程B共享一个对象obj。假设线程A从主存读取Obj.count变量到自己的CPU缓存,同时,线程B也读取了Obj.count变量到它的CPU缓存,并且这两个线程都对Obj.count做了加1操作。此时,Obj.count加1操作被执行了两次,不过都在不同的CPU缓存中。
如果这两个加1操作是串行执行的,那么Obj.count变量便会在原始值上加2,最终主存中的Obj.count的值会是3。然而图中两个加1操作是并行的,不管是线程A还是线程B先flush计算结果到主存,最终主存中的Obj.count只会增加1次变成2,尽管一共有两次加1操作。 要解决上面的问题我们可以使用java synchronized代码块
重排序
重排序类型
除了共享内存和工作内存带来的问题,还存在重排序的问题:在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。
1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
数据依赖性
数据依赖性:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为下列3种类型,上面3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。
很明显,A和C存在数据依赖,B和C也存在数据依赖,而A和B之间不存在数据依赖,如果重排序了A和C或者B和C的执行顺序,程序的执行结果就会被改变。
很明显,不管如何重排序,都必须保证代码在单线程下的运行正确,连单线程下都无法正确,更不用讨论多线程并发的情况,所以就提出了一个as-if-serial的概念。
as-if-serial
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。(强调一下,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。)但是,如果操作之间不存在数据依赖关系,这些操作依然可能被编译器和处理器重排序
A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。
as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器可以让我们感觉到:单线程程序看起来是按程序的顺序来执行的。asif-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。
内存屏障
Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,从而让程序按我们预想的流程去执行。
1、保证特定操作的执行顺序。
2、影响某些数据(或则是某条指令的执行结果)的内存可见性。
编译器和CPU能够重排序指令,保证最终相同的结果,尝试优化性能。插入一条Memory Barrier会告诉编译器和CPU:不管什么指令都不能和这条Memory Barrier指令重排序。
Memory Barrier所做的另外一件事是强制刷出各种CPU cache,如一个Write-Barrier(写入屏障)将刷出所有在Barrier之前写入 cache 的数据,因此,任何CPU上的线程都能读取到这些数据的最新版本。
JMM把内存屏障指令分为4类
StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。
临界区
JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得多线程在这两个时间点按某种顺序执行。
临界区内的代码则可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。虽然线程A在临界区内做了重排序,但由于监视器互斥执行的特性,这里的线程B根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。
回想一下,为啥线程安全的单例模式中一般的双重检查不能保证真正的线程安全?
happens-before
在Java 规范提案中为让大家理解内存可见性的这个概念,提出了happens-before的概念来阐述操作之间的内存可见性。对应Java程序员来说,理解happens-before是理解JMM的关键。
JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和as-if-serial语义是一回事。as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
定义
用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系 。
两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)
加深理解
上面的定义看起来很矛盾,其实它是站在不同的角度来说的。
1)站在Java程序员的角度来说:JMM保证,如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
2)站在编译器和处理器的角度来说:JMM允许,两个操作之间存在happens-before关系,不要求Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序是允许的。
回顾我们前面存在数据依赖性的代码:
站在我们Java程序员的角度
但是仔细考察,2、3是必需的,而1并不是必需的,因此JMM对这三个happens-before关系的处理就分为两类:
1.会改变程序执行结果的重排序
2.不会改变程序执行结果的重排序
JMM对这两种不同性质的重排序,采用了不同的策略,如下:
1.对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序;
2.对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求。
于是,站在我们程序员的角度,看起来这个三个操作满足了happens-before关系,而站在编译器和处理器的角度,进行了重排序,而排序后的执行结果,也是满足happens-before关系的。
Happens-Before规则
JMM为我们提供了以下的Happens-Before规则:
1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
6)join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
7 )线程中断规则:对线程interrupt方法的调用happens-before于被中断线程的代码检测到中断事件的发生。
volatile详解
volatile特性
可以把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步
所以volatile变量自身具有下列特性:
可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
volatile的内存语义
内存语义:可以简单理解为 volatile,synchronize,atomic,lock 之类的在 JVM 中的内存方面实现原则。
volatile写的内存语义如下:
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
volatile读的内存语义如下:
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
所以对于代码
如果我们将flag变量以volatile关键字修饰,那么实际上:线程A在写flag变量后,本地内存A中被线程A更新过的两个共享变量的值都被刷新到主内存中。
在读flag变量后,本地内存B包含的值已经被置为无效。此时,线程B必须从主内存中读取共享变量。线程B的读取操作将导致本地内存B与主内存中的共享变量的值变成一致。
如果我们把volatile写和volatile读两个步骤综合起来看的话,在读线程B读一个volatile变量后,写线程A在写这个volatile变量之前所有可见的共享变量的值都将立即变得对读线程B可见。
为何volatile不是线程安全的
volatile内存语义的实现
volatile重排序规则表
当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
volatile的内存屏障
在Java中对于volatile修饰的变量,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序问题。
volatile写
storestore屏障:对于这样的语句store1; storestore; store2,在store2及后续写入操作执行前,保证store1的写入操作对其它处理器可见。(也就是说如果出现storestore屏障,那么store1指令一定会在store2之前执行,CPU不会store1与store2进行重排序)
storeload屏障:对于这样的语句store1; storeload; load2,在load2及后续所有读取操作执行前,保证store1的写入对所有处理器可见。(也就是说如果出现storeload屏障,那么store1指令一定会在load2之前执行,CPU不会对store1与load2进行重排序
volatile读
在每个volatile读操作的后面插入一个LoadLoad屏障。在每个volatile读操作的后面插入一个loadstore屏障。
loadload屏障:对于这样的语句load1; loadload; load2,在load2及后续读取操作要读取的数据被访问前,保证load1要读取的数据被读取完毕。(也就是说,如果出现loadload屏障,那么load1指令一定会在load2之前执行,CPU不会对load1与load2进行重排序)
loadstore屏障:对于这样的语句load1; loadstore; store2,在store2及后续写入操作被刷出前,保证load1要读取的数据被读取完毕。(也就是说,如果出现loadstore屏障,那么load1指令一定会在store2之前执行,CPU不会对load1与store2进行重排序)
volatile的实现原理
通过对OpenJDK中的unsafe.cpp源码的分析,会发现被volatile关键字修饰的变量会存在一个“lock:”的前缀。
Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。
同时该指令会将当前处理器缓存行的数据直接写会到系统内存中,且这个写回内存的操作会使在其他CPU里缓存了该地址的数据无效。
具体的执行上,它先对总线和缓存加锁,然后执行后面的指令,最后释放锁后会把高速缓存中的脏数据全部刷新回主内存。在Lock锁住总线的时候,其他CPU的读写请求都会被阻塞,直到锁释放。
final的内存语义
在构造线程的类时,我们有种方式就是让类中所有的成员变量都不可变,利用的就是final关键字,那么这个final为何可以做到呢?重排序这种优化动作对构造方法,一样也是存在的。这就说明,一个成员变量加了final关键字后,JMM一定是做了相关处理的。
final的两个重排序规则
对应final域,编译器和处理器需要遵守两个重排序规则
我们假设一个线程A执行writer方法,随后另一个线程B执行reader方法。
看write()方法,只包含一行代码 obj = new FinalMemory();。这一行代码包含两个步骤:
1、在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
把这个对象的引用赋值给引用变量obj。
假设线程B读对象引用(FinalMemory object = obj)与读对象的成员域之间(int a = object.i;int b = object.j)没有重排序,下面的图是一种可能的执行时序:
从上面可能的时序图中我们可以看到,写普通域被编译器重排序到了构造函数之外,读线程B错误的读取了普通变量i初始化之前的值。而写final域的操作,被写final域的重排序规则“限制”到了构造函数之内,读线程B正确读取了final变量初始化之后的值。
总结:写final域的重排序规则可以确保在对象引用为任意线程可见之前,对象的final域已经被正常的初始化了,而普通域不具有这样的保证。
2、初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序
在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作。编译器会在读final域操作的前面插入一个LoadLoad屏障。
reader()方法包含3个步骤:
初次读引用变量obj
初次读引用变量obj指向对象的普通域 i
初次读引用变量obj指向对象的final域 j
我们假设写线程A没有发生任何重排序,则下图是一种可能的时序:
读对象的普通域的操作被处理器重排序到读对象引用之前。读普通域时,该域还没有被线程A写入,所以上面的是一个错误的读取操作。但是读final域的重排序规则把读对象final域的操作“限定”在读对象引用之后,该final域已经被A线程初始化了,是一个正确的读取操作。
总结:读final域的重排序规则可以确保在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。
final域为引用类型
在上面的代码中,final域是一个引用类型,它引用了一个int类型的数组,对于引用类型,写final域的重排序规则对编译器和处理器增加了一下的约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
我们假设线程A先执行write0操作,执行完后线程B执行write1操作,执行完后线程C执行reader操作,下图是一种可能的执行时序:
1是对final域的写入,2是对这个final域引用的对象的成员域的写入,3是把被构造的对象的引用赋值给某个引用变量。这里除了前面提到的1不能和3重排序外,2和3也不能重排序。
JMM可以确保读线程C至少能看到写线程A在构造函数中对final引用对象的成员域的写入。即C至少能看到数组下标0的值为1。而写线程B对数组元素的写入,读线程C可能看得到,也可能看不到。JMM不保证线程B的写入对读线程C可见,因为写线程B和读线程C之间存在数据竞争,此时的执行结果不可预知。
如果想要确保读线程C看到写线程B对数组元素的写入,写线程B和读线程C之间需要使用同步(lock或volatile)来确保内存可见性。
final引用不能从构造函数内逃逸
写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了。其实,要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程所见,也就是对象引用不能在构造函数中逃逸。
假设一个线程A执行writer()方法,另一个线程B执行reader()方法。这里的操作2使得对象还未完成构造前就为线程B可见。即使这里的操作2是构造函数的最后一步,且在程序中操作2排在操作1后面,执行read()方法的线程仍然可能无法看到final域被初始化后的值,因为这里的操作1和操作2之间可能被重排序。
因此在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时的final域可能还没有被初始化。
final语义的实现
会要求编译器在final域的写之后,构造函数return之前插入一个StoreStore障屏。
读final域的重排序规则要求编译器在读final域的操作前面插入一个LoadLoad屏障
锁的内存语义
当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。
如果我们回顾第一章的VolatileCase,我们知道,为了让子线程可以及时看到ready变量的修改,我们需要将ready变量以volatile来修饰。
但是,当我们将程序做如下改造
我们可以看见子线程同样可以中止,为何?我们观察System.out.println的实现,
结合前面锁的内存语义,我们可以知道,当进入synchronized语句块时,子线程会被强制从主内存中读取共享变量,其中就包括了ready变量,所以子线程同样中止了。
synchronized的实现原理
Synchronized在JVM里的实现都是基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。
对同步块,MonitorEnter指令插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象Monitor的所有权,即尝试获得该对象的锁,而monitorExit指令则插入在方法结束处和异常处,JVM保证每个MonitorEnter必须有对应的MonitorExit。
对同步方法,从同步方法反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来实现,相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。
JVM就是根据该标示符来实现方法的同步的:当方法被调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。
synchronized使用的锁是存放在Java对象头里面,
具体位置是对象头里面的MarkWord,MarkWord里默认数据是存储对象的HashCode等信息,
但是会随着对象的运行改变而发生变化,不同的锁状态对应着不同的记录存储方式
了解各种锁
自旋锁
自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
但是线程自旋是需要消耗CPU的,说白了就是让CPU在做无用功,线程不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。
如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。
自旋锁的优缺点
自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起操作的消耗!
但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用cpu做无用功,占着XX不XX,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要cup的线程又不能获取到cpu,造成cpu的浪费。
自旋锁时间阈值
自旋锁的目的是为了占着CPU的资源不释放,等到获取到锁立即进行处理。但是如何去选择自旋的执行时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用CPU资源,进而会影响整体系统的性能。因此自旋次数很重要
JVM对于自旋次数的选择,jdk1.5默认为10次,在1.6引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不在是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间。
JDK1.6中-XX:+UseSpinning开启自旋锁; JDK1.7后,去掉此参数,由jvm控制;
锁的状态
一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,目的是为了提高获得锁和释放锁的效率。
偏向锁
引入背景:大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁,减少不必要的CAS操作
偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,减少加锁/解锁的一些CAS操作(比如等待队列的一些CAS操作),这种情况下,就会给线程加一个偏向锁。 如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。它通过消除资源无竞争情况下的同步原语,进一步提高了程序的运行性能。
偏向锁获取过程:
步骤1、 访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态。
步骤2、 如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。
步骤3、 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4。
步骤4、 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stop the word)
步骤5、 执行同步代码。
偏向锁的释放
偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放偏向锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
偏向锁的适用场景
始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致stop the word操作;
在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向所的时候会导致进入安全点,安全点会导致stw,导致性能下降,这种情况下应当禁用。
jvm开启/关闭偏向锁
开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking
轻量级锁
轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;
轻量级锁的加锁过程:
在代码进入同步块的时候,如果同步对象锁状态为无锁状态且不允许进行偏向(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。
拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤4,否则执行步骤5。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态
如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,当竞争线程尝试占用轻量级锁失败多次之后,轻量级锁就会膨胀为重量级锁,重量级线程指针指向竞争线程,竞争线程也会阻塞,等待轻量级线程释放锁后唤醒他。锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
不同锁的比较
JDK对锁的更多优化措施
逃逸分析
如果证明一个对象不会逃逸方法外或者线程外,则可针对此变量进行优化:
同步消除synchronization Elimination,如果一个对象不会逃逸出线程,则对此变量的同步措施可消除。
同步消除synchronization Elimination,如果一个对象不会逃逸出线程,则对此变量的同步措施可消除。
锁消除和粗化
锁消除:虚拟机的运行时编译器在运行时如果检测到一些要求同步的代码上不可能发生共享数据竞争,则会去掉这些锁。
锁粗化:将临近的代码块用同一个锁合并起来
消除无意义的锁获取和释放,可以提高程序运行性能。
JMM内存模型的八种同步操作
read(读取),从主内存读取数据
load(载入):将主内存读取到的数据写入到工作内存
use(使用): 从工作内存读取数据来计算
assign(赋值):将计算好的值重新赋值到工作内存中
store(存储):将工作内存数据写入主内存
write(写入):将store过去的变量值赋值给主内存中的变量
lock(锁定):将主内存变量加锁,标识为线程 独占状态
unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量
Java 内存模型 - 同步规则
1、如果要把一个变量从主内存中复制到工作内存,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步到主内存中,就要按按顺序地执行store和 write 操作。但Java内存模型 只要求上述操作必须按照顺序执行,而没有保证必须是连续执行。
2、不允许read 和 load 、store 和 write 操作之一单独出现
3、不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
4、不允许一个 线程无原因得(没有发生过任何assign操作)把 数据从工作内存同步到主内存中。
5、一个新的变量只能从主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实时use和store操作之前,必须先执行过了assign 和 load 操作。
6、一个变量在同一时刻 只允许一条线程对其进行lock操作,但lock操作可以被同一线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁,lock和 unolck必须成对出现。
7、如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或 assign操作初始化变量的值。
8、如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许unlock一个被其他线程锁定的变量。
9、对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和 write操作
volatile
可见性实现原理
查看底层汇编
保证可见性
Lock前缀指定会引起处理器缓存回写内存
一个处理器缓存回写到内存会导致其他处理器的缓存无效
保证有序性
不保证原子性
单例模式双重检查加锁问题
如何保证原子性、可见性、有序性
原子性(synchronized(monitorenter/monitoeexit)
可见性(volatile/synchronized/final
有序性(volatile/syncharonized
内存屏障和volatile内存语义
内存屏障:用来控制和规范cpu对内存操作的顺序的cpu指令
内存屏障作用
阻止屏障两侧的指令重排序
强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。
CPU层面内存屏障
Load Barrier
在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据
Store Barrier
在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见
java内存屏障列表
LoadLoad屏障
对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。禁止下面所有的普通读操作和上面的volatile读重排序
StoreStore屏障
对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
禁止上面的普通写和下面的volatile写重排
禁止上面的普通写和下面的volatile写重排
LoadStore屏障
对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
禁止下面所有的普通写操作和上面的volatile读重排序
禁止下面所有的普通写操作和上面的volatile读重排序
StoreLoad屏障
对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
防止上面的volatile写与下面可能有的volatile读/写重排序
防止上面的volatile写与下面可能有的volatile读/写重排序
基于保守策略的JMM内存屏障的插入策略
在每个volatile写操作的前面插入一个StoreStore屏障
在每个volatile写操作后面插入一个StoreLoad屏障
在每个volatile读操作的后面插入一个LoadLoad屏障
在每个volatile读擦偶作的后面插入一个LoadStore屏障
volatile重排序规则
当第二个 操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则保证volatile写之前的操作不会被编译器排序到volatile写之后
当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则保证 volatile读之后的操作不会被编译器重排序到volatile读之前
当第一个操作是volatile写,第二个操作是volatile读时,不能重排序
重排序解决方案
CPU层面解决
编译器层面解决
线程之间通信
volatile 和 synchronized
volatile
可以用来修饰字段(成员变量),就是告知程序任何对该变量的访问均需要从共享内存中获取,而对它的改变必须刷新回共享内存,它能保证所有线程对变量的访问的可见性
synchronized
可以修饰方法或者以同步块的形式进行使用,它主要确保多个线程在同一时刻,只能有一个线程处于方法或者同步块中,它 保证了线程对变量方法的可见性和排他性。
等待、通知(wait,notify)
生产者 消费者
等待方
获取对象的锁
如果条件不满足,那么调用对象的wait方法,被通知后扔要检查条件
条件满足则执行对应的逻辑
对应代码
通知方
获得对象的锁
改变条件
通知所有等待对象上的线程
Thread.join()
ThreadLocal
ThreadLocal和Synchonized都用于解决多线程并发訪问
happens-before
程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作
监视器规则:对一个锁的解锁,happens-before 于随后的这个锁的加锁
volatile变量规则:对一个volatile域的写,hasppens-before 于任意后续对这个volatile域的读
传递性: 如果 A happens - before B ,且 B happens-before C ,那么 A happens-before C
as-if-serial语义
不管怎么重排序,单线程程序的执行结果不能被改变
Disruptor
应用背景和介绍
Disruptor是英国外汇交易公司LMAX开发的一个高性能队列,研发的初衷是解决内部的内存队列的延迟问题,而不是分布式队列。基于Disruptor开发的系统单线程能支撑每秒600万订单,2010年在QCon演讲后,获得了业界关注。
据目前资料显示:应用Disruptor的知名项目有如下的一些:Storm, Camel, Log4j2,还有目前的美团点评技术团队也有很多不少的应用,或者说有一些借鉴了它的设计机制。
Disruptor是一个高性能的线程间异步通信的框架,即在同一个JVM进程中的多线程间消息传递。
传统队列问题
在JDK中,Java内部的队列BlockQueue的各种实现,仔细分析可以得知,队列的底层数据结构一般分成三种:数组、链表和堆,堆这里是为了实现带有优先级特性的队列暂且不考虑。
在稳定性和性能要求特别高的系统中,为了防止生产者速度过快,导致内存溢出,只能选择有界队列;同时,为了减少Java的垃圾回收对系统性能的影响,会尽量选择 Array格式的数据结构。这样筛选下来,符合条件的队列就只有ArrayBlockingQueue。但是ArrayBlockingQueue是通过加锁的方式保证线程安全,而且ArrayBlockingQueue还存在伪共享问题,这两个问题严重影响了性能。
ArrayBlockingQueue的这个伪共享问题存在于哪里呢,分析下核心的部分源码,其中最核心的三个成员变量为
是在ArrayBlockingQueue的核心enqueue和dequeue方法中经常会用到的,这三个变量很容易放到同一个缓存行中,进而产生伪共享问题。
高性能的原理
高性能的原理
高性能的原理
引入环形的数组结构:数组元素不会被回收,避免频繁的GC,
无锁的设计:采用CAS无锁方式,保证线程的安全性
属性填充:通过添加额外的无用信息,避免伪共享问题
环形数组结构是整个Disruptor的核心所在。
首先因为是数组,所以要比链表快,而且根据我们对上面缓存行的解释知道,数组中的一个元素加载,相邻的数组元素也是会被预加载的,因此在这样的结构中,cpu无需时不时去主存加载数组中的下一个元素。而且,你可以为数组预先分配内存,使得数组对象一直存在(除非程序终止)。这就意味着不需要花大量的时间用于垃圾回收。此外,不像链表那样,需要为每一个添加到其上面的对象创造节点对象—对应的,当删除节点时,需要执行相应的内存清理操作。环形数组中的元素采用覆盖方式,避免了jvm的GC。
其次结构作为环形,数组的大小为2的n次方,这样元素定位可以通过位运算效率会更高,这个跟一致性哈希中的环形策略有点像。在disruptor中,这个牛逼的环形结构就是RingBuffer,既然是数组,那么就有大小,而且这个大小必须是2的n次方
其实质只是一个普通的数组,只是当放置数据填充满队列(即到达2^n-1位置)之后,再填充数据,就会从0开始,覆盖之前的数据,于是就相当于一个环。
每个生产者首先通过CAS竞争获取可以写的空间,然后再进行慢慢往里放数据,如果正好这个时候消费者要消费数据,那么每个消费者都需要获取最大可消费的下标。
同时,Disruptor 不像传统的队列,分为一个队头指针和一个队尾指针,而是只有一个角标(上图的seq),它属于一个volatile变量,同时也是我们能够不用锁操作就能实现Disruptor的原因之一,而且通过缓存行补充,避免伪共享问题。该指针是通过一直自增的方式来获取下一个可写或者可读数据。
Hook线程以及捕获线程执行异常
获取线程运行时异常
在Thread类中,关于处理运行时异常API总共有四个
setUncaughtExceptionHandler(UncaughtExceptionHandler eh) :为某个特定线程指定UncaughtExceptionHandler
setDefaultUncaughtExceptionHandler(UncaughtExceptionHandler eh) : 设置全局的UncaughtExceptionHandler
UncaughtExceptionHandler getUncaughtExceptionHandler() : 获取特定线程的UncaughtExceptionHandler
UncaughtExceptionHandler getDefaultUncaughtExceptionHandler(): 获取去全局的UncaughtExceptionHandler
UncaughtExceptionHandler介绍
线程在执行单元中是不允许抛出checked异常的额,而且线程运行在自己的上下文中,派生它的线程将无法直接获得它运行中出现的以常规信息,对此,Java 为我们提供了一个 UncaughtExceptionHandler 接口,,当线程在运行过程中出现异常时,会回调UncaughtExceptionHandler接口,从而得知是哪个线程在运行时出错,以及出现了什么样的错。
代码演示
注入钩子线程
Hook线程介绍
JVM进程的退出时由于JVM进程中没有活跃的非守护线程,或者收到了系统中断信号,向JVM程序注入一个Hook线程,在JVM进程退出的时候,Hook线程会启动执行,通过RunTime可以为JVM注入多个Hook线程,
ThreadHook
Hook线程实战
在我们的开发中 经常会遇到Hook线程,比如为了防止某个程序被重复启动,在进程启动时 会创建一个lock文件,进程收到中断信号的 时候会删除这个lock文件,我们在mysql服务器、zookeeper/kafka 等系统中都能看到lock文件的存在。
代码演示
Hook线程使用应用场景
Hook线程只有在收到退出信号的时候会被执行,如果在kill的时候使用了参数-9, 那么Hook线程不会得到执行,进程将会立即退出,因此.lock文件将得不到清理。
Hook线程中也可以执行一些资源释放的工作,比如关闭文件句柄、socket链接、数据库connection等。
尽量不要再Hook线程中执行一些耗时长的操作,因为其会导致程序迟迟不会退出。
项目实战:并发任务执行框架
架构师定义
架构设计、软件开发
确认需求
系统分解
技术选型
制定技术规格说明
开发管理
深深介入开发的方方面面
沟通协调
与用户,与产品,与升级,与团队成员
架构师方方面面
作用
设计架构
救火
布道
效果
攻关
信念
职责
产品架构
基础服务架构
需求的产生和分析
考试组
有批量离线文档要生成
题库组
批量的题目进行排重
根据条件批量修改题目的内容
共同痛点
都有批量任务要完成且速度慢
都要求可以查询进度
在使用省尽可能的对业务开发人员友好
我们需要做什么
提高性能,采用多线程,屏蔽细节
封装线程池和阻塞队列
每个批量任务拥有自己的上下文环境
需要一个并发安全的容器保存每个任务
自动清除已完成和过期任务
定时轮询?
框架业务示意图
框架流程图
具体实现-可查询进度的并发任务执行框架
用户业务方法结果?
如何执行业务的业务方法?
用户如何提交他的 工作任务和查询任务进度?
整个框架的流程和实现?
具体实现
1、用户业务方法的结果?
一个方法执行的结果有几种可能?三种,成功:按预想的流程出了结果;失败:按按预想的流程没出结果;异常:没按预想的流程抛出了预料之外的错误。因此我们定义了一个枚举,表示这三种情况,
对于方法的业务执行结果,返回值有很多种可能,基本类型,系统定义的对象类型,用户自定义的对象类型都是存在的,我们需要用泛型来说表示这个结果。同时方法执行失败了,我们还需要告诉用户或者业务开发人员,失败的原因,我们再定义了一个任务的结果类。
2、如何执行用户的业务方法?
我们是个框架,用户的业务各种各样,都要放到我们框架里执行,怎么办?当然是定义个接口,我们的框架就只执行这个方法,而使用我们框架的业务方都应该来实现这个接口,当然因为用户业务的数据多样性,意味着我们这个方法的参数也应该用泛型。
3、用户如何提交他的工作和查询任务进度?
用户在前端提交了工作(JOB)到后台,我们需要提供一种封装机制,让业务开发人员可以将任务的相关信息提交给这个封装机制,用户的需要查询进度的时候,也从这个封装机制中取得,同时我们的封装机制内部也要负责清除已完成任务。
在这个封装机制里我们定义了一个类JobInfo,抽象了对用户工作的封装,一个工作可以包含多个子任务(TASK),这个JobInfo中就包括了这个工作的相关信息,比如工作名,用以区分框架中唯一的工作,也可以避免重复提交,也方便查询时快速定位工作,除了工作名以外,工作中任务的列表,工作中任务的处理器都在其中定义。
同时JobInfo还有相当多的关于这个工作的方法,比如查询工作进度,查询每个任务的处理结果,记录每个任务的处理结果等等
负责清除已完成任务,我们则交给CheckJobProcesser类来完成,定时轮询的机制不够优雅,因此我们选用了DelayQueue来实现这个功能
并且在其中定义了清除已完成任务的Runnable和相关的工作线程。
4、框架的主体类
主体类则是PendingJobPool,这也是业务开发人员主要使用的类。这个类主要负责调度,例如工作(JOB)和任务(TASK)的提交,任务(TASK)的保存,任务(TASK)的并发执行,工作进度的查询接口和任务执行情况的查询等等。
流程图
代码
单体代码
和Spring集成代码
项目实战-应用性能优化实战
项目简介
考试后从题库中抽取题目生成大量的离线练习册文档并打印
题目在数据库中存储形式,平均长度800字节:
下图是Diameter协议中的那部分?《img src = "001.jpg"》
项目原来存在的问题
一份PDF文档的生成平均时长在50~55秒左右
为什么慢?
一份离线练习册的生成过程
一份离线练习册的生成过程
分离出需要处理的题目(60~120个,平均大约80个题目左右)
解析处理题目文本,对题目中的图片下载到本地,然后调用第三方工具生成PDF文档(耗时大约40~45秒)
将PDF文档上传到云空间进行存储(耗时大约9、10秒)
提供文档地址让用户去下载打印
分析和改进
第一版:单web
代码
第二版:服务化
代码
改进
考察业务特点:
1、从容量为10万左右的题库中为每个学生抽取适合他的题目;
2、每道题目都含有大量的图片需要下载到本地,和文字部分一起渲染;
3、存在热点题目
1、解决方案:缓存避免重复工作、题目处理并行和异步化
比如A 80 题 B 20 题 A和B有共同的10题
2、如何实现
1)先检索缓存,新题目在生成时要考虑并发安全和充分利用Future
2)题目有更新时,怎么处理?
比较题目有没有变化,可以使用MD5,SHA摘要
改进之后题目处理的流程
代码
见附件
能否继续改进呢?
如何继续改进
1、题库数量太多时,如何处理?
2、服务器重启的时候,已缓存的题目怎么办?
3、还可以继续优化吗?
解决方案
数据结构改进
使用Google ConcurrentLinkedHashMap 代替 concurrentHashMap
线程数的设置
缓存的改进
使用了本地文件存储,启用了一个二级缓存机制
用户体验的改进
使用并发任务执行框架中的思想相结合,在前端显示处理进度,给用户带来更好的使用体验
启示
性能优化一定要建立在对业务的深入分析上,比如我们在性能优化的切入点,在缓存数据结构的选择就建立在对业务的深入理解上;
性能优化要善于利用语言的高并发特性,
性能优化多多利用缓存,异步任务等机制,正是因为我们使用这些特性和机制,才让我们的应用在性能上有个了质的飞跃;
引入各种机制的同时要注意避免带来新的不安全因素和瓶颈,比如说缓存数据过期的问题,并发时的线程安全问题,都是需要我们去克服和解决的。
0 条评论
下一页