一、并发基础
1.什么是进程?
进程就是程序的一次执行过程,是系统程序执行的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
在java中,当我们启动main函数的时候其实就是启动了一个jvm进程,而main函数所在的线程就是这个进程中的一个线程,也叫主线程。
2.什么是线程?
线程跟进程类似,是资源调度的最小单位。一个进程包含一个或多个线程,在进程中多个线程共享进程中的堆和方法区,线程拥有自己的程序计数器,虚拟机栈和本地方法栈。
所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,所以线程又称为轻量级进程。
一个 Java 程序的运行是 main 线程和多个其他线程同时运行。
3.线程与进程的关系、区别以及优缺点?
一个进程中可以有多个线程,多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈 和 本地方法栈。
总结: 线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。
线程执行开销小,但不利于资源的管理和保护;而进程正相反。
4.程序计数器为什么是私有的?
字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。
5.虚拟机栈和本地方法栈为什么是私有的?
虚拟机栈:每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。
从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
本地方法栈:和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。
在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。
6.一句话简单了解堆和方法区
堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),
方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
7.并发与并行的区别?
并发: 同一时间段,多个任务都在执行 (单位时间内不一定同时执行);
并行: 单位时间内,多个任务同时执行。
8.为什么要使用多线程呢?
并发编程的目的就是为了能提高程序的执行效率提高程序运行速度。
9.使用多线程可能带来什么问题?
内存泄漏、死锁、线程不安全等等。
10.说说线程的生命周期和状态?
一开始创建一个线程,到NEW(新建)初始状态,然后start(),线程就到了这个READY(可运行)状态,当线程获取cpu时间片之后,就到了RUNNING(运行)状态。
如果执行wait()方法,变为WAITING(等待)状态,进入等待状态就必须其他线程notify/notifyAll唤醒才能继续回到运行状态,我们还可以给线程加一个超时等待时间,wait(long s)
使他进入TIME_WAITING(超时等待)状态,等时间到了,线程就会自动回到RUNNABLE状态。当线程调用同步方法时,在没有获取到锁的时候,线程进入BLOCKED(阻塞) 状态。
线程在执行Runnable的run()方法之后,进入到终止状态。
11.什么是上下文切换?
多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,
CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。
当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。
任务从保存到再加载的过程就是一次上下文切换。
上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。
所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。
Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少
12.什么是线程死锁?
线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
产生死锁的四个必要条件?
1-互斥条件。 就是该资源同一时刻只能被一个线程占用。
2-请求与保持条件。一个进程因请求资源而阻塞时,对已获得的资源保持不放。
3-不剥夺条件。线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
4-循环等待条件。若干进程之间形成一种头尾相接的循环等待资源关系。
13.如何避免死锁?
为了避免死锁,我们只要破坏产生死锁的四个条件中的其中一个就可以了。
破坏互斥条件 :这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。
破坏请求与保持条件 :一次性申请所有的资源。
破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
破坏循环等待条件 :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
14.说说 sleep() 方法和 wait() 方法区别和共同点?
两者最主要的区别在于:sleep() 方法没有释放锁,而 wait() 方法释放了锁 。
两者都可以暂停线程的执行。
wait() 通常被用于线程间交互/通信,sleep() 通常被用于暂停执行。
wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。
sleep() 方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout) 超时后线程会自动苏醒。
15.为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?
new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。
start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。
但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
总结: 调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行
二、并发进阶
1.synchronized关键字
关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
1)修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁
2)修饰静态方法:也就是给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管 new 了多少个对象,只有一份)。所以,如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
3)修饰代码块 :指定加锁对象,对给定对象/类加锁。synchronized(this|object) 表示进入同步代码库前要获得给定对象的锁。synchronized(类.class) 表示进入同步代码前要获得 当前 class 的锁
synchronized 关键字的底层原理
synchronized修饰方法,方法使用ACC_SYNCHRONIZED标识符修饰,jVM将被该标识符修饰的方法视为同步方法
synchronized修饰代码块,使用monitorenter和monitorexit两条指令,monitorenter和monitorexit分别表示同步代码块开始和结束的位置。
synchronized 关键字底层原理属于 JVM 层面。
synchronized修饰方法时使用ACC_SYNCHRONIZED标志,而修饰代码块时使用monitorenter和monitorexit指令,这两者本质上都是获取监视器锁monitor。
synchronized和ReetrantLock的区别
两者都是可重入锁
“可重入锁” 指的是自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增 1,所以要等到锁的计数器下降为 0 时才能释放锁。
synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们
ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。
ReentrantLock 比 synchronized 增加了一些高级功能
等待可中断
可实现公平锁 ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁 所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。
可实现选择性通知(锁可以绑定多个条件)
在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知”
synchonized实现过程
1:java代码:synchronized
2:monitorenter和monitorexit
3:执行过程中自动升级
4:lock comxchg
1-CAS
2-Unsafe 底层 一条汇编指令 lock cmpxchg
3-对象内存布局 markword
Object o=new Object在内存中占多少个字节?16
锁的信息记录在synchronized的markword里面,markword还记录了GC标记信息
synchronized锁的升级过程
链接: https://blog.csdn.net/fascinate_/article/details/112978910
锁消除
锁粗化 循环里面的加锁的一个操作频繁加锁解锁消耗资源,于是把锁加在循环外面。
- volatile关键字
cpu cache
jmm java内存模型
volatile 关键字 除了防止 JVM 的指令重排 ,还有一个重要的作用就是保证变量的可见性。
1:cpu底层相关知识
超线程:一个ALU对应多个PC|Registers的组合 所谓的四核八线程
2:多级缓存 cache 概念
cache line 概念 一个缓存行就是64字节
3:缓存行对齐
4:线程乱序执行
系统底层如何实现数据一致性,可见性底层原理实现
1:MESI(缓存一致性协议)如果能解决,就使用MESI。
2:如果不能,就锁总线。
系统底层如何保证有序性,有序性的底层原理实现
1:内存屏障(sfence mfence Ifence等原语)
2:锁总线
单例模式
饿汉式——》懒汉式——》双重检查锁单例
DCL 双重检查锁
对象的创建过程 汇编语言
volatile如何解决指令重排序,底层原理实现
1:volatile i ------源码层级
2:ACC_VOLATILE ------字节码层级
3:JVM的内存屏障,对volatile的读写,前面加屏障,后面也得加屏障 -------JVM层级
屏障两边的指令不可以重排!保障有序! JSR内存屏障4个屏障
4:hotspot实现
并发编程的三个重要特性:原子性、可见性、有序性
1)原子性 : 一个的操作或者多次操作,要么所有的操作全部都得到执行并且不会收到任何因素的干扰而中断,要么所有的操作都执行,要么都不执行。synchronized 可以保证代码片段的原子性
2)可见性 :当一个变量对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。volatile 关键字可以保证共享变量的可见性。
3)有序性 :代码在执行的过程中的先后顺序,Java 在编译器以及运行期间的优化,代码的执行顺序未必就是编写代码时候的顺序。volatile 关键字可以禁止指令进行重排序优化。
说说 synchronized 关键字和 volatile 关键字的区别
volatile是线程同步的轻量级实现,效率比synchronized高,volatile只能作用于变量,而synchronized可以作用于方法和代码块。
volatile只能保证数据可见性,synchronized原子性和可见性都能保证。
volatile主要作用于解决变量在多个线程的可见性,而synchronized解决的是多个线程之间访问资源的同步性。
3.ThreadLocal
链接: https://blog.csdn.net/fascinate_/article/details/113524837
强引用: 没有任何引用的对象就被垃圾回收器回收了
软引用:里面地儿不够用了就被垃圾回收了 常用于缓存
弱引用: 只要被弱引用指向的对象就会被垃圾回收器回收
虚引用:堆外内存,不归jvm管理的 应用:NIO
管理堆外内存 jvm有一个对象,这个对象关联堆外内存,当这个对象要被回收了,对外内存也应该要被回收,不然就内存泄露,jvm如何处理呢,凡是这样的对象给他挂一个虚引用
当对象被回收时会被放到某一个队列里,gc线程监控队列,清空对象关联内存
4.线程池
为什么要用线程池?
降低资源消耗
提高响应速度
提高线程的可管理性
Executors 工具类
Runnable自 Java 1.0 以来一直存在,但Callable仅在 Java 1.5 中引入,目的就是为了来处理Runnable不支持的用例。
Runnable 接口不会返回结果或抛出检查异常,但是Callable 接口可以。
所以,如果任务不需要返回结果或抛出异常推荐使用 Runnable 接口,这样代码看起来会更加简洁。
执行execute()方法和submit()方法的区别是什么呢?
1.execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否
2.submit()方法用于提交需要返回值的任务。线程池会返回一个Future类型的对象,通过这个Future对象可以判断任务是否执行成功,并且可以通过 Future 的 get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。
如何创建线程池?
方式一:通过构造方法实现
方式二:通过 Executor 框架的工具类 Executors 来实现
创建三种类型的ThreadPoolExecutor:
FixedThreadPool: 该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。
当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。
若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
SingleThreadExecutor:方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
CachedThreadPool:该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。
若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。
所有线程在当前任务执行完毕后,将返回线程池进行复用。
代码:
有哪几种线程池?如何创建?
ExecutorService threadPool = null;
threadPool = Executors.newFixedThreadPool(n);//固定大小的线程池
threadPool = Executors.newSingleThreadExecutor();//单线程的线程池,只有一个线程在工作
threadPool = Executors.newCachedThreadPool();//有缓冲的线程池,线程数 JVM 控制
threadPool = new ThreadPoolExecutor();//默认线程池,可控制参数比较多
threadPool = Executors.newScheduledThreadPool(2);
ThreadPoolExecutor类分析
ThreadPoolExecutor自定义线程池构造方法:
/**
* 用给定的初始参数创建一个新的ThreadPoolExecutor。
*/
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}
7个参数:3+4
1.corePoolSize :核心线程数 线程数定义了最小可以同时运行的线程数量
2.maximumPoolSize:当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
3.workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
4.keepAliveTime:当线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁;
5.unit : keepAliveTime 参数的时间单位。
6.threadFactory :executor 创建新线程的时候会用到。
7.handler :饱和策略。关于饱和策略下面单独介绍一下。
ThreadPoolExecutor 4种饱和策略定义(拒绝策略):
1.ThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。
2.ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,
如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。
如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
3.ThreadPoolExecutor.DiscardPolicy: 不处理新任务,直接丢弃掉。
4.ThreadPoolExecutor.DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求。
RejectedExecutionHandler rejected = null;
rejected = new ThreadPoolExecutor.AbortPolicy();//默认,队列满了丢任务抛出异常
rejected = new ThreadPoolExecutor.DiscardPolicy();//队列满了丢任务不异常
rejected = new ThreadPoolExecutor.DiscardOldestPolicy();//将最早进入队列的任务删,之后再尝试加入队列
rejected = new ThreadPoolExecutor.CallerRunsPolicy();//如果添加到线程池失败,那么主线程会自己去执行该任务
三种阻塞队列:
BlockingQueue<Runnable> workQueue = null;
workQueue = new ArrayBlockingQueue<>(5);//基于数组的先进先出队列,有界
workQueue = new LinkedBlockingQueue<>();//基于链表的先进先出队列,无界
workQueue = new SynchronousQueue<>();//无缓冲的等待队列,无界
线程池调优
设置最大线程数,防止线程资源耗尽;
使用有界队列,从而增加系统的稳定性和预警能力(饱和策略);
根据任务的性质设置线程池大小:CPU密集型任务(CPU个数个线程),IO密集型任务(CPU个数两倍的线程),混合型任务(拆分)。
5.Atomic 原子类
在我们这里 Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
原子类说简单点就是具有原子/原子操作特征的类。
JUC 包中的原子类是哪 4 类?
基本类型
使用原子的方式更新基本类型
AtomicInteger:整形原子类
AtomicLong:长整型原子类
AtomicBoolean:布尔型原子类
数组类型
使用原子的方式更新数组里的某个元素
AtomicIntegerArray:整形数组原子类
AtomicLongArray:长整形数组原子类
AtomicReferenceArray:引用类型数组原子类
引用类型
AtomicReference:引用类型原子类
AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
AtomicMarkableReference :原子更新带有标记位的引用类型
对象的属性修改类型
AtomicIntegerFieldUpdater:原子更新整形字段的更新器
AtomicLongFieldUpdater:原子更新长整形字段的更新器
AtomicReferenceFieldUpdater:原子更新引用类型字段的更新器
能不能给我简单介绍一下 AtomicInteger 类的原理?
CAS (compare and swap) + volatile 和 native 方法来保证原子操作
6.AQS
AQS 的全称为(AbstractQueuedSynchronizer),这个类在java.util.concurrent.locks包下面。
AQS 抽象的队列式的同步器,是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器。
AQS 原理概览:
AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。
如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,
这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列(CLH)中。
CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。
AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。
private volatile int state;//共享变量,使用volatile修饰保证线程可见性
//返回同步状态的当前值
protected final int getState() {
return state;
}
// 设置同步状态的值
protected final void setState(int newState) {
state = newState;
}
//原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
AQS 使用一个 int 成员变量来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。
AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。
状态信息通过 protected 类型的 getState,setState,compareAndSetState 进行操作
AQS 组件总结
AQS 定义两种资源共享方式
1.Exclusive(独占):
只有一个线程能执行,如 ReentrantLock。又可分为公平锁和非公平锁:
公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
2.Share(共享):多个线程可同时执行,如 CountDownLatch、Semaphore、 CyclicBarrier、ReadWriteLock、MarrigePhaser、exchanger、LockSupport我们都会在后面讲到。
CountDownLatch (倒计时器): CountDownLatch 是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。
CountDownLatch(n),n个线程执行阻塞,最后n为0,再一起执行。
Semaphore(信号量)-允许多个线程同时访问:synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,
Semaphore(n)可以指定n个线程同时访问某个资源。限流 同时运行几个线程 公平或非公平 acquire()/release()
CyclicBarrier(循环栅栏): CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。
主要应用场景和 CountDownLatch 类似。CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。
它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。
CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,
每个线程调用 await() 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。
CyclicBarrier(n),当线程数量到达n时,再一起执行,到达n之前调用 await()屏障前阻塞。
MarrigePhaser(多个栅栏):结婚事件
exchanger(两个线程值交换)
LockSupport(线程阻塞唤醒):park和unpark
3.组合:ReentrantReadWriteLock 可以看成是组合式,因为 ReentrantReadWriteLock 也就是读写锁允许多个线程同时对某一资源进行读。
一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。
但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,
至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS 已经在顶层实现好了。
AQS 使用了模板方法模式,自定义同步器时需要重写下面几个 AQS 提供的模板方法:
isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。
默认情况下,每个方法都抛出 UnsupportedOperationException。 这些方法的实现必须是内部线程安全的,并且通常应该简短而不是阻塞。
AQS 类中的其他方法都是 final ,所以无法被其他类使用,只有这几个方法可以被其他类使用。
以 ReentrantLock 为例,state 初始化为 0,表示未锁定状态。A 线程 lock()时,会调用 tryAcquire()独占该锁并将 state+1。
此后,其他线程再 tryAcquire()时就会失败,直到 A 线程 unlock()到 state=0(即释放锁)为止,其它线程才有机会获取该锁。
当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。
但要注意,获取多少次就要释放多么次,这样才能保证 state 是能回到零态的。
再以 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。
这 N 个子线程是并行执行的,每个子线程执行完后 countDown() 一次,state 会 CAS(Compare and Swap)减 1。
等到所有子线程都执行完后(即 state=0),会 unpark()主调用线程,然后主调用线程就会从 await() 函数返回,继续后余动作。
用过 CountDownLatch 么?什么场景下用的?
CountDownLatch 的作用就是 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。
之前在项目中,有一个使用多线程读取多个文件处理的场景,我用到了 CountDownLatch 。具体场景是下面这样的:
我们要读取处理 6 个文件,这 6 个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。
为此我们定义了一个线程池和 count 为 6 的CountDownLatch对象 。
使用线程池处理读取任务,每一个线程处理完之后就将 count-1,调用CountDownLatch对象的 await()方法,直到所有文件读取完之后,才会接着执行后面的逻辑。
Condition
Condition可以用来实现线程的分组通信与协作。以生产者/消费者问题为例,
wait/notify/notifyAll:在队列为空时,通知所有线程;在队列满时,通知所有线程,防止生产者通知生产者,消费者通知消费者的情形产生。
await/signal/signalAll:将线程分为消费者线程和生产者线程两组:在队列为空时,通知生产者线程生产;在队列满时,通知消费者线程消费。
什么是阻塞队列?如何使用阻塞队列来实现生产者-消费者模型?
java.util.concurrent.BlockingQueue的特性是:
当队列是空的时,从队列中获取或删除元素的操作将会被阻塞,或者当队列是满时,往队列里添加元素的操作会被阻塞。
特别地,阻塞队列不接受空值,当你尝试向队列中添加空值的时候,它会抛出NullPointerException。
另外,阻塞队列的实现都是线程安全的,所有的查询方法都是原子的并且使用了内部锁或者其他形式的并发控制。
BlockingQueue 接口是java collections框架的一部分,它主要用于实现生产者-消费者问题。
特别地,SynchronousQueue是一个没有容量的阻塞队列,每个插入操作必须等待另一个线程的对应移除操作,反之亦然。
CachedThreadPool使用SynchronousQueue把主线程提交的任务传递给空闲线程执行。
7.CopyOnWrite
在juc(java.util.concurrent)包下有着这么两个类:
CopyOnWriteArrayList 和 CopyOnWriteArraySet。
这体现了读写分离的思想。
1.在写操作的线程,会将数组复制出来一份进行操作。而原本的数组不会做改变。
2.读线程则不会受到影响,但是可能读到的是一个过期的数据。
只能保证最终的一致性,不能保证实时的一致性。
同步容器(强一致性)
同步容器指的是 Vector、Stack、HashTable及Collections类中提供的静态工厂方法创建的类。
Collections类是一个工具提供类
什么是CopyOnWrite容器(弱一致性)?
CopyOnWrite容器即写时复制的容器,适用于读操作远多于修改操作的并发场景中。
通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,
然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。
这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。
所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
从JDK1.5开始Java并发包里提供了两个使用CopyOnWrite机制实现的并发容器,它们是CopyOnWriteArrayList和CopyOnWriteArraySet。
CopyOnWrite容器主要存在两个弱点:
容器对象的复制需要一定的开销,如果对象占用内存过大,可能造成频繁的YoungGC和Full GC;
CopyOnWriteArrayList不能保证数据实时一致性,只能保证最终一致性
ConcurrentHashMap (弱一致性)
ConcurrentHashMap的弱一致性主要是为了提升效率,也是一致性与效率之间的一种权衡。
happens-before
happens-before 指定了两个操作间的执行顺序:
如果 A happens before B,那么Java内存模型将向程序员保证 —— A 的执行顺序排在 B 之前,并且 A 操作的结果将对 B 可见,
其具体包括如下8条规则:
程序顺序规则:单线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作;
管程锁定规则:一个unlock操作先行发生于对同一个锁的lock操作;、
volatile变量规则:对一个Volatile变量的写操作先行发生于对这个变量的读操作;
线程启动规则:Thread对象的start()方法先行发生于此线程的其他动作;
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
线程终止规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始;
传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
***乐观锁与悲观锁
何谓乐观锁与悲观锁?
乐观锁对应于生活中乐观的人总是想着事情往好的方向发展,悲观锁对应于生活中悲观的人总是想着事情往坏的方向发展。这两种人各有优缺点,不能不以场景而定说一种人好于另外一种人。
乐观锁:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,
可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,
其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,
这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。
传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
两种锁的使用场景:从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),
即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,
这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。
乐观锁常见的两种实现方式
乐观锁一般会使用版本号机制或CAS算法实现。
- 版本号机制
一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。
当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
举一个简单的例子: 假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。
操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 $50( $100-$50 )。
在操作员 A 操作的过程中,操作员B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。
操作员 A 完成了修改工作,将数据版本号( version=1 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,
此时由于提交数据版本等于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。
操作员 B 完成了操作,也将版本号( version=1 )试图向数据库提交数据( balance=$80 ),但此时比对数据库记录版本时发现,
操作员 B 提交的数据版本号为 1 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须等于当前版本才能执行更新 “ 的乐观锁策略,
因此,操作员 B 的提交被驳回。
这样,就避免了操作员 B 用基于 version=1 的旧数据修改的结果覆盖操作员A 的操作结果的可能。
2. CAS算法
即compare and swap(比较与交换),是一种有名的无锁算法。
无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,
所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数
需要读写的内存值 V
进行比较的值 A
拟写入的新值 B
当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。
一般情况下是一个自旋操作,即不断的重试。
乐观锁的缺点
ABA 问题是乐观锁一个常见的问题
1-ABA 问题
如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?
很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 "ABA"问题。
JDK 1.5 以后的 AtomicStampedReference 类就提供了此种能力,其中的 compareAndSet 方法就是首先检查当前引用是否等于预期引用,
并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
2-循环时间长开销大
自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。
如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),
使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。
第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
3-只能保证一个共享变量的原子操作
CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。
但是从 JDK 1.5开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.
所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。
CAS与synchronized的使用情景
简单的来说CAS适用于写比较少的情况下(多读场景,冲突一般较少),synchronized适用于写比较多的情况下(多写场景,冲突一般较多)
-对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;
而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
-对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。
Java并发编程这个领域中synchronized关键字一直都是元老级的角色,很久之前很多人都会称它为 “重量级锁” 。
但是,在JavaSE 1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的 偏向锁 和 轻量级锁
以及其它各种优化之后变得在某些情况下并不是那么重了。synchronized的底层实现主要依靠 Lock-Free 的队列,
基本思路是 自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。
在线程冲突较少的情况下,可以获得和CAS类似的性能;而线程冲突严重的情况下,性能远高于CAS。
并发容器总结
JDK 提供的并发容器总结 这些容器大部分在 java.util.concurrent 包中。
ConcurrentHashMap:线程安全的Hashmap
CopyOnWriteArrayList:线程安全的List,在读多写少的场合性能非常好,远远好于Vector
ConcurrentLinkedQueue:高雄的并发队列,使用链表实现。可以看做一个线程安全的 LinkedList,这是一个非阻塞队列。
BlockingQueue: 这是一个接口,JDK 内部通过链表、数组等方式实现了这个接口。表示阻塞队列,非常适合用于作为数据共享的通道。
ConcurrentSkipListMap: 跳表的实现。这是一个 Map,使用跳表的数据结构进行快速查找。
1-ConcurrentHashMap:
Collections.synchronizedMap(new HashMap<>())这是通过使用一个全局的锁来同步不同线程间的并发访问,因此会带来不可忽视的性能问题。
所以就有了 HashMap 的线程安全版本—— ConcurrentHashMap 的诞生。在 ConcurrentHashMap 中,无论是读操作还是写操作都能保证很高的性能:
在进行读操作时(几乎)不需要加锁,而在写操作时通过锁分段技术只对所操作的段加锁而不影响客户端对其它段的访问。
1.7——>分段锁 Segment数组+HashEntry 数组+ 链表
Segment 实现了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。
一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,
当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。
1.8——> CAS+synchronized Node 数组 + 链表 / 红黑树
synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,效率又提升 N 倍。
2-CopyOnWriteArrayList:
CopyOnWriteArrayList 类的所有可变操作(add,set 等等)都是通过创建底层数组的新副本来实现的。
读取操作没有任何同步控制和锁操作,理由就是内部数组 array 不会发生修改,只会被另外一个 array 替换,因此可以保证数据安全。
CopyOnWriteArrayList 写入操作 add() 方法在添加集合的时候加了锁,保证了同步,避免了多线程写的时候会 copy 出多个副本出来。
3-ConcurrentLinkedQueue:
Java 提供的线程安全的 Queue 可以分为阻塞队列和非阻塞队列。
阻塞队列的典型例子是 BlockingQueue。 阻塞队列可以通过加锁来实现
非阻塞队列的典型例子是ConcurrentLinkedQueue。 非阻塞队列可以通过 CAS 操作实现。
ConcurrentLinkedQueue 链表 CAS 非阻塞算法
ConcurrentLinkedQueue应该算是在高并发环境中性能最好的队列了。它之所有能有很好的性能,是因为其内部复杂的实现。
4-BlockingQueue:
BlockingQueue是一个接口,继承自 Queue,所以其实现类也可以作为 Queue 的实现来使用,而 Queue 又继承自 Collection 接口。
BlockingQueue-阻塞队列-提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。
ArrayBlockingQueue 有界队列实现类 底层数组 一旦创建,容量不能改变。 默认非公平性。
LinkedBlockingQueue 底层单向链表 阻塞队列 比ArrayBlockingQueue具有更高的吞吐量。
有界阻塞队列:为了防止 LinkedBlockingQueue 容量迅速增,损耗大量内存。通常在创建 LinkedBlockingQueue 对象时,会指定其大小。
无界阻塞队列:如果未指定,容量等于 Integer.MAX_VALUE。
PriorityBlockingQueue 支持优先级的无界阻塞队列。默认情况下元素采用自然顺序进行排序。
也可以通过自定义类实现 compareTo() 方法来指定元素排序规则,或者初始化时通过构造器参数 Comparator 来指定排序规则。
并发控制采用的是 ReentrantLock。只能指定初始的队列大小,后面插入元素的时候,如果空间不够的话会自动扩容。
它就是 PriorityQueue 的线程安全版本。不可以插入 null 值,同时,插入队列的对象必须是可比较大小的(comparable),
否则报 ClassCastException 异常。它的插入操作 put 方法不会 block,因为它是无界队列(take 方法在队列为空的时候会阻塞)。
5-ConcurrentSkipListMap:
对于一个单链表,即使链表是有序的,如果我们想要在其中查找某个数据,也只能从头到尾遍历链表,这样效率自然就会很低,跳表就不一样了。
跳表是一种可以用来快速查找的数据结构,有点类似于平衡树。它们都可以对元素进行快速的查找。
但一个重要的区别是:对平衡树的插入和删除往往很可能导致平衡树进行一次全局的调整。而对跳表的插入和删除只需要对整个数据结构的局部进行操作即可。
这样带来的好处是:在高并发的情况下,你会需要一个全局锁来保证整个平衡树的线程安全。而对于跳表,你只需要部分锁即可。
这样,在高并发环境下,你就可以拥有更好的性能。而就查询的性能而言,跳表的时间复杂度也是 O(logn) 所以在并发数据结构中,JDK 使用跳表来实现一个 Map。
跳表的本质是同时维护了多个链表,并且链表是分层的。跳表是一种利用空间换时间的算法。
JDK 中实现这一数据结构的类是 ConcurrentSkipListMap。