十三、线程池
经常创建和销毁线程、使用量特别大的资源,比如并发情况下的线程,对性能影响很大。因此可以提前创建好多个线程,放入线程池中,使用时直接获取,使用完放回池中。 可以避免频繁创建销毁、实现重复利用。
(一)线程池的好处
- 提高响应速度(减少了创建新线程的时间)
- 降低资源消耗(重复利用线程池中线程,不需要每次都创建)
- 便于对线程进行管理
- 线程复用
- 可以控制最大并发数
(二)三大方法
说到线程池,必须提Executor接口和Executors类
-
Executor:一个接口,其定义了一个接收Runnable对象的方法
executor(Runnable command),
该方法接收一个Runable子实现类,创建线程方法:executor.execute(new RunnableTask());
-
Executors:工具类、线程池的工厂类,用于创建并返回不同类型的线程池
Executors里有三种常用的创建线程池的方法:
public static ExecutorService newSingleThreadExecutor()
:创建一个单线程化的Executor。public static ExecutorService newFiexedThreadPool(int Threads)
: 创建固定数目线程的线程池。public static ExecutorService newCachedThreadPool()
:创建一个可缓存的线程池(容量可伸缩,遇强则强,遇弱则弱)
案例演示1
创建一个单线程化的Executor。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class MyTest{
public static void main(String[] args) {
ExecutorService threadPool = Executors.newSingleThreadExecutor();
try {
for (int i = 0; i < 10; i++) {
//使用线程池的方法来创建并启动线程
threadPool.execute(()->{
System.out.println(Thread.currentThread().getName()+"执行了");
});
}
} finally {
//线程池用完,关闭线程池
threadPool.shutdown();
}
}
}
通过结果我们可以看到,都是同一个线程在执行,因为线程池中只有一个线程
案例演示2
创建固定数目线程的线程池。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class MyTest{
public static void main(String[] args) {
//创建固定数目为5的线程池:5个线程
ExecutorService threadPool = Executors.newFixedThreadPool(5);
try {
for (int i = 0; i < 10; i++) {
//使用线程池的方法来创建并启动线程
threadPool.execute(()->{
System.out.println(Thread.currentThread().getName()+"执行了");
});
}
} finally {
//线程池用完,关闭线程池
threadPool.shutdown();
}
}
}
案例演示3
创建一个可缓存的线程池
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class MyTest{
public static void main(String[] args) {
ExecutorService threadPool = Executors.newCachedThreadPool();
try {
for (int i = 0; i < 10; i++) {
//使用线程池的方法来创建并启动线程
threadPool.execute(()->{
System.out.println(Thread.currentThread().getName()+"执行了");
});
}
} finally {
//线程池用完,关闭线程池
threadPool.shutdown();
}
}
}
(三)七大参数
通过三大方法的构造方法我们可以看到:
它们本质上都是用了ThreadPoolExecutor的构造方法
ThreadPoolExecutor的构造方法最多有七种参数:
ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)
创建一个新 ThreadPoolExecutor给定的初始参数。
- int corePoolSize 核心线程池大小
- int maximumPoolSize 最大线程池大小
- long keepAliveTime 超过该时间没有人调用就会释放
- TimeUnit unit 超时单位
- BlockingQueue workQueue 阻塞队列
- ThreadFactory threadFactory 线程工厂,创建线程的,一般不用动
- RejectedExecutionHandler handler 拒绝处理策略
我们从上面的代码可以看到,newSingleThreadExecutor()
和newFixedThreadPool()
中的阻塞队列用的都是LinkedBlockingQueue
,而这种队列的容量通常为 Integer.MAX_VALUE
,也即21亿,也就是说允许的请求队列长度为21亿,可能会堆积大量的请求,从而导致OOM(Out Of Memory),而newCachedThreadPool()
的最大线程池(maximumPoolSize)大小也为Integer.MAX_VALUE
,可能会创建大量的线程,从而导致OOM,因此,在实际的开发中,我们尽量不使用Executors工具类创建线程池,而是使用ThreadPoolExecutor
可以通过一个例子来理解七大参数:
银行处理业务
某银行共有五个窗口(maximumPoolSize
),常开的窗口只有两个(corePoolSize
),另外三个是业务繁忙的时候才开放,并且银行大厅还有一个排队等候区,等候区有三个位置(阻塞队列BlockingQueue
),如果超过三秒(keepAliveTime
)后三个窗口都没有业务,那么这三个窗口就会关闭(线程池释放)。如果有一天银行办理业务的人数爆满,且五个窗口都有人占用,等候区也满了的情况下,其余多出来的人银行就会有相应的策略(拒绝处理策略)去处理他们。
- 共有四种拒绝策略:
ThreadPoolExecutor.AbortPolicy
银行满了,还有人进来,那么不招待这个人,并且抛出异常ThreadPoolExecutor.CallerRunsPolicy
银行满了,还有人进来,那么推给他自己所在的公司去处理(调用execute本身的线程去运行该任务)。 这提供了一个简单的反馈控制机制,将降低新任务提交的速度。ThreadPoolExecutor.DiscardPolicy
银行满了,还有人进来,那么不招待这个人,不会抛出异常(简单地删除无法执行的任务。)ThreadPoolExecutor.DiscardOldestPolicy
银行满了,还有人进来,那么尝试和前面最早来办理业务的人进行竞争,如果竞争成功那么他可以办理业务,竞争失败,不招待这个人,不抛出异常(如果执行程序没有关闭,则工作队列头部的任务被删除,然后重试执行,可能会再次失败,导致重复)。
案例演示
银行办理业务
import java.util.concurrent.*;
public class MyTest{
public static void main(String[] args) {
ExecutorService threadPool = new ThreadPoolExecutor(2,5,
3, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(3),
Executors.defaultThreadFactory(),//默认的 一般不用动
new ThreadPoolExecutor.AbortPolicy());
try {
for (int i = 1; i <= 2; i++) { //业务办理最大承载数:maximumPoolSize+队列=8
//使用线程池的方法来创建并启动线程
threadPool.execute(()->{
System.out.println(Thread.currentThread().getName()+"执行了");
});
}
} finally {
//线程池用完,关闭线程池
threadPool.shutdown();
}
}
}
可以看到,当只有两个人的情况下,只有两个窗口工作
改为for (int i = 1; i <= 5; i++)后,仍旧是两个核心窗口在处理业务(因为等候区能容纳三人)
改为for (int i = 1; i <= 6; i++)后,等候区坐不下了,第三个窗口也开放了
改为for (int i = 1; i <= 8; i++)后,第四、第五个窗口都开放了
改为for (int i = 1; i <= 9; i++)后,超过了银行最大承载数,由于使用的是AbortPolicy,所以最后一个进来的人,被拒绝招待并抛出异常
换成CallerRunsPolicy后:推给了main线程执行,因为执行execute的是main线程
换成DiscardPolicy后,不招待最后一个人,但不抛出异常
换成DiscardOldestPolicy后,尝试竞争,但不成功,不招待,不抛出异常
我们知道,每台电脑的CPU核数可能不一样,那么最大线程池大小怎么定义最好呢?
- CPU密集型:CPU有几核,最大线程池大小就定义为几
Runtime.getRuntime().availableProcessors()
- IO密集型:程序中有多少个IO较多(IO比较消耗资源)的线程,最大线程池大小就定义为比它大的数
十四、Stream流式计算
-
大数据就是存储+计算
存储交给集合、MySQL解决,计算交给流来解决 -
java.util.stream包下有一个Stream接口,有很多实用的方法,常用的几个:
Stream<T> filter(Predicate<? super T> predicate)
返回由与此给定谓词匹配的此流的元素组成的流。
<R> Stream<R> map(Function<? super T,? extends R> mapper)
返回由给定函数应用于此流的元素的结果组成的流。
Stream<T> sorted()
返回由此流的元素组成的流,根据自然顺序排序。
Stream<T> sorted(Comparator<? super T> comparator)
返回由该流的元素组成的流,根据提供的 Comparator进行排序。
Stream<T> limit(long maxSize)
返回由此流的元素组成的流,截短长度不能超过 maxSize 。
思考题:一分钟内完成此题,只能用一行代码实现!
现在有5个用户,筛选:
1、ID 必须是偶数
2、年龄必须大于23岁
3、用户名转为大写字母
4、用户名字母倒着排序
5、只输出一个用户
import java.util.Arrays;
import java.util.List;
public class MyTest{
public static void main(String[] args) {
User u1 = new User(1,"a",21);
User u2 = new User(2,"b",22);
User u3 = new User(3,"c",23);
User u4 = new User(4,"d",24);
User u5 = new User(6,"e",25);
List<User> list = Arrays.asList(u1, u2, u3, u4, u5);
list.stream().filter(u->{return u.getId()%2==0;}).filter(u->{return u.getAge()>23;})
.map(u->{return u.getName().toUpperCase();}).sorted((uu1,uu2)->{return uu2.compareTo(uu1);})
.limit(1).forEach(System.out::println);
//System.out::println就是把你遍历出来的每一个对象都用来去调用System.out
// (也就是PrintStream类的一个实例)的println方法。
//Consumer<String> c1 = System.out::println;等价于
//Consumer<String> c2 = (x)->System.out.println(x);
}
}
class User{
private int id;
private String name;
private int age;
public User(int id, String name, int age) {
this.id = id;
this.name = name;
this.age = age;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
@Override
public String toString() {
return "User{" +
"id=" + id +
", name='" + name + '\'' +
", age=" + age +
'}';
}
}
十五、ForkJoin详解
(一)原理
JDK1.7引入了一种新的Fork/Join线程池,它可以执行一种特殊的任务:把一个大任务拆成多个小任务并行执行(分而治之的思想)。尤其适用于大数据量的操作,提高效率
我们举个例子:如果要计算一个超大数组的和,最简单的做法是用一个循环在一个线程内完成:
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
还有一种方法,可以把数组拆成两部分,分别计算,最后加起来就是最终结果,这样可以用两个线程并行执行:
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
如果拆成两部分还是很大,我们还可以继续拆,用4个线程并行执行:
┌─┬─┬─┬─┬─┬─┐
└─┴─┴─┴─┴─┴─┘
┌─┬─┬─┬─┬─┬─┐
└─┴─┴─┴─┴─┴─┘
┌─┬─┬─┬─┬─┬─┐
└─┴─┴─┴─┴─┴─┘
┌─┬─┬─┬─┬─┬─┐
└─┴─┴─┴─┴─┴─┘
(二)使用
要想使用Fork/Join线程池,就必须知道ForkJoinPool和ForkJoinTask
- ForkJoinPool是一种ExecutorService的实现,用于运行ForkJoinTask任务。
- ForkJoinTask是在ForkJoinPool中执行的任务的基本类型。通常我们会自定义一个任务类,来继承ForkJoinTask的两个子类:RecursiveAction或者RecursiveTask(V),其中RecursiveAction任务没有返回值,RecursiveTask任务有返回值。这两个子类都有一个抽象的方法,叫做
compute()
- ForkJoinTask有两个方法
ForkJoinTask<V> fork()
和static void invokeAll(ForkJoinTask<?>... tasks)
,它们都是将任务拆分并异步执行 - ForkJoinTask还有一个方法:
V join()
当子任务完成返回计算结果。
- ForkJoinTask有两个方法
ForkJoinPool维护的都是双端队列。ForkJoinPool区别于其它ExecutorService,主要是因为它采用了一种工作窃取(work-stealing)的机制。
当线程B先执行完任务,而线程A还没完成,那么线程B就会尝试窃取线程A的工作提交到池子里的任务来执行,执行中又可产生子任务提交到池子中。
Fork/Join使用步骤:
- 自定义一个继承ForkJoinTask或其子类的类
- 新建一个ForkJoinPool
- 通过ForkJoinPool的执行方法(execute、submit、invoke)执行ForkJoinTask
execute、submit、invoke的区别
execute(ForkJoinTask)
异步执行tasks,无返回值submit(ForkJoinTask)
类似execute,异步执行tasks,不同的是它带Task返回值,可通过task.get 实现同步到主线程invoke(ForkJoinTask)
有Join, tasks会被同步到主进程,使用submit你的主线程由于不会等候子任务结果,它就直接结束了。但是invoke可以,因为它有那句join调用
案例演示1
求1到1亿的累加和
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;
public class MyTest{
public static void main(String[] args) {
long start=System.currentTimeMillis();
ForkJoinPool forkJoinPool = new ForkJoinPool();//2. 新建一个ForkJoinPool
Test test = new Test(0L, 10_0000_0000L);
forkJoinPool.execute(test);//3. 通过ForkJoinPool的执行方法(execute、submit、invoke)执行ForkJoinTask
Long invoke = forkJoinPool.invoke(test);
long end=System.currentTimeMillis();
System.out.println("时间:"+(end-start)/1000.0+"秒,结果:"+invoke);
}
}
class Test extends RecursiveTask<Long>{//1. 自定义一个继承ForkJoinTask或其子类的类,要重写compute()
private Long start;
private Long end;
private Long criticalValue=10000L;//临界值 如果大于该值就采取ForkJoin方法
public Test(Long start, Long end) {
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
Long sum=0L;
if (start - end < criticalValue) {
for(Long i = start;i<=end;i++){
sum+=i;
}
return sum;
}else{
Long middle=(end-start)/2;//中间值
Test test1 = new Test(start, middle);
test1.fork(); 拆分任务,把任务压入线程队列
Test test2 = new Test(middle + 1, end);
test2.fork(); 拆分任务,把任务压入线程队列
return test1.join()+test2.join();
}
}
}
然而实际上使用fork()方法来让子任务采取异步方法执行,这不是高效的实现方法,尤其是对于forkjoinPool在线程有限的情况下,子任务直接使用fork方法执行时间比使用invokeAll执行时间要长,举个例子:
-
fork:假设A手上一共有400个任务,他会将这400个任务拆分成两个200的任务分别分给B和C,那么A就不干活了,只需要等待B和C干完活再汇报给A;而B又会把200的任务分成两个100的任务分给D和E,B也不干活了,等待汇报;同理C也将任务分给F和G。这样,本来只需要4个人干的活,现在需要7个人,并且其中有3个是不干活的。
-
invokeAll:A手上一共有400个任务,他会将这400个任务拆分成2个200的任务,分一个给B,自己留一个,A再将这个200的任务分一半给C,自己留一半,B也分一半给D,自己留一半。
其实,我们查看JDK的invokeAll()
方法的源码就可以发现,invokeAll的N个任务中,其中N-1个任务会使用fork()交给其它线程执行,但是,它还会留一个任务自己执行,这样,就充分利用了线程池,保证没有空闲的不干活的线程。
案例演示2
优化后的代码
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;
public class MyTest{
public static void main(String[] args) {
long start=System.currentTimeMillis();
ForkJoinPool forkJoinPool = new ForkJoinPool();//2. 新建一个ForkJoinPool
Test test = new Test(0L, 10_0000_0000L);
forkJoinPool.execute(test);
Long invoke = forkJoinPool.invoke(test);//3. 通过ForkJoinPool的执行方法(execute、submit、invoke)执行ForkJoinTask
long end=System.currentTimeMillis();
System.out.println("时间:"+(end-start)/1000.0+"秒,结果:"+invoke);
}
}
class Test extends RecursiveTask<Long>{//1. 自定义一个继承ForkJoinTask或其子类的类,要重写compute()
private Long start;
private Long end;
private Long criticalValue=10000L;//临界值 如果大于该值就采取ForkJoin方法
public Test(Long start, Long end) {
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
Long sum=0L;
if (start - end < criticalValue) {
for(Long i = start;i<=end;i++){
sum+=i;
}
return sum;
}else{
Long middle=(end-start)/2;//中间值
Test test1 = new Test(start, middle);
Test test2 = new Test(middle + 1, end);
invokeAll(test1,test2);
return test1.join()+test2.join();
}
}
}
拓展
这道题还可以通过Stream流式计算来解决,执行效率将大大提高!
java.util.stream包下还有一个接口LongStream,用于Long类型数据的流式计算
static LongStream range(long startInclusive, long endExclusive)
需要传入开始节点和结束节点两个参数,返回的是一个有序的LongStream。包含开始节点和结束节点两个参数之间所有的参数,间隔为1。
static LongStream rangeClosed(long startInclusive, long endInclusive)
需要传入开始节点和结束节点两个参数,返回的是一个有序的LongStream。包含开始节点和结束节点两个参数之间所有的参数,间隔为1。
LongStream parallel()
返回并行的等效流。(并行执行)
long reduce(long identity, LongBinaryOperator op)
用于对stream中元素进行聚合求值
- long identity:累加函数的标识值。
- LongBinaryOperator op:对两个值的关联无状态函数进行运算并产生long值结果。
import java.util.stream.LongStream;
public class MyTest{
public static void main(String[] args) {
long start=System.currentTimeMillis();
long result = LongStream.rangeClosed(0L, 10_0000_0000L).parallel().reduce(0, (num1, num2) -> (num1 + num2));
long end=System.currentTimeMillis();
System.out.println("时间:"+(end-start)/1000.0+"秒,结果:"+result);
}
}
十六、异步调用
十七、JMM
(一)什么是JMM
-
内存模型可以理解为在特定的操作协议下,对特定的内存或者高速缓存进行读写访问的过程抽象描述,不同架构下的物理机拥有不一样的内存模型,Java虚拟机是一个实现了跨平台的虚拟系统,因此它也有自己的内存模型,即Java内存模型(Java Memory Model, JMM)。
因此它不是对物理内存的规范,而是在虚拟机基础上进行的规范从而实现平台一致性,以达到Java程序能够“一次编写,到处运行”。 -
究竟什么是内存模型?
内存模型描述了程序中各个变量(实例域、静态域和数组元素)之间的关系,以及在实际计算机系统中将变量存储到内存和从内存中取出变量这样的底层细节 -
Java Memory Model(Java内存模型),是围绕着在并发过程中如何处理可见性、原子性、有序性这三个特性而建立的模型。
(二)JMM中的8种操作
内存交互操作有8种,虚拟机实现必须保证每一个操作都是原子的,不可再分的
- lock (锁定):作用于主内存的变量,把一个变量标识为线程独占状态
- unlock (解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
- read (读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
- load (载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中
- use (使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令
- assign (赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中
- store (存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用
- write (写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中
JMM对这8种操作的使用,制定了如下规则:
- 不允许read和load、store和write操作之一单独出现。即 使用了read必须load,使用了store必须write
- 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存
- 不允许一个线程将没有assign的数据从工作内存同步回主内存
- 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施use、store操作之前,必须经过assign和load操作
- 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁
- 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值
- 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量对一个变量进行unlock操作之前,必须把此变量同步回主内存
然而多线程的内存交互中会出现一个问题:
import java.util.concurrent.TimeUnit;
public class MyTest{
public static int num=0;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
while(num==0){
}
}).start();
TimeUnit.SECONDS.sleep(2);//休眠2秒,确保子线程已经开始执行
num=1;//主线程修改主内存的共享变量为1
System.out.println(num);
}
}
程序没有停止
这时就要引入volatile了:
十八、volatile
volatile是JAVA一个关键字,它有三个特点:
- 可见性
- 不保证原子性
- 禁止指令重排
(一)可见性
案例演示
上面那个问题就可以通过volatile修饰共享变量解决,保证共享变量的改动所有的线程都可见
import java.util.concurrent.TimeUnit;
public class MyTest{
public volatile static int num=0;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
while(num==0){
}
}).start();
TimeUnit.SECONDS.sleep(2);//休眠2秒,确保子线程已经开始执行
num=1;//主线程修改主内存的共享变量为1
System.out.println(num);
}
}
(二)不保证原子性
原子性:一个操作不能被打断,要么全部执行完毕,要么不执行。(在这点上有点类似于事务操作,要么全部执行成功,要么回退到执行该操作之前的状态。)
案例演示
用volatile修饰的变量在多线程情况下,会出现并发问题
public class MyTest{
public volatile static int num=0;
public static void add(){
num++;
}
public static void main(String[] args) {
for (int i = 0; i < 20; i++) {
new Thread(()->{
for (int i1 = 0; i1 < 1000; i1++) {
add();
}
}).start();
}
//保证线程执行完,再输出一句话
while (Thread.activeCount()>2){//线程活跃个数>2(main、gc),证明还有线程没执行完
Thread.yield();//礼让
}
System.out.println(num);
}
}
结果理应是20000,然而实际并不是
当然,在add方法上加上synchronized(或者lock)可以解决这个问题,加了同步锁后,num自增的操作变成了原子性操作,但是synchronized虽然确保了线程的安全,但是在性能上却不是最优的,synchronized关键字会让没有得到锁资源的线程进入BLOCKED
状态,而后在争夺到锁资源后恢复为RUNNABLE
状态,这个过程中涉及到操作系统用户模式和内核模式的转换,代价比较高。
但是除了这两种方法,有什么办法保证原子性呢?
使用JUC的原子类,解决这个问题:
案例演示
import java.util.concurrent.atomic.AtomicInteger;
public class MyTest{
//AtomicInteger()创建一个新的AtomicInteger,初始值为 0 。
public static AtomicInteger num=new AtomicInteger();
public static void add(){
num.getAndIncrement();//AtomicInteger的+1操作
}
public static void main(String[] args) {
for (int i = 0; i < 20; i++) {
new Thread(()->{
for (int i1 = 0; i1 < 1000; i1++) {
add();
}
}).start();
}
//保证线程执行完,再输出一句话
while (Thread.activeCount()>2){//线程活跃个数>2(main、gc),证明还有线程没执行完
Thread.yield();//礼让
}
System.out.println(num);
}
}
(三)禁止指令重排
举个例子:你提前给餐厅的前台打电话预定1号桌的位置,半小时后到。然而前台忘记告诉服务员和老板,现在是饭点,来餐厅的人很多,有客人看到1号桌没人,服务员和老板便招待他坐下了,而这时你到了餐厅,这个结果不是你想要的,于是餐厅给了你相应的赔偿。吃一堑长一智,老板从此都会在餐桌上放一个牌子标识已被预定的位置。
从计算机执行指令角度来分析的话,你要到1号桌吃饭,这是预期结果。餐厅就相当于是处理器,前台就相当于是编译器,服务员和老板就是指令和内存系统。如果你预定的时间点不是吃饭高峰期或者没有人去餐厅吃饭。那么你就相当于是一个线程。就是单线程的。预期结果与实际结果就是一致的。
如果你预定的时间点是饭点,很多人来吃饭(很多线程),这个时候为了餐厅效益,无论是前台还是服务员或者是老板都会对你的位置进行重排序。在你没有来的时候,会安排其他人到你预定的位置吃饭。如果其他人在你的位置吃饭,这个时候你再来吃饭,那么实际结果和预期结果就不一样了。其中这个放在餐桌上的牌子就是特殊类型的内存屏障了。
内存屏障作用:
- 保证特定的操作的执行顺序!
- 可以保证某些变量的内存可见性 (利用这个特性volatile实现了可见性)
什么是指令重排: 在计算机执行指令的顺序在经过程序编译器编译之后形成的指令序列,一般而言,这个指令序列是会输出确定的结果;以确保每一次的执行都有确定的结果。但是,一般情况下,CPU和编译器为了提升程序执行的效率,会按照一定的规则允许进行指令优化,在某些情况下,这种优化会带来一些执行的逻辑问题,主要的原因是代码逻辑之间是存在一定的先后顺序,在并发执行情况下,会发生二义性,即按照不同的执行逻辑,会得到不同的结果信息。
通俗地说就是,你写的程序,计算机并不是按照你写的那样去执行的。
源代码–>编译器优化的重排–> 指令并行也可能会重排–> 内存系统也会重排–> 执行
int x = 1; // 1
int y = 2; // 2
x = x + 5; // 3
y = x * x; // 4
我们所期望的执行顺序:1234,但是可能执行的时候会被指令重排变成 2134 1324 结果依然是一样的,然而不可能重排变成4123,因为处理器在进行指令重排的时候,也会考虑数据之间的依赖性!
再比如,假设a b x y 这四个值初始值都是 0
线程A | 线程B |
---|---|
x=a | y=b |
b=1 | a=2 |
正常的结果: x = 0;y = 0;但是可能由于指令重排
线程A | 线程B |
---|---|
b=1 | a=2 |
x=a | y=b |
指令重排导致的诡异结果: x = 2;y = 1;
由于内存屏障,volatile可以避免指令重排
十九、深入理解CAS
(一)悲观锁和乐观锁
synchronized就是一种独占锁,独占锁是一种悲观锁,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁,因此存在许多性能问题:
- 多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
- 一个线程持有锁会导致其它所有需要此锁的线程挂起。
- 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。
而另一个更加有效的锁就是乐观锁。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试(自旋),直到成功为止。乐观锁用到的机制就是CAS(Compare And Swap,比较并交换)
(二)CAS机制
CAS是英文单词Compare And Swap的缩写,翻译过来就是比较并替换。
CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值V与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B 。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前值。)CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将A替换为B;否则,不要替换,只告诉我这个位置现在的值即可。”
前面介绍的原子操作类,它的底层实现正是利用的CAS机制
我们点开AtomicInteger的源码可以看到:
Unsafe
类是不安全的类,它提供了一些底层的方法,我们是不能直接使用这个类的。这个类中的代码大部分都用native
修饰,是比较接近底层的,不是用Java写的,是用c或者c++写的。AtomicInteger的值保存在value
中,而valueOffset
是value
在内存中的偏移量,利用静态代码块使其类一加载的时候就赋值。value
值使用volatile
,保证其可见性。
我们再接着点开AtomicInteger中的getAndIncrement()
方法:可以看到这里用到了Unsafe类的getAndAddInt()
方法
接着点开Unsafe类的getAndAddInt()
的源码:
var1表示当前对象,var2表示value在内存中的偏移量,var4为增加的值。var5为调用底层方法获取当前对象的内存地址所指向的值也就类似于c和c++中的指针指向的值(value)
compareAndSwapInt()
方法(即CAS)通过var1和var2获取当前内存中的value值,并与var5进行比对,如果一致,说明没有其他线程更改,就将var5+var4的值赋给value(其中var4=1),并返回true,否则返回false。这是一个内存操作,所以效率很高。
由do while语句可知,如果这次没有设置进去值,就重复执行此过程。这一过程称为自旋。
compareAndSwapInt()
是JNI(Java Native Interface)提供的方法,可以是其他语言写的。
AtomicInteger中的compareAndSet()
方法也是调用了Unsafe类中的compareAndSwapInt()
方法:
案例演示1
AtomicInteger中的compareAndSet()
方法
import java.util.concurrent.atomic.AtomicInteger;
public class MyTest{
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(123);
//如果原值为123,那么久换成666
System.out.println(atomicInteger.compareAndSet(123, 666));
System.out.println(atomicInteger.get());
//此时原值已经不是123,会修改失败
System.out.println(atomicInteger.compareAndSet(123, 666));
System.out.println(atomicInteger.get());
}
}
然而,CAS机制也存在许多不足:
- 循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。
- 只能保证一个共享变量的原子性。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JUC包的atomic包里提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
- ABA问题。因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新。但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值并没有发生变化,但是实际上却是被动过了。ABA问题的解决思路就是使用版本号。从Java1.5开始JUC包的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。
案例演示2
ABA问题
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class MyTest{
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(123);
new Thread(()->{
//第一条线程先将123替换为666,再将666替换为123
System.out.println(Thread.currentThread().getName()+":"+
atomicInteger.compareAndSet(123, 666));
System.out.println(Thread.currentThread().getName()+":"+atomicInteger.get());
System.out.println(Thread.currentThread().getName()+":"+
atomicInteger.compareAndSet(666, 123));
System.out.println(Thread.currentThread().getName()+":"+atomicInteger.get());
}).start();
new Thread(()->{
//延时,保证第一条线程先执行完
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
//值是已经被第一条线程动过的,但是第二条线程不知道,CAS仍旧能成功(true),ABA问题
System.out.println(Thread.currentThread().getName()+":"+
atomicInteger.compareAndSet(123, 233));
System.out.println(Thread.currentThread().getName()+":"+atomicInteger.get());
}).start();
}
}
如果值已经被Thread-0动过,我们希望Thread-1的CAS返回false
二十、原子引用
(一)AtomicReference类
它可以让我们自定义的类保证原子性
案例演示
import java.util.concurrent.atomic.AtomicReference;
public class Person {
private String name;
private int age;
public Person(String name, int age){
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
public static void main(String[] args) {
Person p1 = new Person("zhangsan",18);
Person p2 = new Person("lisi",25);
AtomicReference<Person> atomic = new AtomicReference<>();
atomic.set(p1);
//修改成功,设置主内存数据为p2
System.out.println(atomic.compareAndSet(p1,p2)+" "+atomic.get().toString());
//修改失败,主内存数据还是p2
System.out.println(atomic.compareAndSet(p1,p2)+" "+atomic.get().toString());
}
}
(二)AtomicStampedReference类
通俗地讲,AtomicStampedReference类就是带版本号的原子操作
它自带版本号,可以很好地解决ABA问题
-
构造方法:
AtomicStampedReference(V initialRef, int initialStamp)
创建一个新的 AtomicStampedReference与给定的初始值。( initialRef为初始值,initialStamp为初始的版本号) -
常用方法:
boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp)
如果当前的参考值==预期的参考值expectedReference ,并且当前版本号等于预期版本号expectedStamp,则将原值替换为新值newReference,版本号替换为新的版本号newStamp,并返回true;否则都不替换,返回false。
int getStamp()
返回当前版本号的值。
案例演示
AtomicStampedReference解决ABA问题
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicStampedReference;
public class MyTest{
public static void main(String[] args) {
AtomicStampedReference<Integer> asr = new AtomicStampedReference<>(50, 1);
new Thread(()->{
int stamp = asr.getStamp();
System.out.println(Thread.currentThread().getName()+":"+stamp);
//延时,保证一开始两条线程的版本号都一致
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
//第一条线程先将50替换为51,再将51替换为50
System.out.println(Thread.currentThread().getName()+":"+
asr.compareAndSet(50, 51,
asr.getStamp(), asr.getStamp() + 1));
//获取当前版本号
System.out.println(Thread.currentThread().getName()+":"+asr.getStamp());
System.out.println(Thread.currentThread().getName()+":"+
asr.compareAndSet(51, 50,
asr.getStamp(), asr.getStamp() + 1));
//获取当前版本号
System.out.println(Thread.currentThread().getName()+":"+asr.getStamp());
}).start();
new Thread(()->{
int stamp = asr.getStamp();
System.out.println(Thread.currentThread().getName()+":"+stamp);
//延时,保证一开始两条线程的版本号都一致,并且第一条线程先执行完
try {
TimeUnit.SECONDS.sleep(4);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+":"+
asr.compareAndSet(50, 60,
stamp,stamp + 1));
//获取当前版本号
System.out.println(Thread.currentThread().getName()+":"+asr.getStamp());
}).start();
}
}
一开始,两条线程的AtomicStampedReference的版本号都是1,当Thread-0替换两次结束后,版本号加到了3,此时较慢的第二条线程的CAS方法判断当前值50虽然和预期值一样,但是版本号不是之前的1了,所以返回false。
二十一、各种锁的理解
(一)公平锁和非公平锁
公平锁: 非常公平, 不能够插队,必须先来后到!
非公平锁:非常不公平,可以插队 (默认都是非公平)
//ReentrantLock有两个构造方法,空参构造默认是非公平锁
public ReentrantLock() {
sync = new NonfairSync();
}
//有参构造可以设置为公平锁或者非公平锁
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
(二)死锁
多个线程各自占有一些共享资源,并且互相等待其他线程使用完占有的资源才能运行,而导致两个或多个线程都在等待对方释放资源,都停止执行的情形。(比如,有两个小孩A和B,小孩A手里拿着玩具汽车,小孩B手里拿着玩具飞机,A拿着玩具汽车不放,并且想要玩具飞机,B同样拿着玩具飞机不放,想要玩具汽车,于是他们谁都没办法同时玩两种玩具)某一个同步块同时拥有 “ 两个以上对象的锁 ” 时 , 就可能会发生 “ 死锁 ” 的问题。
1.产生死锁的四个必要条件:
(1)互斥条件:一个资源每次只能被一个进程使用。
(2) 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
(3)不剥夺条件 : 进程已获得的资源,在末使用完之前,不能强行剥夺。
(4) 循环等待条件 : 若干进程之间形成一种头尾相接的循环等待资源关系。
上面列出了死锁的四个必要条件,我们只要想办法破其中的任意一个或多个条件就可以避免死锁发生。
案例演示
导致死锁的根源在于不适当地运用“synchronized”关键词来管理线程对特定对象的访问
class Lipstick{//口红
}
class Mirror{//镜子
}
class Girl extends Thread{//化妆的女孩
//需要的资源只有一份,用static来修饰
static Lipstick lipstick=new Lipstick();
static Mirror mirror=new Mirror();
private String name;//女孩名字
private int choice;//选择
public Girl(String name, int choice) {
this.name = name;
this.choice = choice;
}
@Override
public void run() {
try {
makeUp();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private void makeUp() throws InterruptedException {
if(choice==0){//第一个姑娘先拿起了唯一的口红,过了一秒她抱着口红不放且想用唯一的镜子
synchronized (lipstick){
System.out.println(this.name+"拿到了口红");
Thread.sleep(1000);
synchronized (mirror){
System.out.println(this.name+"拿到了镜子");
}
}
}else{//第二个姑娘先拿起了唯一的镜子,过了两秒她抱着镜子不放且想用唯一的口红
synchronized (mirror){
System.out.println(this.name+"拿到了镜子");
Thread.sleep(2000);
synchronized (lipstick){
System.out.println(this.name+"拿到了口红");
}
}
}
}
}
public class MakeUp {
public static void main(String[] args) {
Girl g1 = new Girl("小红", 0);
Girl g2 = new Girl("小芳", 1);
g1.start();
g2.start();
}
}
程序卡住,并没停止,因为两个线程都还在等待对方各自的资源,这就是死锁。
2.死锁的排查
我们可以点开IDEA的Terminal:
输入jps -l
定位我们类的进程号
使用 jstack 进程号
找到死锁问题
发现一个死锁。
解决办法:将嵌套着的synchronized同步块取出来,也即意味着女孩一次只占用一个资源,用完再用下一个资源。
class Lipstick{//口红
}
class Mirror{//镜子
}
class Girl extends Thread{//化妆的女孩
//需要的资源只有一份,用static来修饰
static Lipstick lipstick=new Lipstick();
static Mirror mirror=new Mirror();
private String name;
private int choice;
public Girl(String name, int choice) {
this.name = name;
this.choice = choice;
}
@Override
public void run() {
try {
makeUp();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private void makeUp() throws InterruptedException {
if(choice==0){//第一个姑娘先拿起了唯一的口红,过了一秒她想用唯一的镜子
synchronized (lipstick){
System.out.println(this.name+"拿到了口红");
Thread.sleep(1000);
}
synchronized (mirror){
System.out.println(this.name+"拿到了镜子");
}
}else{//第二个姑娘先拿起了唯一的镜子,过了两秒她想用唯一的口红
synchronized (mirror){
System.out.println(this.name+"拿到了镜子");
Thread.sleep(2000);
}
synchronized (lipstick){
System.out.println(this.name+"拿到了口红");
}
}
}
}
public class MakeUp {
public static void main(String[] args) {
Girl g1 = new Girl("小红", 0);
Girl g2 = new Girl("小芳", 1);
g1.start();
g2.start();
}
}
(三)可重入锁
也叫做递归锁
可重入锁就是说某个线程已经获得某个锁,可以再次获取该锁而不会出现死锁。
可重入锁在同一线程中才可重入,不需要重新获取对象锁。
synchronized和ReentrantLock都是可重入锁
1.synchronized
案例演示
public class Demo01 {
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
synchronized (this) {
System.out.println("第1次获取锁,这个锁是:" + this);
int index = 1;
while (true) {
synchronized (this) {
System.out.println("第" + (++index) + "次获取锁,这个锁是:" + this);
}
if (index == 10) {
break;
}
}
}
}
}).start();
}
}
2.ReentrantLock
import java.util.concurrent.locks.ReentrantLock;
public class Demo01 {
public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();
new Thread(new Runnable() {
@Override
public void run() {
lock.lock();// lock和unlock必须成对出现,否则就会死在里面
try {
System.out.println("第1次获取锁,这个锁是:" + this);
int index = 1;
while (true) {
lock.lock();
try {
System.out.println("第" + (++index) + "次获取锁,这个锁是:" + this);
if (index == 10) {
break;
}
} finally {
lock.unlock();
}
}
} finally {
lock.unlock();
}
}
}).start();
}
}
(四)自旋锁
1.互斥锁和自旋锁
计算机系统资源总是有限的,有些资源需要互斥访问,因此就有了锁机制,只有获得锁的线程才能访问资源。锁保证了每次只有一个线程可以访问资源。当线程申请一个已经被其他线程占用的锁,就会出现两种情况。一种是没有获得锁的线程会阻塞自己,等到锁被释放后再被唤起,这就是互斥锁;另一种是没有获得锁的线程一直循环在那里看是否该锁的保持者已经释放了锁,这就是自旋锁。
2.自旋锁的优缺点
(1)优点
- 互斥锁从等待到解锁过程,线程会从sleep状态变为running状态,过程中有线程上下文的切换,抢占CPU等开销。而自旋锁从等待到解锁过程,线程一直处于running状态,没有上下文的切换。
- ==自旋锁不会引起调用者休眠,如果自旋锁已经被别的线程保持,调用者就一直循环在那里看是否该自旋锁的保持者释放了锁。由于自旋锁不会引起调用者休眠,所以自旋锁的效率远高于互斥锁。
(2)缺点
- 自旋锁一直占用CPU,在未获得锁的情况下,一直运行,如果不能在很短的时间内获得锁,会导致CPU效率降低。
- 试图递归地获得自旋锁会引起死锁。递归程序决不能在持有自旋锁时调用它自己,也决不能在递归调用时试图获得相同的自旋锁。
由此可见,我们要慎重的使用自旋锁,自旋锁适合于锁使用者保持锁时间比较短并且锁竞争不激烈的情况。正是由于自旋锁使用者一般保持锁时间非常短,因此选择自旋而不是睡眠是非常必要的,自旋锁的效率远高于互斥锁。
3.自旋锁的实现
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
public class Demo01{
public static void main(String[] args) throws InterruptedException {
SpinLock lock = new SpinLock();
new Thread(()->{
lock.lock();
//加延时,放大问题发生性
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
lock.unlock();
},"T1").start();
//加个延时,确保T1线程先拿到锁
TimeUnit.SECONDS.sleep(2);
new Thread(()->{
lock.lock();
lock.unlock();
},"T2").start();
}
}
class SpinLock{
AtomicReference<Thread> atomicReference=new AtomicReference<>();
public void lock(){
Thread thread = Thread.currentThread();
System.out.println(thread.getName()+"lock");
while (!atomicReference.compareAndSet(null,thread)){
}
}
public void unlock(){
Thread thread = Thread.currentThread();
System.out.println(thread.getName()+"unlock");
atomicReference.compareAndSet(thread,null);
}
}
一开始,线程T1启动,执行lock()
,输出"T1lock",将atomicReference的值从null替换为线程T1,CAS返回true,此时不走while循环。过了两秒后,线程T2启动,执行lock()
,输出"T2lock",此时atomicReference的值为线程T1,即线程T1正在使用该锁,CAS返回false,走while循环,进入自旋。当休眠结束,线程T1执行到unlock()
,输出"T1unlock",将atomicReference的值从线程T1替换为null,这时,线程T2的lock()
中while循环中的CAS又将atomicReference的值从null替换为线程T2并返回true,退出循环,执行unlock()
,输出"T2unlock",又将atomicReference的值从线程T2替换为null。