并发编程并不是 Java 特有的语言特性,它是一个通用且早已成熟的领域。Java 只是根据自身情况做了实现罢了。并发编程可以总结为三个核心问题:分工、同步、互斥。分工指的是如何高效地拆解任务并分配给线程,而同步指的是线程之间如何协作,互斥则是保证同一时刻只允许一个线程访问共享资源。 例如: Fork/Join 框架就是一种分工模式,CountDownLatch 就是一种典型的同步方式,而可重入锁则是一种互斥手段。当你理解或学习并发编程的时候,如果能够站在较高层面,系统且有体系地思考问题,那就会容易很多。本文是JUC第一讲:并发包深入理解
面试题1:如何使线程按序交替?
- 实现方式:先做一个标记number=1;定义三个方法,先判断是否number为1,阻塞其他线程,打印当前线程,唤醒2号线程
1、《Java并发编程实战》作者阵容可谓大师云集,也包括Doug Lea(基础篇)
2、《Java并发编程的艺术》讲解并发包内部实现原理,能读明白,内功大增(高段位)
3、《图解Java多线程设计模式》并发编程设计模式方面的经典书籍
4、《操作系统:精髓与设计原理》经典操作系统教材
5、http://ifeve.com 国内专业并发编程网站
6、http://www.cs.umd.edu/~pugh/java/memoryModel/ 很多并发编程的早期资料都在这里
JUC就是 java.util .concurrent 工具包的简称。这是一个处理线程的工具包, JDK1.5 开始出现的。它加了一些在并发编程中常用的工具类,用于定义类似线程的自定义子系统,包括线程池,异步io,轻量级任务框架等。
一种线程安全的hash表,对于多线程的操作,性能介于hashmap和hashtable之间
版本 | 数据结构 |
---|---|
jdk1.7 | 内部采用了锁分段机制来替代 hashtable 的独占锁(也就是每一个 Segment 上同时只有一个线程可以操作),从而提高了性能 segment[] 数组和HashEntry[] 数组, Segment 的个数一但初始化就不能改变, 默认 Segment的个数是 16 个 |
jdk1.8 | 进行put操作时:上面的segment采用的是 cas 机制来保证线程安全的。使用的 Synchronized 锁加 CAS 的机制。 结构也由Java7 中的 Segment 数组 + HashEntry 数组 + 链表 进化成了 Node 数组 + 链表 / 红黑树)。Node 是类似于一个 HashEntry 的结构。 它的冲突再达到一定大小时会转化成红黑树, 在冲突小于一定数量时又退回链表 |
initialCapacity初始容量 | 16 |
---|---|
loadFactor 加载因子 | 0.75 |
concurrencyLevel 并发级别 | 16 |
table | 默认为null,初始化发生在第一次put操作,默认大小为16的数组 |
nextTable | 默认为null,扩容时新生成的数组,其大小为原数组的两倍 |
sizeCtl | 默认为0,用来控制table的初始化和扩容操作(-1代表table正在初始化,-N表示n-1个线程正在扩容操作) |
Node | 保存key,value及key的hash值的数据结构(Node:保存key,value及key的hash值的数据结构) |
Segment的数组大小一定是2的次幂? |
主要是便于通过按位与的散列算法来定位Segment的index,保证存储空间的充分利用。
由Segment的数组结构(一种可重入的reentrantLock)和HashEntry数组结构(存储键值对数据)组成,对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁
1、假设table已经初始化完成,put操作采用CAS+synchronized实现并发插入或更新操作
2、首先对key.hashcode进行hash操作,得到key的hash值,定位索引位置,index = hash &(length-1)
3、获取table中对应索引的对象f,node类型:Unsafe.getObjectVolatile来获取 tabAt[index],获取指定内存的数据,保证每次拿到数据都是最新
4、如果f为null,说明table中该位置第一次插入元素,利用CAS方法插入Node节点
- CAS成功,说明Node节点已经插入,随后检查是否需要进行扩容
- CAS失败,说明有其它线程提前插入了节点,自旋重新尝试插入节点
如果f的hash值为-1,意味有其它线程正在扩容,则一起进行扩容操作
5、f不为null的其余情况把新的Node节点按链表或红黑树的方式插入到合适的位置,这个过程采用同步内置锁实现并发(Synchronized锁住Node,减少了锁粒度)
- 在节点f上进行同步,节点插入之前,再次利用tabAt(tab, i) == f判断,防止被其它线程修改
- 如果f.hash >= 0,说明f是链表结构的头结点,遍历链表,如果找到对应的node节点,则修改value,否则在链表尾部加入节点
- 如果f是TreeBin类型节点if(f instanceof TreeBin),说明f是红黑树根节点,则在树结构上遍历元素,更新或增加节点
- 如果链表中节点数binCount >= TREEIFY_THRESHOLD(默认是8),则把链表转化为红黑树结构
无需加锁,涉及的共享变量都使用volatile修饰,volatile保证内存可见性。
获取索引的对象f=tabAt[index],遍历key,找到相等的,cas来保证变量的原子性读取
元素数量达到容量阈值sizeCtl(长度*0.75),扩容分为两部分:
1、构建一个nextTable,大小为table的两倍
2、Unsafe.compareAndSwapInt修改sizeCtl值-1,保证只有一个线程初始化,扩容后的数组长度为原来的两倍,但是容量是原来的1.5
3、把table的数据复制到nextTable中:扩容操作支持并发插入,支持节点的并发复制
ConcurrentHashMap 在扩容时会计算出一个步长(stride),最小值是16,然后给当前扩容线程分配“一个步长”的节点数,例如16个,让该线程去对这16个节点进行扩容操作(将节点从老表移动到新表)。
如果在扩容结束前又来一个线程,则也会给该线程分配一个步长的节点数让该线程去扩容。依次类推,以达到多线程并发扩容的效果。
因为hashmap的线程不安全,在并发环境下,可能会形成环状链表(扩容时造成的,与transfer函数有关),导致get操作时,cpu空转。而hashtable实现线程安全的代价太大了,get/put所有相关操作都是基于synchronized的(全表锁)
这个案例是来演示session会话续期策略的,倘若是用户再次登录系统,会更新其sessionKey的超期时间。renewal为延续session会话,使用的数据结构为ReentrantReadWriteLock读写锁以及ConcurrentHashMap来保存会话信息。
// redis session读取,会话续期
public class RedisSessionManager {
/**
* 会话map ReentrantReadWriteLock 读锁共享 写锁互斥
*/
private final ReadWriteLock rwl = new ReentrantReadWriteLock();
private ConcurrentMap<String, Long> sessionKeyMap = new ConcurrentHashMap<>();
private void addToReNewMap(String id, long lastAccessAt) {
rwl.readLock().lock();
try {
if (sessionKeyMap.size() < 102400) {
sessionKeyMap.put(id, lastAccessAt);
}
} finally {
rwl.readLock().unlock();
}
}
public void renewal() {
Map<String, Long> localSessionKeyMap;
rwl.writeLock().lock();
try {
localSessionKeyMap = sessionKeyMap;
sessionKeyMap = new ConcurrentHashMap<>();
} finally {
rwl.writeLock().unlock();
}
localSessionKeyMap = Iters.nullToEmpty(localSessionKeyMap);
if (localSessionKeyMap.size() > 0) {
LOGGER.warn("renewal session size = {}", localSessionKeyMap.size());
localSessionKeyMap.forEach(this.expirationPolicy::onExpirationUpdated);
}
}
}
1.2 进程与线程
进程(Process) 是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。程序是指令、数据及其组织形式的描述,进程是程序的实体。
线程(thread) 是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
总结来说:
1.3 线程的状态
枚举:Thread.State
public enum State {
/**
* Thread state for a thread which has not yet started.
*/
NEW,(新建)
/**
* Thread state for a runnable thread. A thread in the runnable
* state is executing in the Java virtual machine but it may
* be waiting for other resources from the operating system
* such as processor.
*/
RUNNABLE,(准备就绪)
/**
* Thread state for a thread blocked waiting for a monitor lock.
* A thread in the blocked state is waiting for a monitor lock
* to enter a synchronized block/method or
* reenter a synchronized block/method after calling
* {@link Object#wait() Object.wait}.
*/
BLOCKED,(阻塞)
/**
* Thread state for a waiting thread.
* A thread is in the waiting state due to calling one of the
* following methods:
*
* - {@link Object#wait() Object.wait} with no timeout
* - {@link #join() Thread.join} with no timeout
* - {@link LockSupport#park() LockSupport.park}
*
* *
A thread in the waiting state is waiting for another thread to
* perform a particular action.
*
* For example, a thread that has called Object.wait()
* on an object is waiting for another thread to call
* Object.notify() or Object.notifyAll() on
* that object. A thread that has called Thread.join()
* is waiting for a specified thread to terminate.
*/
WAITING,(不见不散)
/**
* Thread state for a waiting thread with a specified waiting time.
* A thread is in the timed waiting state due to calling one of
* the following methods with a specified positive waiting time:
*
* - {@link #sleep Thread.sleep}
* - {@link Object#wait(long) Object.wait} with timeout
* - {@link #join(long) Thread.join} with timeout
* - {@link LockSupport#parkNanos LockSupport.parkNanos}
* - {@link LockSupport#parkUntil LockSupport.parkUntil}
*
*/
TIMED_WAITING,(过时不候)
/**
* Thread state for a terminated thread.
* The thread has completed execution.
*/
TERMINATED;(终结)
}
wait/sleep 的区别?
(1) sleep 是 Thread 的静态方法,wait 是 Object 的方法,任何对象实例都能调用。
(2) sleep 不会释放锁,它也不需要占用锁。 wait 会释放锁,但调用它的前提是当前线程占有锁(即代码要在 synchronized 中)。
(3) 它们都可以被 interrupted 方法中断
并发与并行
串行模式
并行模式
并发
并发(concurrent)指的是多个程序可以同时运行的现象,更细化的是多进程可以同时运行或者多指令可以同时运行。 并发的重点在于它是一种现象, 并发描述的是多进程同时运行的现象。但实际上,对于单核心 CPU 来说,同一时刻只能运行一个线程。所以,这里的"同时运行"表示的不是真的同一时刻有多个线程运行的现象,这是并行的概念,而是提供一种功能让用户看来多个程序同时运行起来了,但实际上这些程序中的进程不是一直霸占 CPU 的,而是执行一会停一会。
要解决大并发问题,通常是将大任务分解成多个小任务, 由于操作系统对进程的调度是随机的,所以切分成多个小任务后,可能会从任一小任务处执行。这可能会出现一些现象:
并发: 同一时刻多个线程在访问同一个资源,多个线程对一个点
并行: 多项工作一起执行,之后再汇总
1.5 管程
管程(monitor)是保证了同一时刻只有一个进程在管程内活动,即管程内定义的操作在同一时刻只被一个进程调用(由编译器实现).但是这样并不能保证进程以设计的顺序执行
JVM 中同步是基于进入和退出管程(monitor)对象实现的,每个对象都会有一个管程(monitor)对象,管程(monitor)会随着 java 对象一同创建和销毁
执行线程首先要持有管程对象,然后才能执行方法,当方法完成之后会释放管程,方法在执行时候会持有管程,其他线程无法再获取同一个管程
1.6 用户线程和守护线程
常见的Java线程的4种创建方式分别为:继承Thread类、实现Runnable接口、通过ExecutorService和Callable实现有返回值的线程、基于线程池,如下图所示:
run
方法,Thread类实现了Runnable接口并定义了操作线程的一些方法,我们可以通过继承Thread类的方式创建一个线程
Thread.currentThread()
方法,直接使用this,即可获得当前线程;run()
方法,基于Java编程语言的规范,如果子类已经继承(extends)了一个类,就无法再直接继承Thread类,此时可以通过实现Runnable接口创建线程。具体的实现过程为:通过实现Runnable接口创建ChildrenClassThread 线程,实例化名称为childrenThread的线程实例,创建Thread类的实例并传入childrenThread线程实例,调用线程的start方法启动线程。
继承 Thread 类创建线程Demo如下, 创建一个类并继承Thread接口,然后实例化线程对象并调用start方法
启动线程,start方法是一个native方法,通过在操作系统上启动一个新线程,并最终执行run方法来启动一个线程。run方法内的代码是线程类的具体实现逻辑
class ThreadDemo extends Thread/implements Runnable {
public void run() {
for(int x=0;x<60;x++)
System.out.println(Thread.currentThread()+"子线程运行");
//System.out.println(Thread.currentThread().getName()+" : "+ x);
}
}
class ThreadTest {
public static void main(String[] args) {
//1,继承方式:extends
ThreadDemo threadDemo= new ThreadDemo();
threadDemo.start();
for(int x=0;x<60;x++){
System.out.println("主线程运行");
}
/**2,实现接口方式测试:implements
ThreadDemo threadDemo= new ThreadDemo();
Thread td1 = new Thread(threadDemo);
Thread td2 = new Thread(threadDemo);
Thread td3 = new Thread(threadDemo);
td1.start();
td2.start();
td3.start();
**/
}
}
以上代码定义了一个名为ThreadDemo的线程类,该类继承了Thread, run方法内的代码为线程的具体执行逻辑,在使用该线程时只需新建一个该线程的对象并调用其start方法即可。
有时,我们需要在主线程中开启多个线程并发执行一个任务,然后收集各个线程执行返回的结果并将最终结果汇总起来,这时就要用到Callable接口。具体的实现方法为:创建一个类并实现Callable接口,在call方法中实现具体的运算逻辑并返回计算结果。具体的调用过程为:创建一个线程池、一个用于接收返回结果的Future List及Callable线程实例,使用线程池提交任务并将线程执行之后的结果保存在Future中,在线程执行结束后遍历Future List中的Future对象,在该对象上调用get方法就可以获取Callable线程任务返回的数据并汇总结果
通过 Future 接口创建线程 Demo
FutureTask<Response<Map<Long, ItemDTO>>> task = this.buildItemTaskCache(paramDTO.getDistrictIds(), itemIds, itemReadContext.getIncludeRewriteSaleQuantity(), itemReadContext.getIncludeRewriteStockQuantity(), consumerAppName);
taskMap.put(ITEM_TASK, task);
microReadThreadPool.submit(task);
for (Map.Entry<String, FutureTask> taskEntry : taskMap.entrySet()) {
String key = taskEntry.getKey();
FutureTask value = taskEntry.getValue();
Response<Map<Long, ItemDTO>> itemResp = (Response<Map<Long, ItemDTO>>) value.get(batchFindFullDetailTaskTimeout, TimeUnit.MILLISECONDS);
}
通过 CompletableFuture 创建线程 Demo
CompletableFuture<Response<Map<Long, ItemDTO>>> itemFuture = CompletableFuture.supplyAsync(() -> {
Response<Map<Long, ItemDTO>> mapResponse = itemMap(itemIds);
return mapResponse;
}, microReadThreadPool);
Response<Map<Long, ItemDTO>> itemResp = itemFuture.get(15, TimeUnit.SECONDS);
基于线程池
提供了一个线程队列,队列中保存着所有等待状态的线程,避免了创建和销毁的额外开销,提高了程序响应的速度
下一篇文章:java中的线程池会详细讲到
callble和runnable的区别
创建线程的4种方式
当阻塞队列进行插入数据时,如果队列已满,线程将会阻塞等待直到队列非满;从阻塞队列取数据时,如果队列已空,线程将会阻塞等待直到队列非空
具有 4 组不同的方法用于插入、移除以及对队列中的元素进行检查
组别 | 插入 | 移除 | 检查 | 处理 |
---|---|---|---|---|
第一组 | 插入 add(e) | 移除 remove() | 检查 element() | 处理方式:抛出异常 |
第二组 | 插入 offer(e) | 移除 poll() | 检查 peek() | 处理方式:返回特殊值 |
第三组 | 插入 put(e) | 移除take() | 检查 不可用 | 处理方式: 一直阻塞 |
第四组 | 插入 offer(e,time,unit) | 移除 poll(time,unit) | 检查 不可用 | 处理方式:超时退出 |
对四组不同的行为方式解释: | ||||
抛出异常 | 如果试图的操作无法立即执行,抛一个异常 | |||
– | – | |||
特定值 | 如果试图的操作无法立即执行,返回一个特定的值(常常是 true / false)。 | |||
阻塞 | 如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行。 | |||
超时 | 如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行,但等待时间不会超过给定值。返回一个特定值以告知该操作是否成功(典型的是true / false) | |||
注意:无法向一个BlockingQueue中插入 null。如果你试图插入null,BlockingQueue将会抛出一个NullPointerException |
1、ArrayBlockingQueue 有界的阻塞队列 其内部实现是将对象放到一个数组里
细节:
1 | 内部有个数组 items 用来存放队列元素; |
---|---|
2 | putindex 下标标示入队元素下标 |
3 | takeIndex是出队下标,count统计队列元素个数 |
4 | 独占锁lock用来对出入队操作加锁 |
5 | notEmpty,notFull条件变量用来进行出入队的同步 |
方法:
1 | offer方法 | 在队尾插入元素,如果队列满则返回false,否者入队返回true |
---|---|---|
2 | Put操作 | 在队列尾部添加元素,如果队列满则等待队列有空位置插入后返回 |
3 | Poll操作 | 从队头获取并移除元素,队列为空,则返回null |
4 | Take操作 | 从队头获取元素,如果队列为空则阻塞直到队列有元素。 (当前线程会被挂起放到 notEmpty 的条件队列里面,直到入队操作执行调用notEmpty.signal后当前线程才会被激活,) |
5 | Peek操作 | 返回队列头元素但不移除该元素,队列为空,返回null |
6 | Size操作 | 获取队列元素个数,非常精确因为计算size时候加了独占锁,其他线程不能入队或者出队或者删除元素 |
总结 锁的粒度比较大 类似在方法上添加synchronized |
2、DelayQueue 延迟无界阻塞队列
只有在延迟期满时才能从中提取元素。该队列的头部是延迟期满后保存时间最长的Delayed 元素
运用:缓存系统的设计,缓存中的对象,超过了空闲时间,需要从缓存中移出
1、take()和offer()都是lock了重入锁,按照synchronized的公平锁,两个方法是互斥
2、take()方法需要等待1个小时才能返回,offer()需要马上提交一个10秒后运行的任务,此时offer()可以插队获取锁
3、原理:A执行时,B lock()锁,并休眠;当锁被A释放处于可用状态时,B线程却还处于被唤醒的过程中,此时C线程请求锁,可以优先C得到锁
3、LinkedBlockingQueue 有界/无界链表阻塞队列(线程池默认使用)
内部以一个链式结构(链接节点)对其元素进行存储,这一链式结构可以选择一个上限。如果没有定义上限,将使用 Integer.MAX_VALUE 作为上限
细节:
1 | 两个 Node 分别用来存放首尾节点 |
---|---|
2 | 初始值为 0 的原子变量 count用来记录队列元素个数 |
3 | 两个ReentrantLock的独占锁,分别用来控制元素入队和出队加锁(takeLock取元素,putLock添加元素) |
4 | notEmpty和notFull用来实现入队和出队的同步 |
5 | 可以同时又一个线程入队和一个线程出队 |
方法: | |
1 | 带时间的Offer操作-生产者 |
– | :– |
2 | 带时间的poll操作-消费者 (获取并移除队首元素,在指定的时间内去轮询队列看有没有首元素有则返回,否者超时后返回null) |
3 | put操作-生产者 (与带超时时间的poll类似不同在于put时候如果当前队列满了它会一直等待其他线程调用notFull.signal才会被唤醒) |
4 | take操作-消费者 (与带超时时间的poll类似不同在于take时候如果当前队列空了它会一直等待其他线程调用notEmpty.signal()才会被唤醒) |
5 | size操作-消费者 (当前队列元素个数,如代码直接使用原子变量count获取) |
6 | peek操作 (获取但是不移除当前队列的头元素,没有则返回null。 ) |
7 | remove操作 删除队列里面的一个元素,有则删除返回true,没有则返回false |
4、PriorityBlockingQueue 无界的并发队列
可以实现comparable接口中的方法来排序队列中的元素 //是二叉树最小堆的实现
5、synchronizedQueue 不存储元素的BlockingQueue
每一个put操作必须要等待一个take操作,否则不能继续添加元素;适合做交换工作
面试题2:
在多线程操作下,一个数组中最多只能存入 3 个元素。多放入不可以存入数组,或等待某线程对数组中某个元素取走才能放入,要求使用java的多线程来实现。
使用阻塞队列中的ArrayBlockingQueue队列来实现。
存数据使用put(e)方法,取数据使用take()方法。处理方式: 一直阻塞
面试题3:
如果提交任务时,线程池队列已满,这时会发生什么?
1、如果使用的是LinkedBlockingQueue,也就是无界队列,可以继续添加任务到阻塞队列中等待执行,可以无限存放任务;
2、如果使用的是有界队列,如ArrayBlockingQueue,任务首先会被添加到ArrayBlockingQueue中,满了后,会使用拒绝策略rejectedExecutionHandler处理满了的任务,
Abort(直接抛出rejectedExecutionException)、 默认
Discard(按照LIFO丢弃)、
DiscardOldest(按照LRU丢弃)、
CallsRun(主线程执行)
多线程安全的解决方案 同步代码块,同步方法,同步锁lock(是一个显示锁,必须通过unlock方法进行释放锁,放在finally操作中)
Synchronized关键字
synchronized 是 Java 中的关键字,是一种同步锁。它修饰的对象有以下几种:
Demo1 售票系统
class Ticket {
//票数
private int number = 30;
//操作方法: 卖票
public synchronized void sale() {
//判断:是否有票
if(number > 0) {
System.out.println(Thread.currentThread().getName()+" : "+(number--)+" "+number);
}
}
}
如果一个代码块被 synchronized 修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁,而这里获取锁的线程释放锁只会有两种情况:
①获取锁的线程执行完了该代码块,然后线程释放对锁的占有;
②线程执行发生异常,此时 JVM 会让线程自动释放锁。
那么如果这个获取锁的线程由于要等待 IO 或者其他原因(比如调用 sleep方法)被阻塞了,但是又没有释放锁,其他线程便只能干巴巴地等待,因此就需要有一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间或者能够响应中断),通过 Lock 就可以办到。
2.2 什么是 Lock
Lock 与的 Synchronized 区别
Lock 接口定义如 Demo 所示
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
ReentrantLock 可重入锁
public class UnReentrant{
Lock lock = new Lock();
public void outer(){
lock.lock();
inner();
lock.unlock();
}
public void inner(){
lock.lock();
//do something
lock.unlock();
}
}
outer 中调用了 inner, outer 先锁住了 lock, 这样 inner 就不能再获取 lock。 其实调用outer 的线程已经获取了 lock 锁, 但是不能在 inner 中重复利用已经获取的锁资源, 这种锁即称之为不可重入。
可重入就意味着: 线程可以进入任何一个它已经拥有的锁所同步着的代码块。
重点:消费者/生产者问题
解决方案:为了避免虚假唤醒问题,应该把代码放在循环中。
可以使用synchronized和wait,notifyall机制,也可以使用lock锁加上condition(控制线程通信)机制。
condition接口:
描述了可能会与锁有关的条件变量,这些变量在用法上与使用Object描述了可能会与锁有关的条件变量,这些变量在用法上与使用Object.wait访问的隐式监视器类似,但提供了更强大的功能。await,signal,signalall
写入并复制,不适合添加操作多的场景,每次添加都会进行复制,开销大。适合并发迭代操作多的场景
特点:
1、线程安全版本的ArrayList,每次增加的时候,需要新创建一个比原来容量+1大小的数组
2、拷贝原来的元素到新的数组中,同时将新插入的元素放在最末端
3、然后切换引用
4、迭代时生成快照数组;适合读多写少
- 基于CopyOnWriteArrayList实现
- 不能插入重复数据,每次add的时候都要遍历数据,性能略低于CopyOnWriteArrayList
CopyOnWrite特点
- 添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器
- 如果读的时候有多个线程正在向ArrayList添加数据,读还是会读到旧的数据
优点 | 1、对CopyOnWrite容器进行并发的读,而不需要加锁,是一种读写分离的思想 2、适合读多写少的并发场景。比如白名单,黑名单 |
---|---|
缺点 | 1、内存占用问题 写操作的时候,内存里会同时驻扎两个对象的内存 2、数据一致性问题 CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性 |
CopyOnWrite使用Demo:
public Response<Map<Long, Long>> generateXxx(XxxDTO... details) {
List<Object> updates = Lists.newCopyOnWriteArrayList();
Arrays.stream(details).forEach(detail -> updates.add(toXxx(detail)));
return saveSnapshots(updates.toArray(new GoodsSnapshot[updates.size()]));
}
背景:我们知道,在多线程程序中,诸如++i或i++等运算不具有原子性,因此不是安全的线程操作。可以通过synchronized或ReentrantLock将该操作变成一个原子操作,但是synchronized和ReentrantLock均属于重量级锁。因此JVM为此类原子操作提供了一些原子操作同步类,使得同步操作(线程安全操作)更加方便、高效,它便是AtomicInteger。
AtomicInteger为提供原子操作的Integer的类,常见的原子操作类还有AtomicBoolean、AtomicInteger、AtomicLong、AtomicReference等,它们的实现原理相同,区别在于运算对象的类型不同。还可以通过AtomicReference将一个对象的所有操作都转化成原子操作。AtomicInteger的性能通常是synchronized和ReentrantLock的好几倍。
Atomic 是指一个操作是不可中断的。并发包 java.util.concurrent 的原子类都存放在java.util.concurrent.atomic下,如下图所示
基本类型
使用原子的方式更新基本类型
如AtomicInteger,AtomicBoolean i++变成原子操作,底层是cas。
数组类型
使用原子的方式更新数组里的某个元素
引用类型
AtomicReference 类使用示例:首先创建了一个 Person 对象,然后把 Person 对象设置进 AtomicReference 对象中,然后调用 compareAndSet 方法,该方法就是通过 CAS 操作设置 ar。如果 ar 的值为 person 的话,则将其设置为 updatePerson
public class AtomicReferenceTest {
public static void main(String[] args) {
AtomicReference<Person> ar = new AtomicReference<Person>();
Person person = new Person("SnailClimb", 22);
ar.set(person);
Person updatePerson = new Person("Daisy", 20);
ar.compareAndSet(person, updatePerson);
System.out.println(ar.get().getName());// Daisy
System.out.println(ar.get().getAge());// 20
}
}
使用场景:需要原子性
对象的属性修改类型
作用:保证内存的可见性,线程每次都从主存中读取数据。
缺点:不具备“互斥性”:多个线程能同时读写主存,不能保证变量的“原子性”:
(i++不能作为一个整体,分为3个步骤读-改-写),可以使用cas算法保证数据可原子性。
是一种硬件对并发的支持,用于管理对共享数据的访问。相当于是无锁的非阻塞实现。
包含三个操作数,内存值V,预估值A,更新值B,当且仅当V==A,V=B;否则,不做任何操作。
悲观锁:
乐观锁:(锁的粒度小)
CAS算法 :
乐观锁的实现往往需要硬件的支持,多数处理器都都实现了一个CAS指令,实现“Compare And Swap”的语义
CAS包含3个操作数:
1 需要读写的内存位置V
2 进行比较的值A
3 拟写入的新值B
当且仅当位置 V 的值等于 A 时,CAS 才会通过原子方式用新值 B 来更新位置 V 的值;否则不会执行任何操作
CAS乐观锁的缺点
ABA问题:如果另一个线程修改V值假设原来是A,先修改成B,再修改回成A。当前线程的CAS操作无法分辨当前V值是否发生过变化
代码示例:
public class AtomicIntegerDefectDemo {
public static void main(String[] args) {
defectOfABA();
}
// ABA缺陷
static void defectOfABA() {
final AtomicInteger atomicInteger = new AtomicInteger(1);
Thread coreThread = new Thread(
() -> {
final int currentValue = atomicInteger.get();
System.out.println(Thread.currentThread().getName() + " ------ currentValue=" + currentValue);
// 这段目的:模拟处理其他业务花费的时间
try {
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean casResult = atomicInteger.compareAndSet(1, 2);
System.out.println(Thread.currentThread().getName()
+ " ------ currentValue=" + currentValue
+ ", finalValue=" + atomicInteger.get()
+ ", compareAndSet Result=" + casResult);
}
);
coreThread.start();
// 这段目的:为了让 coreThread 线程先跑起来
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread amateurThread = new Thread(
() -> {
int currentValue = atomicInteger.get();
boolean casResult = atomicInteger.compareAndSet(1, 2);
System.out.println(Thread.currentThread().getName()
+ " ------ currentValue=" + currentValue
+ ", finalValue=" + atomicInteger.get()
+ ", compareAndSet Result=" + casResult);
currentValue = atomicInteger.get();
casResult = atomicInteger.compareAndSet(2, 1);
System.out.println(Thread.currentThread().getName()
+ " ------ currentValue=" + currentValue
+ ", finalValue=" + atomicInteger.get()
+ ", compareAndSet Result=" + casResult);
}
);
amateurThread.start();
}
}
Thread-0 ------ currentValue=1
Thread-1 ------ currentValue=1, finalValue=2, compareAndSet Result=true
Thread-1 ------ currentValue=2, finalValue=1, compareAndSet Result=true
Thread-0 ------ currentValue=1, finalValue=2, compareAndSet Result=true
如何解决ABA问题:用另一个标识判断某值是否有改变过。
乐观锁的业务场景及实现方式 20181222 以后再补
在完成某些运算时,只有其他所有线程的运算全部完成,当前运算才继续执行,
可以用于统计多线程执行的时间。
可被多个线程并发的实现减1操作,并在计数器为0后调用await方法的线程被唤醒,从而实现多线程间的协作
现需要解析一个excel里的多个sheet数据,使用多线程,每个线程解析其中一个sheet的数据,等到所有sheet解析完,程序提示解析完成构造函数传入int型参数做改为计数器,countDown被调用,计数器减1,await会一直阻塞程序,直至计数为0.
如果某个sheet解析较慢,可以使用带时间参数的await方法,到时间后,不再阻塞当前线程
1、在AQS队列中,将线程包装为Node.SHARED节点,即标志位共享锁
2、当头节点获得共享锁后,唤醒下一个共享类型结点的操作
- 1、头节点node1,调用unparkSuccessor()方法唤醒了Node2,并且调用tryAcquireShared方法,检查下一个节点是共享节点
- 2、如果是,更改头结点,重复以上步骤,以实现节点自身获取共享锁成功后,唤醒下一个共享类型结点的操作
1、提供了一个基于FIFO队列,可以用于构建锁或者其他相关同步装置的基础框架
使用方式是继承:
子类通过继承同步器并需要实现它的方法来管理其状态,管理方式是通过acquire和release方式操纵状态
在多线程环境中对状态的操作必须保证原子性,需要使用这个同步器提供的以下三个方法对状态进行操作
1、AbstractQueuedSynchronizer.getState()
2、AbstractQueuedSynchronizer.setState(int)
3、AbstractQueuedSynchronizer.compareAndSetState(int, int)
同步器是实现锁的关键
同步器面向的是线程访问和资源控制,他定义了线程对资源是否能够获取以及线程的排队等操作。
依赖于FIFO队列,队列中的node就是保存着线程引用和线程状态的容器。
对于一个排它锁的获取和释放
//获取:
while(获取锁){
if(获取到)
退出while循环
else{
if(当前线程没有入队)
入队
阻塞当前线程
}
}
//释放:
if(释放成功){
删除头结点
激活原头结点的后继结点
};
1、锁是面向使用者的,定义了用户调用的接口,隐藏了实现细节;
2、AQS是锁的实现者,屏蔽了同步状态管理,线程的排队,等待唤醒的底层操作;
3、锁是面向使用者,AQS是锁的具体实现者
1、countDownLatch.await()发生什么?
直接调用了AQS的acquireSharedInterruptibly
当前线程就会进入了一个死循环当中,在这个死循环里面,会不断的进行判断,通过调用tryAcquireShared方法,如果值为0(说明共享锁没有了),会跳出循环
2、释放操作 //countDown操作实际就是释放锁的操作,每调用一次,计数值减少1
3、限定时间的await方法 await(long timeout, TimeUnit unit)
spinForTimeoutThreshold写死了1000ns,这就是所谓的自旋操作,让线程在循环中自旋,否则阻塞线程
代码:参考并发编程的艺术第8章 8.1/8.2
让一组线程到达一个屏障(也称同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续工作
由ReentrantLock可重入锁和Condition共同实现的
注意:
1、前面四个线程等待最后一个线程超时了,这个时候这四个线程不会再等待最后一个线程写入完毕,而是直接抛出BrokenBarrierException异常,继续执行后续的动作。最后一个线程完成写入数据操作后也继续了后续的动作
2、构造函数的参数表示屏障拦截的线程数量,还有一个高级的构造函数CyclicBarrier(int parties,Runnable barrierAction):在线程达到屏障后,优先执行barrierAction
用于多线程计算数据,最后合并计算结果的场景
例如:excel保存了用户所有的银行流水,每一个sheet保存一个账户近一年的每笔流水,现需要统计用户的日均银行流水,先用多线程处理每个sheet的银行流水,再用barrierAction计算最后结果,计算整个日均流水
CountDownLatch和CyclicBarrier都能够实现线程之间的等待,只不过它们侧重点不同
代码:参考并发编程8.2 P191
Semaphore可以控同时访问的线程个数,通过acquire()获取一个许可,如果没有就等待,而release()可以释放一个许可
Semaphore其实和锁有点类似,它一般用于控制对某组资源的访问权限 //操作系统中讲过
应用场景:
semaphore可以用作流量控制,特别是公用资源有限的应用场景,如数据库连接。现在要读取几万个文件的数据,IO密集型任务,可以启动几十个线程去并发读取,读到内存后,还需要保存到数据库中,而数据库的连接数只有10个,这时必须并发控制10个线程同时获取数据库连接,来保存数据,否则报错无法获取数据库连接
方法:
代码:参考并发编程P196
提供了在线程间交换数据的一种手段,它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过exchange方法交换数据,如果第一个线程先执行exchange()方法,他会一直等待第二个线程也执行此方法,当两个线程都到达同步点时,这两个线程就交换数据。
应用场景:
1、用于遗传算法:选两个人作为交配对象,需要交换两人的数据,并使用交叉规则得出2个交配结果
2、用于校对工作:我们需要将纸质银行流水通过人功能的方式录入成电子银行流水,为避免错误,采用AB岗录入,对两个excel数据进行校对,看是否录入一致
可以使用exchange(V x,long timeout,TimeUnit unit) //设置最大等待时长
代码:参考并发编程P198
公平锁:
就是在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程线程是等待队列的第一个,就占有锁
否则就会加入到等待队列中,以后会按照FIFO的规则从队列中取到自己
非公平锁:
比较粗鲁,上来就直接尝试占有锁,如果尝试失败,就再采用类似公平锁那种方式
非阻塞队列使用的是CAS(compare and swap)来实现线程执行的非阻塞
入队:
出队:
正例: 自定义线程工厂, 并且根据外部特征进行分组, 比如, 来自同一机房的调用, 把机房编号赋值给whatFeatureOfGroup:
public class UserThreadFactory implements ThreadFactory {
private final String namePrefix;
private final AtomicInteger nextId = new AtomicInteger(1);
// 定义线程组名称, 在利用 jstack 来排查问题时, 非常有帮助
UserThreadFactory(String whatFeatureOfGroup) {
namePrefix = "FromUserThreadFactory's" + whatFeatureOfGroup + "-Worker-";
}
@Override
public Thread newThread(Runnable task) {
String name = namePrefix + nextId.getAndIncrement();
Thread thread = new Thread(null, task, name, 0, false);
System.out.println(thread.getName());
return thread;
}
}
FixedThreadPool
和 SingleThreadPool
:
CachedThreadPool
:
ScheduledThreadPool
:
SimpleDateFormat
是线程不安全的类,一般不要定义为 static 变量,如果定义为 static,必须加锁,或者使用 DateUtils工具类。正例:注意线程安全,使用DateUtils。或者如下处理
private static final ThreadLocal<DateFormat> df = new ThreadLocal<DateFormat>(){
@Override
protected DateFormat initialValue () {
return new SimpleDateFormat("yyyy-MM-dd");
}
};
说明:如果是JDK8 的应用,可以使用 Instant 代替 Date,LocalDateTime 代替 Calendar,DateTimeFormatter 代替 SimpleDateFormat,官方给出的解释:simple beautiful strong immutable thread-safe.
String createAt = DateUtils.format(logRecordPushMq.getCreatedAt(), DateUtils.FORMAT_YMD);
public static String format(Date dateDate, String format) {
SimpleDateFormat formatter = new SimpleDateFormat(format);
String dateString = formatter.format(dateDate);
return dateString;
}
ScheduledExecutorService
则没有这个问题。ThreadLocalRandom
,而在JDK7 之前,需要编码保证每个线程持有一个实例。private volatile Helper helper = null;
)正例:
public class LazyInitDemo {
private volatile Helper helper = null;
public Helper getHelper() {
if (helper == null) {
synchronized(this) {
if (helper == null) {
helper = new Helper();
}
}
}
return helper;
}
// other methods and fields...
}
如果是 count++ 操作,使用如下类实现:
AtomicInteger count = new AtomicInteger();
count.addAndGet(1);
private static LongAdder BATCH_FIND_BRAND_BY_ID_HIT = new LongAdder();
BATCH_FIND_BRAND_BY_ID_HIT.increment();
正例:
objectThreadLocal.set(userInfo);
try {
// ...
} finally {
objectThreadLocal.remove();
}
说明一: 在 lock 方法与 try 代码块之间的方法调用抛出异常, 无法解锁, 造成其它线程无法成功获取锁。
说明二: 如果 lock 方法在 try 代码块之内, 可能由于其它方法抛出异常, 导致在 finally 代码块中, unlock 对未加锁的对象解锁, 它会调用 AQS 的 tryRelease 方法(取决于具体实现类) , 抛出 IllegalMonitorStateException
异常。
说明三: 在 Lock 对象的 lock 方法实现中可能抛出 unchecked 异常, 产生的后果与说明二相同。
正例:
Lock lock = new XxxLock();
// ...
lock.lock();
try {
doSomething();
doOthers();
} finally {
lock.unlock();
}
反例:
Lock lock = new XxxLock();
// ...
try {
// 如果此处抛出异常, 则直接执行 finally 代码块
doSomething();
// 无论加锁是否成功, finally 代码块都会执行
lock.lock();
doOthers();
} finally {
lock.unlock();
}
说明: 乐观锁在获得锁的同时已经完成了更新操作, 校验逻辑容易出现漏洞, 另外, 乐观锁对冲突的解决策略有较复杂的要求, 处理不当容易造成系统压力或数据异常,所以资金相关的金融敏感信息不建议使用乐观锁更新。