文章目录
多线程高并发基本知识及面试总结
必须推荐一个写的很好的博客:Java技术爱好者并发的内容讲的非常清晰深刻!!!
1 多线程
一个Java应用程序 java.exe
,至少有三个线程:
main()
,主线程gc()
,垃圾回收线程- 异常处理线程
1.1 进程和线程
1.1.1 基本概念
并发与并行的区别?
并发: 同⼀时间段,多个任务都在执行,时分复用,宏观上多任务同时执行,但是实际在微观上是时分复用
并行: 单位时间内,多个任务同时执行。真的同时在执行多个任务(多核cpu并行)
同步**:必须一件一件事做,等前一件做完了才能做下一件事。**
异步:异步是在等待某个资源的时候继续做自己的事
比如:
同步是指:当程序1调用程序2时,程序1停下不动,直到程序2完成回到程序1来,程序1才继续执行下去。
异步是指:当程序1调用程序2时,程序1径自继续自己的下一个动作,不受程序2的的影响。同步是指:发送方发出数据后,等接收方发回响应以后才发下一个数据包的通讯方式。
异步是指:发送方发出数据后,不等接收方发回响应,接着发送下个数据包的通讯方式。
为啥使用多线程
开销小,提高CPU的利用率
高并发的基础
可能会造成的问题:内存泄漏、上下文切换、死锁
啥叫上下文切换
当前任务在执⾏完 CPU 时间⽚切换到另⼀个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是⼀次上下文切换
什么叫线程安全?
就是线程同步的意思,就是当一个程序对一个线程安全的方法或者语句进行访问的时候,其他的不能再对他进行操作了,必须等到这次访问结束以后才能对这个线程安全的方法进行访问
线程不安全如何解决
使用同步线程锁(synchronized)
自旋操作类(AtomicInteger等 )
线程隔离类(ThreadLocal )
线程优先级,操作系统线程与JVM线程
现今 Java 中线程的本质,其实就是操作系统中的线程,其线程库和线程模型很大程度上依赖于操作系统(宿主系统)的具体实现,
1.1.2 什么叫守护线程?
用户线程:我们平常创建的普通线程。
守护线程:守护线程是指为其他线程服务的线程。GC就是守护线程
当 JVM 中不存在任何一个正在运行的非守护线程时,则 JVM 进程即会退出。
为什么需要守护线程,以及何时使用,它的应用场景是什么?
如果 JVM 中没有一个正在运行的非守护线程,JVM 会退出。换句话说,守护线程拥有自动结束自己生命周期的特性,而非守护线程不具备这个特点。JVM 中的垃圾回收线程就是典型的守护线程,如果说不具备该特性,当 JVM 要退出时,由于垃圾回收线程还在运行着,导致程序无法退出。
通常来说,守护线程经常被用来执行一些后台任务,但是呢,你又希望在程序退出时,或者说 JVM 退出时,线程能够自动关闭,此时,守护线程是你的首选。
守护线程怎么使用?
//在调用start()方法前,调用setDaemon(true)把该线程标记为守护线程
Thread itqiankunThread = new ItQiankunThread();
itqiankunThread.setDaemon(true);
itqiankunThread.start();
1.1.3 线程和进程的区别?
- 进程是对运行时程序的封装,是系统进行资源调度和分配的的基本单位,实现了操作系统的并发;
- 线程是进程的子任务,是CPU调度和分派的基本单位,用于保证程序的 实时性,实现进程内部的并发;
- 一个程序至少有一个进程,一个进程至少有一个线程,线程依赖于进程而存在;
- 进程在执行过程中拥有独立的内存单元,而多个线程共享进程的内存。多个线程共享进程的堆和方法区资源,但是每个线程有自己的程序计数器、虚拟机栈和本地方法栈。
协程:一个线程也可以拥有多个协程。其上下文切换由自己控制,由当前协程切换到其他协程由当前协程来控制。
一个进程最多可以创建多少个进程取决于什么?
- 进程的虚拟内存空间上限,因为创建一个线程,操作系统需要为其分配一个栈空间,如果线程数量越多,所需的栈空间就要越大,那么虚拟内存就会占用的越多。
- 系统参数限制,虽然 Linux 并没有内核参数来控制单个进程创建的最大线程个数,但是有系统级别的参数来控制整个系统的最大线程个数。
1.1.4 进程的通信方式
进程是分配系统资源的单位,因此各进程拥有的内存地址空间相互独立,为了保证安全,一个进程不能直接访问另一个进程的地址空间。但是进程之间的信息交换又是必须实现的。为了保证进程间的安全通信,操作系统提供了一些方法
包括:
内存类:共享内存、匿名管道、有名管道
消息类型:信号(Linux)、信号量(进程同步)、消息队列、套接字(网络通信)
1.1.5 线程通信(同步)的方式
互斥量 Synchronized/Lock:采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问
信号量 Semphare:它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量
信号(通知机制),Wait/Notify:通过通知操作的方式来保持多线程同步。
1.1.6 进程的状态(生命周期)
1.1.7 线程的生命周期
说法不一,以下是Thread 类中State的定义,以源码为准[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XeGtvRZ8-1632050425292)(D:/MyApp/HardYun/我的坚果云/java笔记/面试复习/面试进阶/多线程高并发.assets/1620455978312.png)]
1.1.8 线程死锁
线程死锁的条件
1、**互斥条件:**该资源任意一个时刻只由一个线程占用。
2、**请求与保持条件:**一个进程因请求资源而阻塞时,对已获得的资源保持不放。
3、**不剥夺条件:**线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
4、**循环等待条件:**若干进程之间形成一种头尾相接的循环等待资源关系。
如何避免线程死锁?
破坏产生死锁的四个条件中的其中一个
1、破坏互斥条件:这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。
2、破坏请求与保持条件:一次性申请所有的资源
3、破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
4、破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放破坏循环等待条件。
1.2 多线程的创建与通信
1.2.1 如何实现线程之间的数据共享
1、内部类共享外部类
2、实现Runnable接口
3、创建ThreadLocal类
参考文章:
1 、如果每个线程执行的代码相同,可以使用同一个Runnable对象,这个Runnable对象中有那个共享数据,例如,卖票系统就可以这么做。
public class SellTicket {
//卖票系统,多个窗口的处理逻辑是相同的
public static void main(String[] args) {
Ticket t = new Ticket();
new Thread(t).start();
new Thread(t).start();
}
}
/**
* 将属性和处理逻辑,封装在一个类中
*/
class Ticket implements Runnable {
private int ticket = 10;
public synchronized void run() {
while (ticket > 0) {
ticket--;
System.out.println("当前票数为:" + ticket);
}
}
}
2 、内部类共享外部类数据的思想,即把要共享的数据变得全局变量,这样就保证了操作的是同一份数据
3 、定义一个全局共享的ThreadLocal变量,然后启动多个线程向该ThreadLocal变量中存储一个随机值,接着各个线程调用另外其他多个类的方法,这多个类的方法中读取这个ThreadLocal变量的值,就可以看到多个类在同一个线程中共享同一份数据。
public class ThreadLocalTest {
private static ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
for (int i = 0; i < 2; i++) {
new Thread(new Runnable() {
@Override
public void run() {
int data = new Random().nextInt();
System.out.println(Thread.currentThread().getName() + " put random data:" + data);
threadLocal.set(data);
new A().get();
new B().get();
}
}).start();
}
}
static class A {
public void get() {
int data = threadLocal.get();
System.out.println("A from " + Thread.currentThread().getName() + " get data:" + data);
}
}
static class B {
public void get() {
int data = threadLocal.get();
System.out.println("B from " + Thread.currentThread().getName() + " get data:" + data);
}
}
}
1.2.2 多线程的创建
//Lambad表达式来代替匿名内部类创建线程
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Hello World!");
}
});
new Thread(() -> System.out.println("Hello World!"));
* 实现线程的方法 ———— 四种:
* |---继承 Thread 类,重写 run() 方法,里面写要执行的操作。创建实例对象。
* |--- class MyThread extends Thread {@Override public void run() {}}
* |--- MyThread thread1 = new MyThread();
* |--- thread.start(),启动线程,start()会调用 run()方法运行程序
*
* |---不足:只能单继承,所以数据不能共享。
*
* |---实现 Runnable 接口,重写 run() 方法,再把实例对象传入 Thread ,创建线程的实例对象
* |--- class MyThread implements Runnable{@Override public void run() {}}
* |--- 创建实例化对象:MyThread mythread = new MyThread();
* |--- 传入线程:Thread thread = new Thread(mythread);
* |--- 正常使用 thread.start()
*
* |---不足:无返回值,数据共享
*
* |---实现 Callable 接口,重写call(),多一层 FutureTask,call()比 run()更强大有返回值。
* |--- class MyThread implements Callable{@Override public void call() {}}
* |--- 创建实例化对象:MyThread mythread = new MyThread();
* |--- 实例化对象传入 FutureTask,FutureTask futureTask = new FutureTask(mythread);
* |--- 实例化对象传入 Thread,Thread thread = new Thread(futureTask)
* |--- 正常使用 thread.start()
*
* |---不足:创建复杂,无法重复利用,有返回值。
*
* |---线程池创建,一次创建多个,要就取,用完了再放回,可以重复使用。
* |--初始化创建池子:
* ExecutorService service = Executors.newFixedThreadPool(10);
* ThreadPoolExecutor service1 = (ThreadPoolExecutor) service;
* |-- 传入Thread实例对象,执行指定的线程池操作:service.execute(new Thread());
* |-- 关闭线程池:service.shutdown();
Callable相比较Runnable
有返回值,通过FutureTask.get()方法可以获取结果,但是如果获取结果是耗时的操作,可能会引起阻塞
可以抛异常
Callable并不能直接与Runnable挂上关系,从而丢进Thread中执行,而是通过Runnable的实现类FutureTask的构造方法实现
public class CallableTest {
public static void main(String[] args) throws ExecutionException, InterruptedException {
MyThread myThread = new MyThread();
FutureTask<Integer> futureTask = new FutureTask<>(myThread);
new Thread(futureTask,"A").start();
Integer integer = futureTask.get();
System.out.println(integer);
}
}
class MyThread implements Callable<Integer>{
@Override
public Integer call() throws Exception {
System.out.println("成功执行call方法!");
return 1111;
}
}
1.2.3 线程通信:wait()、sleep()、yield()、notify()、notifyAll() 辨析
sleep(),的作用是让当前线程休眠,不放锁,正在执行的线程主动让出cpu,然后cpu就可以去执行其他任务,当时间过后该线程重新由“阻塞状态”编程“就绪状态”,从而等待cpu的调度执行,注意:sleep方法只是让出了cpu的执行权,并不会释放同步资源锁。
yield()的作用是让步,它能够让当前线程从“运行状态”进入到“就绪状态”,从而让其他等待线程获取执行权,但是不能保证在当前线程调用yield()之后,其他线程就一定能获得执行权
wait() ,释放锁,让当前线程处于“等待(阻塞)状态”,直到其他线程**调用此对象的 notify() 方法或 notifyAll() **方法”,当前线程被唤醒(进入“就绪状态”)。
notify()方法不能唤醒某个具体的线程,所以只有一个线程在等 待的时候它才有用武之地。而notifyAll()唤醒所有线程并允许他们争夺锁确保了至少有一个线程能继续运行。
- |— wait() 与 sleep()
- |—wait():阻塞,释放监视器
- |—sleep():阻塞,不放
- |— wait(),notify(),notifyAll()三个方法必须使用在同步代码块或同步方法中。
- |— Thread类: sleep() , Object类中: wait()、notify()、notifyAll()
为什么把wait()等方法放在Object类中
JAVA提供的锁是对象级的而不是线程级的,每个对象都有锁,通过线程获得。
wait等的是某个对象上的锁,多个线程竞争这个锁,wait和notify方法都放到目标对象上,那这个对象上可以维护线程的队列,可以对相关线程进行调度。
如果将wait方法和线程队列都放到Thread中,线程正在等待的是哪个锁 就不明显了,那么就必然要求某个Thread知道其他所有Thread的信息。简单的说,由于wait,notify和notifyAll都是锁级别的操作,所以把他们定义在Object类中因为锁属于对象。
wait()和notify()的区别?
1.3 线程池
线程池重点:四大方法、7大参数、4种拒绝策略
创建对象,仅仅是在 JVM 的堆里分配一块内存而已;而创建一个线程,却需要调用操作系统内核的 API,然后操作系统要为线程分配一系列的资源,这个成本就很高了。所以线程是一个重量级的对象,应该避免频繁创建和销毁,一般就是采用线程池来避免频繁的创建和销毁线程。
**连接池技术核心:**通过减少对于连接创建、关闭来提升性能。用于用户后续使用,好处是后续使用不用在创建连接以及线程,因为这些都需要相关很多文件、连接资源、操作系统内核资源支持来完成构建,会消耗大量资源,并且创建、关闭会消耗应用程序大量性能。
线程池的原理——就是线程池执行的讲解
线程池的原理有点类似于操作系统中的缓冲区的概念,它的流程如下:先启动若干数量的线程,并让这些线程都处于睡眠状态,当客户端有一个新请求时,就会唤醒线程池中的某一个睡眠线程,让它来处理客户端的这个请求,当处理完这个请求后,线程又处于睡眠状态。在高峰期每秒的客户端请求并发数可能是几千几万次,如果为每个客户端请求创建一个新线程的话,那耗费的CPU时间和内存将是惊人的,如果采用一个拥有一定数量的线程池,那将会节约大量的系统资源,使得更多的CPU时间和内存用来处理实际的商业应用,而不是频繁的线程创建与销毁。
数据库连接池的原理
一个数据库连接对象均对应一个物理数据库连接,每次操作都打开一个物理连接,使用完都关闭连接,这样造成系统的性能低下。 数据库连接池的解决方案是在应用程序启动时建立足够的数据库连接,并讲这些连接组成一个连接池,由应用程序动态地对池中的连接进行申请、使用和释放。对于多于连接池中连接数的并发请求,应该在请求队列中排队等待。并且应用程序可以根据池中连接的使用率,动态增加或减少池中的连接数。
连接池本质上是构建一个容器,容器来存储创建好的线程、http连接、数据库连接、netty连接等。
首先初始化连接池,根据设置相应参数,连接池大小、核心线程数、核心连接数等参数,初始化创建数据库、http、netty连接以及jdk线程。
连接池使用,前边初始化好的连接池、线程池,直接从连接池、线程中取出资源即可进行使用,使用完后要记得交还连接池、线程池,通过池容器来对资源进行管理。
对于连接池维护,连接池、线程池来维护连接、线程状态,不可用连接、线程进行销毁,正在使用连接、线程进行状态标注,连接、线程不够后并且少于设置最大连接、线程数,要进行新连接、线程创建。
1.3.1 线程池的优点
Executor, ExecutorService 和 Executors 间的区别与联系
三者均是 Executor 框架中的一部分。
- ExecutorService 接口继承了 Executor 接口,是 Executor 的子接口
- Executors 类是工具类,提供工厂方法用来创建不同类型的线程池。如:
newSingleThreadExecutor()
1.3.2 四大方法创建线程池
JUC包里提供了一个线程池的静态工厂类
Executors
,但《阿里巴巴 Java 开发手册》中提到,禁止使用这些方法来创建线程池,而应该手动new ThreadPoolExecutor
来创建线程池。
最重要的原因是:Executors 提供的很多方法默认使用的都是无界的 LinkedBlockingQueue,高负载情境下,无界队列很容易导致 OOM,而 OOM 会导致所有请求都无法处理,这是致命问题。
FixedThreadPool
被称为可重用固定线程数的线程池。corePoolSize
和maximumPoolSize
都被设置为 nThreads,这个 nThreads 参数是我们使用的时候自己传递的。SingleThreadExecutor
是只有一个线程的线程池CachedThreadPool
是一个会根据需要创建新线程的线程池。允许创建的线程数量为Integer.MAX_VALUE
,可能会创建大量线程,从而导致 OOMScheduledThreadPool
,具有时间调度性的线程池
//1.利用Executors工具类直接创建线程池,其实应该有四种种
ExecutorService pool = Executors.newSingleThreadExecutor();
ExecutorService pool1 = Executors.newCachedThreadPool();
ExecutorService pool2 = Executors.newFixedThreadPool(5);
ExecutorService pool3 = Executors.newScheduledThreadPool(4);
//底层源码
public static ExecutorService newSingleThreadExecutor() {
return new Executors.FinalizableDelegatedExecutorService(
new ThreadPoolExecutor(1, 1, 0L,
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue()));//阻塞队列无界
}
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads, 0L,
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue());//阻塞队列无界
}
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, 2147483647, 60L, //最大线程数Integer.MAX_VALUES
TimeUnit.SECONDS,
new SynchronousQueue());
}
public ScheduledThreadPoolExecutor(int corePoolSize) {
super(corePoolSize, 2147483647, 10L, //最大线程数Integer.MAX_VALUES
TimeUnit.MILLISECONDS,
new ScheduledThreadPoolExecutor.DelayedWorkQueue());
}
1.3.3 七大参数创建线程池
ThreadPoolExecutor
,有 7 个参数:
-
keepAliveTime & unit:如果一个线程空闲了 keepAliveTime & unit 这么久,而且线程池的线程数大于 corePoolSize ,那么这个空闲的线程就要被回收了。
注意:corePoolSize首先满,满完之后是阻塞队列满,阻塞队列也满了才会maximubPoolSize
1.3.4 四大拒绝策略:
当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略。
- AbortPolicy - 丢弃任务,并抛出拒绝执行 RejectedExecutionException 异常信息。线程池默认的拒绝策略。会打断当前的执行流程
- DiscardPolicy - 直接丢弃,其他啥都没有
- DiscardOldestPolicy - 丢弃阻塞队列 workQueue 中最老的一个任务,并再次尝试提交当前任务
- CallerRunsPolicy - 哪里来的到哪里去,比如回到 main
public static void main(String[] args) {
//Junit单元测试不支持多线程,难怪怎么都不输出
ExecutorService threadPool = new ThreadPoolExecutor(
2,
5,
3, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(3),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.DiscardPolicy());
try {
for (int i = 1; i <=9 ; i++) {
threadPool.execute(()->{
System.out.println(Thread.currentThread().getName()+" OK");
});
}
} catch (Exception e) {
e.printStackTrace();
} finally {
threadPool.shutdown();
}
}
1.3.5 自定义拒绝策略
通过实现RejectedExecutionHandler接口,自定义一个拒绝策略类,重写它的rejectedExecution()方法:
public class CustomRejectionHandler implements RejectedExecutionHandler {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
System.out.println(r.toString() + "被拒绝了,执行入库操作,之后手动补偿");
}
}
1.3.5 线程池调优
如何设置最大线程数
-
CPU 密集型计算:大部分场景下都是纯 CPU 计算
- 线程的数量 = CPU 核数 ,多线程本质上是提升多核 CPU 的利用率
-
I/O 密集型的计算场景: CPU 计算和 I/O 操作交叉执行(网络读取,文件读取)
- 最佳线程数 =(I/O 耗时 / CPU 耗时)* CPU核数
2 锁
2.1 各种锁
具体参考文章:java中的各种锁详细介绍
可重入锁:
==可重入就是说某个线程已经获得某个锁,可以再次获取锁而不会出现死锁。==是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁,ReentrantLock和synchronized都是可重入锁。
乐观锁
乐观锁:不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。乐观锁适用于多读的应用类型,这样可以提高吞吐量,乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。
悲观锁
悲观锁:对于同一个数据的并发操作,认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
**独占锁:**一次只能被一个线程占用
**共享锁:**一次可以被多个线程占用
**自旋锁:**不放弃CPU时间片,用while循环不断的尝试获取锁,适合等一下下的情境
**非自旋锁:**执行上下文切换保存现场
锁有:乐观锁,悲观锁,自旋锁,读写锁。
锁有四种级别,按照量级从轻到重分为:无锁、偏向锁、轻量级锁、重量级锁。
每个对象一开始都是无锁的,随着线程间争夺锁,越激烈,锁的级别越高,并且锁只能升级不能降级。
2.2 JUC.Locks 包下的锁
JUC.Locks包下有三个接口:Lock(可重入锁)、Condition(监听器)、ReadWriteLock(读写锁)
2.2.1 实现类:可重入锁、读锁、写锁
ReentrantLock, ReentrantReadWriteLock.ReadLock, ReentrantReadWriteLock.WriteLock
ReentrantLock是Lock接口最具代表的一个实现类,有着和synchronized锁相似的功能,但是区别于synchronized是,它对锁有更强的可操控性,能够显式的获取锁,显式的释放锁
public ReentrantLock() {
this.sync = new ReentrantLock.NonfairSync();//非公平锁,可插队
}
public ReentrantLock(boolean fair) {//公平锁
this.sync = (ReentrantLock.Sync)(fair ? new ReentrantLock.FairSync() : new ReentrantLock.NonfairSync());
}
上锁的操作
// 传统的synchronized 方法
public synchronized void sale(){
if(number>0){
System.out.println(Thread.currentThread().getName()+
"卖出了第"+(number--)+"张票");
}
}
// lock锁
public void saleLock(){
Lock lock = new ReentrantLock();
lock.lock();
try {
if(number>0){
System.out.println(Thread.currentThread().getName()+
"卖出了第"+(number--)+"张票");
}
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
Lock实现生产者和消费者问题
// 生产者
public void increase() throws InterruptedException {
lock.lock();
try {
while(number!=0){//用while循环防止虚假唤醒
condition.await();
}
number++;
System.out.println(Thread.currentThread().getName()+"生产");
condition.signalAll();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
// 消费者
public void decrease() throws InterruptedException {
lock.lock();
try {
while(number==0){
condition.await();
}
number--;
System.out.println(Thread.currentThread().getName()+"消费");
condition.signalAll();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
2.3 Condition 监视器接口
Condition是一个监视器接口,在JUC.locks包下。是在JDK1.5出现用来替代传统Object()的wait()和notify()来实现线程中通信协作。相比传统的Object()的wait()和notify(),Condition下的await()和signal()
可以实现精准唤醒
class Test{
private int number = 1;
private Lock lock = new ReentrantLock();
private Condition condition1 = lock.newCondition();
private Condition condition2 = lock.newCondition();
public void printA() throws InterruptedException {
lock.lock();
try {
while(number!=1){
condition1.await();//使condition1代表对象A
}
System.out.println(Thread.currentThread().getName()+"AAA");
number = 2;
condition2.signal();//精准唤醒condition2的对象B
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
2.4 ReadWriteLock 读写锁
接口实现类:ReentrantReadWriteLock
由于可重入锁ReentrantLock实现了标准的互斥锁,在一个线程获取了这个锁,其他线程想获取相同的锁时得保持等待状态,等待当前线程释放锁。一次最多只能有一个线程持有锁,这种情况可以很好的避免了写-写、读-写的线程安全问题,但是读-读的可用性重叠也同时被避开了,但是在大多数情况下,我们数据允许多个人同时访问时,此时ReentrantLock锁就不太适合,此时需要引入读写锁。
读可以被多线程同时读,写的时候只能有一个线程去写,锁的粒度更小,在原来lock的基础之上又细分了
readlock()和writelock()
public static void main(String[] args) {
MyCacheLock myCacheLock = new MyCacheLock();
for (int i = 0; i <5; i++) {
final int temp = i;
new Thread(()->{
myCacheLock.set(temp+"",temp);
},String.valueOf(i)).start();
}
for (int i = 0; i <5; i++) {
final int temp = i;
new Thread(()->{
myCacheLock.get(temp+"");
},String.valueOf(i)).start();
}
}
class MyCacheLock{
private Map<String,Integer> map = new HashMap<>();
ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
public void get(String key){
//锁的粒度更小,在原来lock的基础之上又细分了readlock()和writelock()
readWriteLock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName()+"读取"+key);
map.get(key);
System.out.println(Thread.currentThread().getName()+"读取ok");
} catch (Exception e) {
e.printStackTrace();
} finally {
readWriteLock.readLock().unlock();
}
}
public void set(String key,Integer value){
readWriteLock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName()+"写入"+key);
map.put(key,value);
System.out.println(Thread.currentThread().getName()+"写入ok");
} catch (Exception e) {
e.printStackTrace();
} finally {
readWriteLock.writeLock().unlock();
}
}
}
备注:CAS算法,全称 Compare And Swap(比较与交换)
比较当前工作内存中的值和主内存中的值,如果这个值是期望的,那么则执行操作!如果不是就 一直循环!
是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。
JUC中的原子类就是通过CAS来实现了乐观锁
CAS虽然很高效,但是它也存在三大问题,:
1、ABA问题。
CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
2 、循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
3 、 只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。
3 JMM、volatile、ThreadLocal、synchronized
3.1 JMM—Java内存模型
3.1.1 讲一下你对JMM的理解。
JMM就是Java内存模型(java memory model)。规定所有的变量都存储在主内存中,包括实例变量,静态变量,但是不包括局部变量和方法参数。每个线程都有自己的工作内存,线程的工作内存保存了该线程用到的变量和主内存的副本拷贝,线程对变量的操作都在工作内存中进行。线程不能直接读写主内存中的变量。
不同的线程之间也无法访问对方工作内存中的变量。线程之间变量值的传递均需要通过主内存来完成。
要解决这个问题,就需要把变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使⽤它都到主存中进行读取
3.1.2 JMM定义了什么
这个简单,整个Java内存模型实际上是围绕着三个特征建立起来的。分别是:原子性,可见性,有序性。
1、原子性
含义
一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作。
如何保证原子性
- 通过 synchronized 关键字定义同步代码块或者同步方法保障原子性。
- 通过 Lock 接口保障原子性。
- 通过 Atomic 类型保障原子性。
2、可见性
含义
当一个线程修改了共享变量的值,其他线程能够看到修改的值。
Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方法来实现可见性的。
volatile 的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新,因此我们可以说 volatile 保证了多线程操作时变量的可见性。
如何保证可见性
- volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
- 同时通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
3、有序性
volitle实现指令重排的原理
指令重排序
一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的,比如下面的代码,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。
含义
即程序执行的顺序按照代码的先后顺序执行。JVM 存在指令重排,所以存在有序性问题。
如何保证有序性
- volatile关键字禁止指令重排
3.1.3 JMM八种内存交互操作
3.2 volatile关键字
**以volatile关键字为切入点,考察应聘者对Java并发的了解程度,往往会问到底,**Java内存模型(JMM)和Java并发编程的一些特点都会被牵扯出来,再深入的话还会考察JVM底层实现以及操作系统的相关知识。
**1、Java并发这块掌握的怎么样?来谈谈你对volatile关键字的理解吧。**volatile的作用和底层原理
被volatile修饰的共享变量,就会具有以下两个特性:
保证了不同线程对该变量操作的内存可见性。当对volatile变量执行写操作后,JMM会把工作内存中的最新变量值强制刷新到主内存,写操作会导致其他线程中的缓存无效
**禁止指令重排序。**JVM会在volatile读写前后均加上内存屏障,在一定程度上保证有序性
2、详细的说一下究竟什么是内存可见性,什么又是重排序?
指令重排序
一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的,比如下面的代码,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。
可见性当一个线程修改了共享变量的值,其他线程能够看到修改的值。
**3、说说并发编程的三大特性?**原子性、可见性、有序性
4、JMM Java内存模型?
5、volatile的原理和实现机制
生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障,内存屏障会提供3个功能:
1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
2)它会强制将对缓存的修改操作立即写入主存;
3)如果是写操作,它会导致其他CPU中对应的缓存行无效。
6、synchronized和volitle的区别,synchronize如何实现线程安全?
为什么volatile不能保证线程安全?
可见性不能保证操作的原子性,前面说过了count++不是原子性操作,会当做三步,先读取count的值,然后+1,最后赋值回去count变量。需要保证线程安全的话,需要使用synchronized关键字或者lock锁,给count++这段代码上锁:
3.2.1 volatile和synchronized区别
volatile轻量级,只能修饰变量。synchronized重量级,还可修饰方法
volatile只能保证数据的可见性,不能用来同步,因为多个线程并发访问volatile修饰的变量不会阻塞,而synchronized可能会造成线程的阻塞.
volatile仅能实现变量的修改可见性,但不具备原子特性,synchronized不仅保证可见性,而且还保证原子性,因为,只有获得了锁的线程才能进入临界区,从而保证临界区中的所有语句都全部执行。多个线程争抢synchronized锁对象时,会出现阻塞。
volatile本质是在告诉jvm当前变量在寄存器中的值是不确定的,需要从主存中读取,synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住.
3.2.2 volatile禁止指令重排序的原理是什么
首先要讲一下内存屏障,内存屏障可以分为以下几类:
- LoadLoad 屏障:对于这样的语句Load1,LoadLoad,Load2。在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore屏障:对于这样的语句Store1, StoreStore, Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
- LoadStore 屏障:对于这样的语句Load1, LoadStore,Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreLoad 屏障:对于这样的语句Store1, StoreLoad,Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
在每个volatile读操作后插入LoadLoad屏障,在读操作后插入LoadStore屏障。
在每个volatile写操作的前面插入一个StoreStore屏障,后面插入一个SotreLoad屏障。
3.4 ThreadLocal
使用ThreadLocal维护变量时,其为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立的改变自己的副本,而不会影响其他线程对应的副本。
3.4.1 实现原理
ThreadLocal的实现原理就是ThreadLocal里面有一个静态的ThreadLocalMap,然后当set()的时候,会把当前实例化对象的ThreadLocal作为key值,然后把set()里面的参数当成value放到这个ThreadLocalMap里面
而ThreadLocalMap内部维护了一个类似HashMap的entry结构
static class ThreadLocalMap {
private static final int INITIAL_CAPACITY = 16;
private ThreadLocal.ThreadLocalMap.Entry[] table;//类似与hashMap中的entry结构
private int size = 0;
private int threshold;
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocal.ThreadLocalMap(this, firstValue);
}
3.4.2 内存泄漏
Entry是继承WeakReference类,key是弱引用,value是强引用。
- 如果Entry对象的Key每个都强引用到ThreadLocal对象的话,那么这个ThreadLocal对象就会因为和Entry对象存在强引用关联而无法被GC回收,造成内存泄漏,除非线程结束后,线程被回收了,ThreadLocalMap才会跟着回收。
- ThreadLocalMap的Entry对ThreadLocal对象是弱引用,GC回收后,会产生一些key为null的value无法被访问,也无法被回收,最终导致内存泄漏。预防措施是调用ThreadLocal的remove()方法,清除掉ThreadLocalMap里面key为null的value。
3.5 synchronized
3.5.1 synchronize
有哪些用法?可以修饰类吗?
从语法上讲,Synchronized可以把任何一个非null对象作为"锁",在HotSpot JVM实现中,锁有个专门的名字:对象监视器(Object Monitor)。
Synchronized总共有三种用法:
- 当synchronized作用在实例方法时,监视器锁(monitor)便是对象实例(this);
- 当synchronized作用在静态方法时,监视器锁(monitor)便是对象的Class实例,因为Class数据存在于永久代,因此静态方法锁相当于该类的一个全局锁;
- 当synchronized作用在某一个对象实例时,监视器锁(monitor)便是括号括起来的对象实例;
-
修饰一个代码块,被修饰的代码块称为同步语句块,作用的对象是调用这个代码块的对象;
-
修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
public synchronized void method(){};
- 虽然可以使用synchronized来定义方法,但synchronized并不属于方法定义的一部分,因此,synchronized关键字不能被继承。如果在父类中的某个方法使用了synchronized关键字,而在子类中覆盖了这个方法,在子类中的这个方法默认情况下并不是同步的,而必须显式地在子类的这个方法中加上synchronized关键字才可以。
- 在定义接口方法时不能使用synchronized关键字。
- 构造方法不能使用synchronized关键字,但可以使用synchronized代码块来进行同步。
- 修饰一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象;
//静态方法是属于类的而不属于对象的。同样的,synchronized修饰的静态方法锁定的是这个类的所有对象
public synchronized static void method() {
}
- 修饰一个类,其作用的范围是synchronized后面括号括起来的部分,作用主的对象是这个类的所有对象。
class ClassName {
public void method() {
synchronized(ClassName.class) {
// todo
}
}
}
3.5.2 synchronized的底层原理
在JVM中,对象在内存中分为三块区域:对象头,实例数据和对齐填充。
Synchronized在JVM里的实现都是 基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。
Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向),Synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时notify/notifyAll/wait等方法会使用到Monitor锁对象,所以必须在同步代码块中使用。
1、同步代码块原理
看同步代码块的底层实现原理,使用javap -v xxx.class
命令进行反编译
//同步代码块
package com.paddx.test.concurrent;
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("Method 1 start");
}
}
}
反编译结果
这是使用同步代码块被标志的地方,就是刚刚提到的对象头,它会关联一个monitor对象,也就是括号括起来的对象。
1、monitorenter,如果当前monitor的进入数为0时,线程就会进入monitor,并且把进入数+1,那么该线程就是monitor的拥有者(owner)。
2、如果该线程已经是monitor的拥有者,又重新进入,就会把进入数再次+1。也就是可重入的。
3、monitorexit,执行monitorexit的线程必须是monitor的拥有者,指令执行后,monitor的进入数减1,如果减1后进入数为0,则该线程会退出monitor。其他被阻塞的线程就可以尝试去获取monitor的所有权。
monitorexit指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁;所以当一个线程访问同步代码块时,首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁,
总的来说,synchronized的底层原理是通过monitor对象来完成的。
2、同步方法原理
package com.paddx.test.concurrent;
public class SynchronizedMethod {
public synchronized void method() {
System.out.println("Hello World!");
}
}
多了一个标志位ACC_SYNCHRONIZED,作用就是一旦执行到这个方法时,就会先判断是否有标志位
- 如果有这个标志位,就会先尝试获取monitor,获取成功才能执行方法,方法执行完成后再释放monitor。
- 在方法执行期间,其他线程都无法获取同一个monitor。
- 归根结底还是对monitor对象的争夺,只是同步方法是一种隐式的方式来实现。
monitor,内置在每一个对象中,任何一个对象都有一个monitor与之关联,synchronized在JVM里的实现就是基于进入和退出monitor来实现的,底层则是通过成对的MonitorEnter和MonitorExit指令来实现,因此每一个Java对象都有成为Monitor的潜质。所以我们可以理解monitor是一个同步工具。
3.5.3 synchronized锁的优化
- JDK1.5之前,synchronized是属于重量级锁,重量级需要依赖于底层操作系统的Mutex Lock实现,然后操作系统需要切换用户态和内核态,这种切换的消耗非常大,所以性能相对来说并不好。
- 在JDK1.6后开始对synchronized进行优化,增加了自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略。锁的等级从无锁,偏向锁,轻量级锁,重量级锁逐步升级,并且是单向的,不会出现锁的降级。
- 锁主要存在四种状态,依次是**:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态**,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁。但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
1、自旋锁
线程的阻塞和唤醒需要CPU从用户态转为核心态,所谓自旋锁,就是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。
在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整。
2、自适应性自旋锁
由于线程是一直在循环检测锁的状态,就会占用cpu资源,如果线程占用锁的时间比较长,那么自旋的次数就会变多,占用cpu时间变长导致性能变差,但是设置的自旋次数是多少比较合适呢?
自适应性自旋锁的意思是,自旋的次数不是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
3、锁消除
在JVM编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁。比如StringBuffer的append()方法,就是使用synchronized进行加锁的。如果在实例方法中StringBuffer作为局部变量使用append()方法,StringBuffer是不可能存在共享资源竞争的,因此会自动将其锁消除.
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
public String add(String s1, String s2) {
//sb属于不可能共享的资源,JVM会自动消除内部的锁
StringBuffer sb = new StringBuffer();
sb.append(s1).append(s2);
return sb.toString();
}
4、锁粗化
如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗话的概念。意思是将多个连续加锁、解锁的操作连接在一起,扩展成为一个范围更大的锁。例如:
vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。
5、偏向锁
偏向锁是JDK1.6引入的一个重要的概念,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得。也就是说在很多时候我们是假设有多线程的场景,但是实际上却是单线程的。所以偏向锁是在单线程执行代码块时使用的机制。
原理是什么呢,我们前面提到锁的争夺实际上是Monitor对象的争夺,还有每个对象都有一个对象头,对象头是由Mark Word和Klass pointer 组成的。一旦有线程持有了这个锁对象,标志位修改为1,就进入偏向模式,同时会把这个线程的ID记录在对象的Mark Word中,当同一个线程再次进入时,就不再进行同步操作,这样就省去了大量的锁申请的操作,从而提高了性能。
一旦有多个线程开始竞争锁的话呢?那么偏向锁并不会一下子升级为重量级锁,而是先升级为轻量级锁。
6、轻量级锁
如果获取偏向锁失败,也就是有多个线程竞争锁的话,就会升级为JDK1.6引入的轻量级锁,Mark Word 的结构也变为轻量级锁的结构。
执行同步代码块之前,JVM会在线程的栈帧中创建一个锁记录(Lock Record),并将Mark Word拷贝复制到锁记录中。然后尝试通过CAS操作将Mark Word中的锁记录的指针,指向创建的Lock Record。如果成功表示获取锁状态成功,如果失败,则进入自旋获取锁状态。
如果自旋获取锁也失败了,则升级为重量级锁,也就是把线程阻塞起来,等待唤醒。
7、重量级锁
重量级锁就是一个悲观锁了,但是其实不是最坏的锁,因为升级到重量级锁,是因为线程占用锁的时间长(自旋获取失败),锁竞争激烈的场景,在这种情况下,让线程进入阻塞状态,进入阻塞队列,能减少cpu消耗。所以说在不同的场景使用最佳的解决方案才是最好的技术。synchronized在不同的场景会自动选择不同的锁,这样一个升级锁的策略就体现出了这点
偏向锁:适用于单线程执行。
轻量级锁:适用于锁竞争较不激烈的情况。
重量级锁:适用于锁竞争激烈的情况。
3.5.4 Lock锁与synchronized区别
- synchronized是Java语法的一个关键字,加锁的过程是在JVM底层进行。Lock是一个类,是JDK应用层面的
- synchronized在加锁和解锁操作上都是自动完成的,Lock锁需要我们手动加锁和解锁。如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;
- Lock锁有丰富的API能知道线程是否获取锁成功,而synchronized不能。
- synchronized能修饰方法和代码块,Lock锁只能锁住代码块。
- Lock锁有丰富的API,可根据不同的场景,在使用上更加灵活。
- synchronized是非公平锁,而Lock锁既有非公平锁也有公平锁,可以由开发者通过参数控制。
- Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;
4 JUC
4.1 JUC包结构
java.util.concurrent
工具包以简化并发编程。JDK
的java.util.concurrent
包,包含并发工具类和两个子包atomic
和locks
。
- Concurrent:
concurrent
包下包含一些并发工具类,如Executors、Semaphore、CountDownLatch、CyclicBarrier、BlockingQueue
等- Atomic:原子数据的构建。
- Locks:基本的锁的实现,最重要的
AQS
框架和LockSupport
4.2 常用的辅助类
4.2.1 CountDownLatch:减一计数器
一个
CountDownLatch
初始化计算的一个作为一个简单的开/关锁,门:所有线程调用await
在门口等候直到它被一个线程调用countDown()
计数归零。一个CountDownLatch
初始化n可以用来使一个线程等待直到n个线程完成一些动作。每次有线程调用 countDown() 数量-1,假设计数器变为0,countDownLatch.await() 就会被唤醒,继续执行await() 后面的程序
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(5);
for (int i = 0; i <=5; i++) {
new Thread(()->{
System.out.println(Thread.currentThread().getName()+"线程");
countDownLatch.countDown();//计数器-1操作
},String.valueOf(i)).start();
}
countDownLatch.await();//计数器减到0之后才会继续往下执行
System.out.println("减一到0");
}
4.2.2 CyclicBarrier 加法计数器
CyclicBarrier数的是调用了CyclicBarrier.await()进入等待的线程数, 当线程数达到了CyclicBarrier初始时规定的数目时,所有进入等待状态的线程被唤醒并继续。可看成是个障碍, 所有的线程必须到齐后才能一起通过这个障碍。 释放等待线程后可以重用,所以称它为循环 的 barrier。
public static void main(String[] args) {
CyclicBarrier cyclicBarrier = new CyclicBarrier(5,()->{
System.out.println("线程到齐完毕");
});
for (int i = 0; i <=5; i++) {
new Thread(()->{
System.out.println(Thread.currentThread().getName()+"线程");
try {
cyclicBarrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
},String.valueOf(i)).start();
}
}
4.2.3 Semaphore:信号量
[ˈseməfɔ:®]
在java中,使用了synchronized关键字和Lock锁实现了资源的并发访问控制,在同一时间只允许唯一了线程进入临界区访问资源(读锁除外),这样子控制的主要目的是为了解决多个线程并发使用同一资源造成的数据不一致的问题。在另外一种场景下,一个资源有多个副本可供同时使用,比如打印机房有多个打印机,Java提供了另外的并发访问控制–资源的多副本的并发限流访问控制——信号量Semaphore
Semaphore内部维护了一个计数器,其值为可以访问的共享资源的个数。一个线程要访问共享资源,先获得信号量,如果信号量的计数器值大于1,意味着有共享资源可以访问,则使其计数器值减去1 acquire(),再访问共享资源。如果计数器值为0,线程进入休眠。当某个线程使用完共享资源后,释放信号量 release(),并将信号量内部的计数器加1,之前进入休眠的线程将被唤醒并再次试图获得信号量。
-
semaphore.acquire() 获得,会将当前的信号量 - 1,假设如果已经满了,等待!
-
semaphore.release(); 释放,会将当前的信号量 + 1,然后唤醒等待的线程!
作用: 多个共享资源互斥的使用!并发限流,控制最大的线程数!
public static void main(String[] args) {
//设置并发可访问的线程数
Semaphore semaphore = new Semaphore(2);
for (int i = 0; i <4; i++) {
new Thread(()->{
try {
semaphore.acquire();
System.out.println(Thread.currentThread().getName()+"获取资源!");
TimeUnit.SECONDS.sleep(4);
System.out.println(Thread.currentThread().getName()+"释放资源");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release();
}
},String.valueOf(i)).start();
}
}
/**
输出:实现了线程的并发访问资源控制,排队使用
0获取资源!
1获取资源!
1释放资源
0释放资源
2获取资源!
3获取资源!
3释放资源
2释放资源
*/
4.3 原子类CAS
4.3.1 Java并发工具类中的 CAS 机制讲一讲?
CAS(Compare-And-Swap)是比较并交换
的意思,用于判断内存中某个值是否为预期值,如果是则更改为新的值,这个过程是原子
的。
它是一条 CPU 并发原语,原语由若干指令组成,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说 CAS 是一条 CPU 的原子指令,由操作系统硬件来保证。
Java无法直接访问底层操作系统,而是通过本地(native)方法来访问。不过尽管如此,JVM还是开了一个后门,JDK中有一个类Unsafe,它提供了硬件级别的原子操作。
要理解CAS,就必须从JUC中的Atomic原子类中学习
4.3.2 AtomicInteger
构造函数,对象地址偏移量
public class AtomicInteger extends Number implements Serializable {
private static final Unsafe U = Unsafe.getUnsafe();//获取底层的unsafe类
private static final long VALUE;
private volatile int value;
public AtomicInteger(int initialValue) {
this.value = initialValue;
}
static {
//VALUE 字段表示 "value" 内存位置,在 compareAndSwap 方法 ,第二个参数会用到.
//通过反射来获取vaue这个变量,然后再通过Unsafe来获取对象地址的偏移量
VALUE = U.objectFieldOffset(AtomicInteger.class, "value");
}
}
Unsaf从硬件偏移量中取出旧址
@CallerSensitive
public static Unsafe getUnsafe() {
Class var0 = Reflection.getCallerClass();
// Bootstrap 类加载器是C++的,正常返回null,否则就抛异常。
if (!VM.isSystemDomainLoader(var0.getClassLoader())) {
throw new SecurityException("Unsafe");
} else {
return theUnsafe;
}
}
//unsafe类
public long objectFieldOffset(Class<?> c, String name) {
if (c != null && name != null) {
return this.objectFieldOffset1(c, name);
} else {
throw new NullPointerException();
}
}
类中的其他方法,以getAndSet
为例,实现原子操作
//返回当前的值
public final int get() {
return value;
}
//原子更新为新值并返回旧值
public final int getAndSet(int newValue) {
return unsafe.getAndSetInt(this, valueOffset, newValue);
}
//如果输入的值等于预期值,则以原子方式更新为新值
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
//方法相当于原子性的 ++i
public final int getAndIncrement() {
//三个参数,1、当前的实例 2、value实例变量的偏移量 3、递增的值。
return unsafe.getAndAddInt(this, valueOffset, 1);
}
//方法相当于原子性的 --i
public final int getAndDecrement() {
//三个参数,1、当前的实例 2、value实例变量的偏移量 3、递减的值。
return unsafe.getAndAddInt(this, valueOffset, -1);
}
实现逻辑封装在 Unsafe 中 getAndAddInt
方法,继续往下看, Unsafe
源码解析
@HotSpotIntrinsicCandidate
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = this.getIntVolatile(o, offset);//获取对象内存地址偏移量上的数值v
//如果现在还是v,设置为 v + delta,否则返回false,继续循环再次重试.
} while(!this.weakCompareAndSetInt(o, offset, v, v + delta));//while循环进行CAS更新,如果更新失败,循环再次重试
return v;
}
//native硬件级别的原子操作
@HotSpotIntrinsicCandidate
public final boolean weakCompareAndSetInt(Object o, long offset, int expected, int x) {
return this.compareAndSetInt(o, offset, expected, x);
}
@HotSpotIntrinsicCandidate
public final native boolean compareAndSetInt(Object var1, long var2, int var4, int var5);
//它底层只是一台cPU指令来完成的,所以能确保原子操作
实现逻辑封装在 Unsafe 中 getAndSet
方法,继续往下看, Unsafe
源码解析
//AtomicInteger的getAndSet方法,底层调用unsafe中的方法 getAndSeInt
public final int getAndSet(int newValue) {
return U.getAndSetInt(this, VALUE, newValue);
}
@HotSpotIntrinsicCandidate
public final int getAndSetInt(Object o, long offset, int newValue) {
int v;
do {
v = this.getIntVolatile(o, offset);//通过对象和偏移值,获取旧值
} while(!this.weakCompareAndSetInt(o, offset, v, newValue));
return v;
}
4.3.3 CPU如何实现原子操作
- 最终调用了一条汇编指令:lock cmpxchg 指令,来实现底层 cas 的。也就是 cpu 中有一条 cmpxchg 指令。
- 但是这条指令不是原子的,也就是拿出来和比较是两个操作,中间有可能被别人打断。
CPU 处理器速度远远大于在主内存中的,为了解决速度差异,在他们之间架设了多级缓存,如 L1、L2、L3 级别的缓存,这些缓存离CPU越近就越快,将频繁操作的数据缓存到这里,加快访问速度。现在都是多核 CPU 处理器,每个 CPU 处理器内维护了一块字节的内存,每个内核内部维护着一块字节的缓存,当多线程并发读写时,就会出现缓存数据不一致的情况。
此时,处理器提供:
- 总线锁定
当一个处理器要操作共享变量时,在 BUS 总线上发出一个 Lock 信号,其他处理就无法操作这个共享变量了。
缺点很明显,总线锁定在阻塞其它处理器获取该共享变量的操作请求时,也可能会导致大量阻塞,从而增加系统的性能开销。
- 缓存锁定
后来的处理器都提供了缓存锁定机制,也就说当某个处理器对缓存中的共享变量进行了操作,其他处理器会有个嗅探机制,将其他处理器的该共享变量的缓存失效,待其他线程读取时会重新从主内存中读取最新的数据,现代的处理器基本都支持和使用的缓存锁定机制。
4.3.4 CAS不足
ABA问题
如果另一个线程修改V值假设原来是A,先修改成B,再修改回成A。当前线程的CAS操作无法分辨当前V值是否发生过变化。但实际上已经被修改过了。(并发业务场景下存在问题)
自旋开销问题
CAS 出现冲突后就会开始
自旋
操作,如果资源竞争非常激烈,自旋长时间不能成功就会给 CPU 带来非常大的开销。解决方案:可以考虑限制自旋的次数,避免过度消耗 CPU;另外还可以考虑延迟执行。
只能保证单个变量的原子性
当对一个共享变量执行操作时,可以使用 CAS 来保证原子性,但是如果要对多个共享变量进行操作时,CAS 是无法保证原子性的,比如需要将 i 和 j 同时加 1,这个时候可以使用 synchronized
进行加锁
有没有其他办法呢?有,将多个变量操作合成一个变量操作。从 JDK1.5 开始提供了AtomicReference
类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
i++;j++;
4.3.5 ABA 问题的解决
可以在数据上加上版本号即可,改了一次就新增一个版本号。
在 Java 中,可以使用 AtomicStampedReference
来解决这个问题
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicStampedReference;
public class ABASingle {
public static void main(String[] args) {
AtomicInteger atomicInt = new AtomicInteger(100);
atomicInt.compareAndSet(100, 101);
atomicInt.compareAndSet(101, 100);
System.out.println("new value = " + atomicInt.get());
boolean result1 = atomicInt.compareAndSet(100, 101);
System.out.println(result1); // result:true
AtomicInteger v1 = new AtomicInteger(100);
AtomicInteger v2 = new AtomicInteger(101);
AtomicStampedReference<AtomicInteger> stampedRef = new AtomicStampedReference<AtomicInteger>(
v1, 0);
int stamp = stampedRef.getStamp();
stampedRef.compareAndSet(v1, v2, stampedRef.getStamp(),
stampedRef.getStamp() + 1);
stampedRef.compareAndSet(v2, v1, stampedRef.getStamp(),
stampedRef.getStamp() + 1);
System.out.println("new value = " + stampedRef.getReference());
boolean result2 = stampedRef.compareAndSet(v1, v2, stamp, stamp + 1);
System.out.println(result2); // result:false
}
}
5 AQS
5.1 AQS是什么
谈到并发编程,不得不说AQS(AbstractQueuedSynchronizer)
。AQS即是抽象队列同步器,是用来构建Lock锁和同步组件的基础框架,很多我们熟知的锁和同步组件都是基于AQS构建,比如ReentrantLock、ReentrantReadWriteLock、CountDownLatch、Semaphore。
实际上AQS是一个抽象类,我们不妨先看一下源码:
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable {
//头结点
private transient volatile Node head;
//尾节点
private transient volatile Node tail;
//共享状态
private volatile int state;
//内部类,构建链表的Node节点
static final class Node {
volatile Node prev;
volatile Node next;
volatile Thread thread;
}
}
//AbstractQueuedSynchronizer的父类
public abstract class AbstractOwnableSynchronizer implements java.io.Serializable {
//占用锁的线程
private transient Thread exclusiveOwnerThread;
}
由源码可以看出AQS是有以下几个部分组成的:
5.1.1 State共享变量
AQS中里一个很重要的字段state,表示同步状态,是由volatile
修饰的,用于展示当前临界资源的获锁情况。通过getState(),setState(),compareAndSetState()三个方法进行维护。
关于state的几个要点:
- 使用volatile修饰,保证多线程间的可见性。
- getState()、setState()、compareAndSetState()使用final修饰,限制子类不能对其重写。
- compareAndSetState()采用乐观锁思想的CAS算法,保证原子性操作。
5.1.2 CLH队列
AQS里另一个重要的概念就是CLH队列,它是一个双向链表队列,其内部由head和tail分别记录头结点和尾结点,队列的元素类型是Node。
简单讲一下这个队列的作用,就是当一个线程获取同步状态(state)失败时,AQS会将此线程以及等待的状态等信息封装成Node加入到队列中,同时阻塞该线程,等待后续的被唤醒。
队列的元素就是一个个的Node节点,下面讲一下Node节点的组成:
5.1.3 exclusiveOwnerThread
AQS通过继承AbstractOwnableSynchronizer类,拥有的属性。表示独占模式下同步器的持有者。
//AbstractQueuedSynchronizer的父类
public abstract class AbstractOwnableSynchronizer implements java.io.Serializable {
//占用锁的线程
private transient Thread exclusiveOwnerThread;
}
5.2 AQS的实现原理
AQS有两种模式,分别是独占式和共享式。
5.2.1 独占式
同一时刻仅有一个线程持有同步状态,也就是其他线程只有在占有的线程释放后才能竞争,比如ReentrantLock。下面从源码切入,梳理独占式的实现思路。
首先看acquire()方法,这是AQS在独占模式下获取同步状态的方法。
先讲这个方法的总体思路:
- tryAcquire()尝试直接去获取资源,如果成功则直接返回。
- 如果失败则调用addWaiter()方法把当前线程包装成Node(状态为EXCLUSIVE,标记为独占模式)插入到CLH队列末尾。
- 然后acquireQueued()方法使线程阻塞在等待队列中获取资源,一直获取到资源后才返回,如果在整个等待过程中被中断过,则返回true,否则返回false。
- 线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。
我们展开来分析,看tryAcquire()方法,尝试获取资源,成功返回true,失败返回false。
接着看addWaiter()方法,这个方法的作用是把当前线程包装成Node添加到队列中。
接着我们看enq()方法,就是一个自旋的操作,把传进来的node添加到队列最后,如果队列没有初始化则进行初始化。
接着我们再把思路跳回去顶层的方法,看acquireQueued()方法。
最后是selfInterrupt()方法,自我中断。
过程记不住没关系,下面画张图来总结一下,其实很简单。
5.2.2 共享式
共享资源可以被多个线程同时占有,直到共享资源被占用完毕。比如ReadWriteLock和CountdownLatch。
首先还是看最顶层的acquireShared()方法。
这段代码很简单,首先调用tryAcquireShared()方法,tryAcquireShared返回是一个int数值,当返回值大于等于0的时候,说明获得成功获取锁,方法结束,否则返回负数,表示获取同步状态失败,执行doAcquireShared方法。
tryAcquireShared()方法是一个模板方法由子类去重写,意思是需要如何获取同步资源由实现类去定义,AQS只是一个框架。
那么就看如果获取资源失败,执行的doAcquireShared()方法。
这段逻辑基本上跟独占式的逻辑差不多,不同的地方在于入队的Node是标志为SHARED共享式的,获取同步资源的方式是tryAcquireShared()方法。
5.3 AQS的模板模式
模板模式在AQS中的应用可谓是一大精髓,在上文中有提到的tryAcquireShared()和tryAcquire()都是很重要的模板方法。一般使用AQS往往都是使用一个内部类继承AQS,然后重写相应的模板方法。
AQS已经把一些常用的,比如入队,出队,CAS操作等等构建了一个框架,使用者只需要实现获取资源,释放资源的,因为很多锁,还有同步器,其实就是获取资源和释放资源的方式有比较大的区别。
那么我们看一下模板方法有哪些。
5.3.1 tryAcquire()
tryAcquire()方法,独占式获取同步资源,返回true表示获取同步资源成功,false表示获取失败。
5.3.2 tryRelease()
tryRelease()方法,独占式使用,tryRelease()的返回值来判断该线程是否已经完成释放资源,子类来决定是否能成功释放锁。
5.3.3 tryAcquireShared()
tryAcquireShared()方法,共享式获取同步资源,返回大于等于0表示获取资源成功,返回小于0表示失败。
5.3.4 tryReleaseShared()
tryReleaseShared()方法,共享式尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
5.3.5 isHeldExclusively()
isHeldExclusively()方法,该线程是否正在独占资源。只有用到condition才需要去实现它。
6 ReentrantLock
FairSync是公平锁的实现,NonfairSync则是非公平锁的实现。通过构造器传入的boolean值进行判断。
6.1 上锁
ReentrantLock是通过lock()方法上锁,所以看lock()方法。
sync就是NonfairSync或者FairSync。
acquire()方法前面已经解析过了,主要看FairSync的tryAcquire()方法。
如果是非公平锁NonfairSync的tryAcquire(),我们继续分析。
关键的区别在尝试获取锁的时候,公平锁会判断是否需要排队再去更新同步状态,非公平锁是直接就更新同步,不判断是否需要排队。
从性能上来说,公平锁的性能是比非公平锁要差的,因为公平锁要遵守FIFO(先进先出)的原则,这就会增加了上下文切换与等待线程的状态变换时间。非公平锁的缺点也是很明显的,因为允许插队,这就会存在有线程饿死的情况。
6.2 解锁
解锁对应的方法就是unlock()。
关键在于tryRelease(),这就不需要分公平锁和非公平锁的情况,只需要考虑可重入的逻辑。