s1 基础知识
001 并发编程的优缺点
优点 | 充分利用多核cpu的计算能力 | 方便业务拆分,提升系统并发能力和性能,提升程序执行效率,提速 |
---|---|---|
缺点 | 内存泄漏 | 上下文切换 | 线程安全 | 死锁 |
002 并发编程三要素
三要素 | 安全问题的原因 | 具体原因 | 对策 |
---|---|---|---|
原子性 | 线程切换 | 一个或多个操作要么全部执行成功要么全部执行失败 | JDK Atomic开头的原子类、synchronized、LOCK |
可见性 | 缓存 | a线程对共享变量的修改,b线程能够立刻看到 | synchronized、volatile、LOCK |
有序性 | 编译优化 | 处理器可能会对指令进行重排序 | Happens-Before 规则 |
:::success
tip: java程序保证多线程运行安全
方法一:使用安全类,考虑atomic包下的类,比如 java.util.concurrent 下的类,使用原子类AtomicInteger
方法二:使用自动锁 synchronized
方法三:使用手动锁 Lock
方法四: 考虑atomic包下的类,保证操作的可⻅性
方法五: 涉及到对线程的控制,考虑CountDownLatch/Semaphore==
:::
003 并行并发区别
类别 | 并发 | 并行 | 串行 |
---|---|---|---|
定义 | 多个任务在同个 CPU 核上,按细分的时间片轮流(交替)执行,从逻辑看那些任务是同时执行 | 单位时间内,多个处理器或多核处理器同时处理多个任务,是真正意义上的“同时进行” | 有n个任务,由一个线程按顺序执行. 不存在线程不安全情况,也就不存在临界区的问题 |
举例 | 两个队列,一个售票口 | 两个队列,2个售票口 | 1个队列,一个售票口 |
004 线程进程区别
类别 | 进程 | 线程 |
---|---|---|
本质 | 操作系统资源分配的基本单位 | 处理器任务调度和执行的基本单位 |
资源开销 | 有独立代码和数据空间,进程间切换开销大 | ,同一类线程共享代码和数据空间,每个线程有自己独立的运行栈和pc,开销小 |
包含关系 | 一个进程可以包含1-n个线程 | 线程是进程的一部分,轻量级的进程 |
内存分配 | 进程之间的地址空间和资源是相互独立 | 同一进程的线程共享本进程的地址空间和资源 |
影响关系 | 进程崩溃,保护模式下对其他无影响,健壮 | 线程崩溃,整个进程挂 |
执行过程 | 程序运行的入口、顺序执行序列和程序出口 | 线程不能独立执行,必须依存在应用程序中 |
005 上下文切换
任务从保存到再加载的过程就是一次上下文切换
1.CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。
2.当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态
:::success
上下文切换可以认为是内核(操作系统的核心)在 CPU 上对于进程(包括线程)进行切换,上下
文切换过程中的信息是保存在进程控制块(PCB, process control block)中的。PCB 还经常被称
作“切换桢”(switchframe)。信息会一直保存到 CPU 的内存中,直到他们被再次使用。
:::
006 守护线程 | 用户线程
用户 (User) 线程 :运行在前台,执行具体的任务,如程序的主线程 | 连网的子线程等都是用户线程
守护 (Daemon) 线程 :运行在后台,为其他前台线程服务,一旦所有用户线程都结束运行,守护线程会随 JVM 一起结束工作. 比如垃圾回收线程,
:::success
tip:
1. setDaemon(true) 必须在 start() 方法前执行,否则会抛出IllegalThreadStateException 异常
2. 在守护线程中产生的新线程也是守护线程
3. 不是所有的任务都可以分配给守护线程来执行,比如读写操作或者计算逻辑
4. 守护 (Daemon) 线程中不能依靠 finally 块的内容来确保执行关闭或清理资源的逻辑。因为daemon线程的finally语句块可能无法被执行.
5.优先级别:守护线程的优先级比较低,用于为系统中的其它对象和线程提供服务。
:::
007 死锁
百度百科 :死锁是指两个或两个以上的进程(线程)在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程(线程)称为死锁进程(线程)。
如图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
死锁的四要素:
1. 互斥条件:线程(进程)对于所分配到的资源有排它性,即一个资源只能被一个线程(进程)占用,直到被该线程(进程)释放
2. 请求与保持条件:一个线程(进程)因请求被占用资源而发生阻塞时,对已获得的资源保持不放。
3. 不剥夺条件:线程(进程)已获得的资源在末用完之前不能被其他线程强行剥夺,只有自己用完后才释放资源。
4. 循环等待条件:当发生死锁时,所等待的线程(进程)必定会形成一个环路(类似于死循环),造成永久阻塞
避免线程死锁:(破环四要素任意一个即可)
破坏互斥条件
这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)
破坏请求与保持条件
一次性申请所有的资源
破坏不剥夺条件
占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源
破坏循环等待条件
靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件
- 尽量使用 tryLock(long timeout, TimeUnit unit)的方法(ReentrantLock、ReentrantReadWriteLock),设置超时时间,超时可以退出防止死锁。
- 尽量使用 Java. util. concurrent 并发类代替自己手写锁。
- 尽量降低锁的使用粒度,尽量不要几个功能用同一把锁。
- 尽量减少同步的代码块
1. 固定加锁的顺序,⽐如我们可以使⽤Hash值的⼤⼩来确定加锁的先后
2. 尽可能缩减加锁的范围,等到操作共享变量的时候才加锁。
3. 使⽤可释放的定时锁(⼀段时间申请不到锁的权限了,直接释放掉
008 创建线程的四种方式(池化资源)
创建线程有四种方式:
- 继承 Thread 类; 重写run方法,调用线程对象的start()方法开启线程
//Thread类本质是实现Runnablre接口的实例
public class Test extends Thread{
public void run(){
System.out.print("Test.run()");
}
}
Test test = new Test();
test.start();//start方法是一个native方法,启动新线程,执行run()方法
- 实现 Runnable 接口; 重写run方法,调用线程对象的start()方法开启线程
public class MyThread extends OtherClass implements Runnable {
public void run() {
System.out.println("MyThread.run()");
}
}
//启动 MyThread,需要首先实例化一个 Thread,并传入自己的 MyThread 实例:
MyThread myThread = new MyThread();
Thread thread = new Thread(myThread);
thread.start();
//事实上,当传入一个 Runnable target 参数给 Thread 后,Thread 的 run()方法就会调用
target.run()
public void run() {
if (target != null) {
target.run();
}
}
-
实现 Callable 接口; 1. 创建实现Callable接口的类myCallable2. 以myCallable为参创建FutureTask对象
3. 将FutureTask作为参数创建Thread对象 4. 调用线程对象的start()方法
(对比runable,有返回值,且允许抛出异常,可获取异常信息)(5以后才有)
//创建一个线程池
ExecutorService pool = Executors.newFixedThreadPool(taskSize);
// 创建多个有返回值的任务
List<Future> list = new ArrayList<Future>();
for (int i = 0; i < taskSize; i++) {
Callable c = new MyCallable(i + " ");
// 执行任务并获取 Future 对象
Future f = pool.submit(c);
list.add(f);
}
// 关闭线程池
pool.shutdown();
// 获取所有并发任务的运行结果
for (Future f : list) {
// 从 Future 对象上获取任务的返回值,并输出到控制台
System.out.println("res:" + f.get().toString());
}
- 使用 Executors 工具类创建线程池
Executors提供了一系列工厂方法用于创建线程池,返回的线程池都实现了ExecutorService接口。
主要有newFixedThreadPool,newCachedThreadPool,newSingleThreadExecutor,newScheduledThreadPool
// 创建线程池
ExecutorService threadPool = Executors.newFixedThreadPool(10);
while(true) {
threadPool.execute(new Runnable() { // 提交多个线程任务,并执行
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " is running ..");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
}
}
线程池
池化技术相比大家已经屡见不鲜了,线程池、数据库连接池、Http 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高响应速度,提高对资源的利用率,线程复用。提高线程的可管理性.提供定时执行、定期执行、单线程、并发数控制等功能
(1)newSingleThreadExecutor:创建一个单线程的线程池。这个线程池只有一个线程在工作,也就是相当于单线程串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行。
(2)newFixedThreadPool:创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。如果希望在服务器上使用线程池,建议使用 newFixedThreadPool方法来创建线程池,这样能获得更好的性能。
(3) newCachedThreadPool:创建一个可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60 秒不执行任务)的线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务。此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说 JVM)能够创建的最大线程大小。
(4)newScheduledThreadPool:创建一个大小无限的线程池。此线程池支持定时以及周期性执行任务需求。
- RUNNING:这是最正常的状态,接受新的任务,处理等待队列中的任务。
- SHUTDOWN:不接受新的任务提交,但是会继续处理等待队列中的任务。
- STOP:不接受新的任务提交,不再处理等待队列中的任务,中断正在执行任务的线程。
- TIDYING:所有的任务都销毁了,workCount 为 0,线程池的状态在转换为TIDYING 状态时,会执行钩子方法 terminated()。
- TERMINATED:terminated()方法结束后,线程池的状态就会变成这个。
ThreadPoolExecutor创建线程池
ThreadPoolExecutor 3 个最重要的参数:
- corePoolSize :核心线程数,线程数定义了最小可以同时运行的线程数量。
- maximumPoolSize :线程池中允许存在的工作线程的最大数量
- workQueue:当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,任务就会被存放在队列中。
009 run() 和 start() 区别
run | start |
---|---|
线程体,执行线程的运行时代码 | 启动线程 |
可以重复调用 | 只能调一次 |
线程进入就绪状态,分配到时间片才运行 |
s2 线程的状态和基本操作
010 线程的生命周期及五种基本状态
阻塞的情况分三种:
(一). 等待阻塞:运行状态中的线程执行 wait()方法,JVM会把该线程放入等待队列(waitting queue)中,使本线程进入到等待阻塞状态;
(二). 同步阻塞:线程在获取 synchronized 同步锁失败(因为锁被其它线程所占用),,则JVM会把该线程放入锁池(lock pool)中,线程会进入同步阻塞状态;
(三). 其他阻塞: 通过调用线程的 sleep()或 join()或发出了 I/O 请求时,线程会进入到阻塞状态。当 sleep()状态超时、join()等待线程终止或者超时、或者 I/O处理完毕时,线程重新转入就绪状态。
011 Java 中用到的线程调度算法
分时调度模型是指让所有的线程轮流获得 cpu 的使用权,并且平均分配每个线程占用的 CPU 的时间片。
Java虚拟机采用抢占式调度模型,是指优先让可运行池中优先级高的线程占用CPU,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用CPU。处于运行状态的线程会一直运行,直至它不得不放弃 CPU。
012 线程调度器(Thread Scheduler)和时间分片(Time Slicing )
线程调度器是一个操作系统服务,它负责为 Runnable 状态的线程分配 CPU 时间。一旦我们创建一个线程并启动它,它的执行便依赖于线程调度器的实现。
时间分片是指将可用的 CPU 时间分配给可用的 Runnable 线程的过程。分配CPU 时间可以基于线程优先级或者线程等待的时间。
线程调度并不受 Java 虚拟机控制,所以由应用程序来控制它是更好的选择(不要让你的程序依赖于线程的优先级)
013 sleep() 和 wait() 有什么区别
类别 | sleep() | wait() |
---|---|---|
相同: | 都可以暂停线程的执行 | |
类 | Thread线程类的静态方法 | Object类的方法 |
锁 | 不放 | 释放锁 |
用途 | 暂停执行 | 线程间交互/通信 |
用法 | 到时间后自动苏醒 | notify() 或者 notifyAll() 方法唤醒 |
014 sleep()方法和 yield()方法
类别 | sleep() | yield() |
---|---|---|
相同点: | 都可以暂停当前线程的执行 | |
不同点 | 不考虑线程的优先级,因此会给低优先级的线程以运行的机会 | 只会给相同优先级或更高优先级的线程以运行的机会 |
转入阻塞(blocked)状态 | 转入就绪(ready)状态 | |
抛出 InterruptedException | 没有声明任何异常 | |
用法 | 具有更好的可移植性 | 不建议使用yield()方法来控制并发线程执行 |
015 Java 如何实现多线程之间的通讯和协作?
可以通过 中断 和 共享变量 的方式实现线程间的通讯和协作
比如说最经典的生产者-消费者模型:当队列满时,生产者需要等待队列有空间才能继续往里面放入商品,而在等待的期间内,生产者必须释放对临界资源(即队列)的占用权。因为生产者如果不释放对临界资源的占用权,那么消费者就无法消费队列中的商品,就不会让队列有空间,那么生产者就会一直无限等待下去。因此,一般情况下,当队列满时,会让生产者交出对临界资源的占用权,并进入挂起状态。然后等待消费者消费了商品,然后消费者通知生产者队列有空间了。同样地,当队列空时,消费者也必须等待,等待生产者通知它队列中有商品了。这种互相通信的过程就是线程间的协作。
Java中线程通信协作的最常见的两种方式:
一.syncrhoized加锁的线程的Object类的wait()/notify()/notifyAll()
二.ReentrantLock类加锁的线程的Condition类的await()/signal()/signalAll()线程间直接的数据交换:
三.通过管道进行线程间通信:1)字节流;2)字符流
016 实现线程同步的方法
- 同步代码方法:sychronized 关键字修饰的方法
- 同步代码块:sychronized 关键字修饰的代码块
- 使用特殊变量域volatile实现线程同步:volatile关键字为域变量的访问提供了一种免锁机制
- 使用重入锁实现线程同步:reentrantlock类是可重入、互斥、实现了lock接口的锁, 他与sychronized方法具有相同的基本行为和语义
017 线程安全(Servlet | Struts2 | SpringMVC)
线程安全是编程中的术语,指某个方法在多线程环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确完成。
Servlet 不是线程安全的,servlet 是单实例多线程的,当多个线程同时访问同一个方法,是不能保证共享变量的线程安全性的。
Struts2 的 action 是多实例多线程的,是线程安全的,每个请求过来都会 new一个新的 action 分配给这个请求,请求完成后销毁。
SpringMVC 的 Controller 是线程安全的吗?不是的,和 Servlet 类似的处理流程。
Struts2 好处是不用考虑线程安全问题;Servlet 和 SpringMVC 需要考虑线程安全问题,但是性能可以提升不用处理太多的 gc,可以使用 ThreadLocal 来处理多线程的问题
并发理论(重要⭐⭐⭐)
s3. Java内存模型
01 Java中垃圾回收
垃圾回收是在内存中存在没有引用的对象或超过作用域的对象时进行的.
垃圾回收的目的是识别并且丢弃应用不再使用的对象来释放和重用资源
02 finalize()方法
垃圾回收器GC(garbage colector)决定回收某对象时,就会运行该对象的finalize()方法;
finalize是Object类的一个方法,该方法在Object类中的声明protected void finalize() throws Throwable { }
在垃圾回收器执行时会调用被回收对象的finalize()方法,可以覆盖此方法来实现对其资源的回收。
注意:一旦垃圾回收器准备释放对象占用的内存,将首先调用该对象的finalize()方法,并且下一次垃圾回收动作发生时,才真正回收对象占用的内存空间
析构函数(finalization): 特殊的情况下,比如调用了一些native的方法(一般是C写的),可以要在finaliztion里去调用C的释放函数
03 as-if-serial规则和happens-before规则的区别
as-if-serial语义 | happens-before |
---|---|
保证单线程内程序的执行结果不被改变 | 保证正确同步的多线程程序的执行结果不被改变 |
给编写单线程程序的程序员创造了一个幻境:单线程 程序是按程序的顺序来执行的 |
给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。 |
目的: 都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度 |
04 并发关键字 synchronized
在 Java 中,synchronized 关键字是用来控制线程同步的,就是在多线程的环境下,控制 synchronized 代码段不被多个线程同时执行。synchronized 可以修饰类、方法、变量.
JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
public class Test{
//私有化该类的引用
private static volatile Test instance; //volatile修饰,保证可见和禁止指令重排
//构造器私有化
pvivate Test(){}
//提供公共的访问方式
public static Test getInstance(){
if(instance == null){
//类对象加锁
synchronized (Test.class){
if(instance == null){
instance = new Test();
}
}
}
return instance;
}
}
instance = new Test();这段代码其实是分为三步执行:
1. 为 instance 分配内存空间
2. 调用构造器初始化 instance
3. 将 instance 指向分配的内存地址
but 由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2;
指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例;
例如,线程 T1 执行了 1 和 3,此时 T2 调用getInstance() 后发现 Instance 不为空,因此返回
Instance,但此时 Instance 还未被初始化。
使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。
底层:
可以看出在执行同步代码块之前之后都有一个monitor字样,其中前面的是monitorenter,后面的是离开monitorexit,不难想象一个线程也执行同步代码块,首先要获取锁,而获取锁的过程就是monitorenter ,在执行完代码块之后,要释放锁,释放锁就是执行monitorexit指令。
为什么会有两个monitorexit呢?
这个主要是防止在同步代码块中线程因异常退出,而锁没有得到释放,这必然会造成死锁(等待的线程永远获取不到锁)。因此最后一个monitorexit是保证在异常情况下,锁也可以得到释放,避免死锁。
仅有ACC_SYNCHRONIZED这么一个标志,该标记表明线程进入该方法时,需要monitorenter,退出该方法时需要monitorexit。
synchronized可重入的原理
重入锁是指一个线程获取到该锁之后,该线程可以继续获得该锁。底层原理维护
一个计数器,当线程获取该锁时,计数器加一,再次获得该锁时继续加一,释放
锁时,计数器减一,当计数器值为0时,表明该锁未被任何线程所持有,其它线
程可以竞争获取锁。
自旋锁
很多 synchronized 里面的代码只是一些很简单的代码,执行时间非常快,此时
等待的线程都加锁可能是一种不太值得的操作,因为线程阻塞涉及到用户态和内
核态切换的问题。既然 synchronized 里面的代码执行得非常快,不妨让等待锁
的线程不要被阻塞,而是在 synchronized 的边界做忙循环,这就是自旋。如果
做了多次循环发现还没有获得锁,再阻塞,这样可能是一种更好的策略
05 可见性(线程之间修改变量)
(1)volatile 修饰变量
(2)synchronized 修饰修改变量的方法
(3)wait/notify
(4)while 轮询
06 synchronized、volatile、CAS 比较
(1)synchronized 是悲观锁,属于抢占式,会引起其他线程阻塞。
(2)volatile 提供多线程共享变量可见性和禁止指令重排序优化。
(3)CAS 是基于冲突检测的乐观锁(非阻塞)
07 synchronized 和 Lock 有什么区别?
- 首先synchronized是Java内置关键字,在JVM层面,Lock是个Java类;
- synchronized 可以给类、方法、代码块加锁;而 lock 只能给代码块加锁。
- synchronized 不需要手动获取锁和释放锁,使用简单,发生异常会自动释放锁,不会造成死锁;而 lock 需要自己加锁和释放锁,如果使用不当没有 unLock()去释放锁就会造成死锁。
- 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到
08 synchronized 和 ReentrantLock 区别
两者都是可重入锁。自己可以再次获取自己的内部锁。
synchronized | ReentrantLock |
---|---|
Java中每一个对象都可以作为锁,这是synchronized实现同步的基础: | 使用起来比较灵活,但是必须有释放锁的配合动作 |
不需要手动释放和开启锁 | 必须手动获取与释放锁 |
可以修饰类、方法、变量== | 只适用于代码块锁 |
操作的应该是对象头中 mark word | 底层调用的是 Unsafe 的park 方法加锁 |
09 volatile 关键字的作用
volatile 关键字来保证可见性和禁止指令重排。
volatile 提供 happens-before 的保证,确保一个线程的修改能对其他线程是可见的
volatile 的一个重要作用就是和 CAS 结合,保证了原子性
volatile 常用于多线程环境下的单次操作(单次读或者单次写)
10 synchronized 和 volatile 的区别是什么?
synchronized 表示只有一个线程可以获取作用对象的锁,执行代码,阻塞其他线程。
volatile 表示变量在 CPU 的寄存器中是不确定的,必须从主存中读取。保证多线程环境下变量的可见性;禁止指令重排序。
区别
- volatile 是变量修饰符;synchronized 可以修饰类、方法、变量。
- volatile 仅能实现变量的修改可见性,不能保证原子性;而synchronized 可以保证变量的修改可见性和原子性。
- volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。
- volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。
- volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。但是volatile关键字只能用于变量而synchronized关键字可以修饰方法以及代码块。synchronized关键字在JavaSE1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁以及其它各种优化之后执行效率有了显著提升,实际开发中使用 synchronized 关键字的场景还是更多一些
##### **11 Java内存模型(重要⭐JMM)** 具体可见网址 :
内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。 ###### Java内存模型 同一套内存模型规范,不同语言在实现上可能会有些差别。接下来着重讲一下 Java 内存模型实现原理。
**Java运行时内存区域和硬件内存的关系**
JVM运行时内存区域是分片(栈、堆等等),其实这些是JVM定义的逻辑概念。在传统硬件内存架构没有这些概念。
从上图可以看出栈堆既存在主内存又存在于告诉缓存中,所以两者没有直接关系
**Java线程和主内存的关系**
- 所有变量存储在主内存中(Main Memory)
- 每一个线程有一个私有的本地内存/工作内存(Local Memory),本地内存存储了该线程以读/写共享变量的拷贝副本
- 线程对变量的所有操作必须在本地内存进行,而不能直接读写主内存
- 不同线程之间无法访问对方本地内存的变量
tip : 工作内存就是本地内存的意思
小结
由于CPU 和主内存间存在数量级的速率差,想到了引入了多级高速缓存的传统硬件内存架构来解决,多级高速缓存作为 CPU 和主内间的缓冲提升了整体性能。解决了速率差的问题,却又带来了缓存一致性问题。
数据同时存在于高速缓存和主内存中,如果不加以规范势必造成灾难,因此在传统机器上又抽象出了内存模型。
Java 语言在遵循内存模型的基础上推出了 JMM 规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题
为了更精准控制工作内存和主内存间的交互,JMM 还定义了八种操作:lock, unlock, read,load,use,assign, store, write。见下图:
s4 Lock体系
01 Lock简介
它的优势有:
(1)可以使锁更公平
(2)可以使线程在等待锁的时候响应中断
(3)可以让线程尝试获取锁,并在无法获取锁的时候立即返回或者等待一段时间
(4)可以在不同的范围,以不同的顺序获取和释放锁
整体上来说 Lock 是 synchronized 的扩展版,Lock 提供了无条件的、可轮询
的(tryLock 方法)、定时的(tryLock 带参方法)、可中断的(lockInterruptibly)、
可多条件队列的(newCondition 方法)锁操作。另外 Lock 的实现类基本都支持
非公平锁(默认)和公平锁,synchronized 只支持非公平锁,当然,在大部分情
况下,非公平锁是高效的选择
02 乐观锁和悲观锁的理解及实现
悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。再比如 Java 里面的同步 synchronized关键字的实现也是悲观锁
乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于 write_condition 机制,其实都是提供的乐观锁
乐观锁的实现方式:
1、使用版本标识来确定读到的数据与提交时的数据是否一致。提交后修改版本标识,不一致时可以采取丢弃和再次尝试的策略。
2、java 中的 Compare and Swap 即 CAS ,当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。 CAS 操作中包含三个操作数 —— 需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B)。如果内存位置 V 的值与预期原值 A 相匹配,那么处理器会自动将该位置值更新为新值 B。否则处理器不做任何操作。
03 CAS
CAS 是 compare and swap 的缩写,即我们所说的比较交换。
cas 是一种基于锁的操作,而且是乐观锁. 乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如
通过给记录加 version 来获取数据,性能较悲观锁有很大的提高
CAS是通过无限循环来获取数据的,若果在第一轮循环中,a 线程获取地址里面的值被b 线程修改了,那么 a 线程需要自旋,到下次循环才有可能机会执行。java.util.concurrent.atomic 包下的类大多是使用 CAS 操作来实现的
(AtomicInteger,AtomicBoolean,AtomicLong)
Q | A |
---|---|
1、ABA 问题: | 1.5 开始 JDK 的 atomic包里提供了一个类 AtomicStampedReference |
2、循环时间长开销大: | 对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized |
3、只能保证一个共享变量的原子操作 | 当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子 但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时 候就可以用锁。 |
04 AQS(AbstractQueuedSynchronizer)详解与源码分析
这个类在java.util.concurrent.locks包下面
AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。当然,我们自己也能利用AQS非常轻松容易地构造出符合我们自己需求的同步器
AQS 原理概览
AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。
AQS(AbstractQueuedSynchronizer)原理图:
AQS使用一个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改;
private volatile int state; //共享变量,使用volatile修饰保证线程可见性
状态信息通过protected类型的getState,setState,compareAndSetState进行操作
//返回同步状态的当前值
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 对资源的共享方式
Exclusive(独占):只有一个线程能执行,如ReentrantLock。
公平锁:按照线程在队列中的排队顺序,先到者先拿到锁;
非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
Share(共享):多个线程可同时执行,如Semaphore/CountDownLatch。Semaphore、CountDownLatch、CyclicBarrier、ReadWriteLock
AQS底层使用了模板方法模式
同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样
1. 使用者继承AbstractQueuedSynchronizer并重写指定的方法。(这些重写方法很简单,无非是对于共享资源state的获取和释放)
2. 将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。
AQS使用了模板方法模式,自定义同步器时需要重写下面几个AQS提供的模板方法:
1 isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。
2 tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。
3 tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。
4 tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
5 tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。
以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。
ReentrantLock重入锁,是实现Lock接口的一个类,也是在实际编程中使用频率很高的一个锁,支持重入性,表示能够对共享资源能够重复加锁,即当前线程获取该锁再次获取不会被阻塞
①重入性的实现原理
要想支持重入性,就要解决两个问题:1. 在线程获取锁的时候,如果已经获取锁的线程是当前线程的话则直接再次获取成功;2. 由于锁会被获取n次,那么只有锁在被释放同样的n次之后,该锁才算是完全释放成功。
②ReentrantLock支持两种锁:公平锁和非公平锁。何谓公平性,是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求上的绝对时间顺序,满足FIFO
05 多线程锁的升级原理
Java中,锁共有4种状态,级别从低到高依次为:无状态锁,偏向锁,轻量级锁和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级。
1)只有⼀个线程进⼊临界区,偏向锁
2)多个线程交替进⼊临界区,轻量级锁
3)多线程同时进⼊临界区,重量级锁
06 ReadWriteLock
ReadWriteLock 是一个读写锁接口,读写锁是用来提升并发程序性能的锁分离技术,ReentrantReadWriteLock 是 ReadWriteLock 接口的一个具体实现,实现了读写的分离,读锁是共享的,写锁是独占的,读和读之间不会互斥,读和写、写和读、写和写之间才会互斥,提升了读写的性能。
而读写锁有以下三个重要的特性:
(1)公平选择性:支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平。
(2)重进入:读锁和写锁都支持线程重进入。
(3)锁降级:遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁。
07 ConcurrentHashMap
ConcurrentHashMap是Java中的一个线程安全且高效的HashMap实现.利用了锁分段的思想提高了并发度
JDK 1.6版本关键要素:
- segment继承了ReentrantLock充当锁的角色,为每一个segment提供了线程安全的保障;
- segment维护了哈希散列表的若干个桶,每个桶由HashEntry构成的链表。
JDK1.8后,ConcurrentHashMap抛弃了原有的Segment 分段锁(锁段),而采用了CAS + synchronized 来保证并发安全性。
同步容器:可以简单地理解为通过 synchronized 来实现同步的容器,如果有多个线程调用同步容器的方法,它们将会串行执行。比如 Vector,Hashtable等方法返回的容器,需要同步的方法上加上关键字 synchronized;
并发容器使用了与同步容器完全不同的加锁策略来提供更高的并发性和伸缩性,例如在 ConcurrentHashMap 中采用了一种粒度更细的加锁机制,可以称为分段锁,在这种锁机制下,允许任意数量的读线程并发地访问 map,并且执行读操作的线程和写操作的线程也可以并发的访问 map,同时允许一定数量的写操作线程并发地修改 map,所以它可以在并发环境下实现更高的吞吐量。
ConcurrentHashMap 使用分段锁来保证在多线程下的性能。
ConcurrentHashMap 中则是一次锁住一个桶。ConcurrentHashMap 默认将hash 表分为 16 个桶,诸如 get,put,remove 等常用操作只锁当前需要用到的桶。
这样,原来只能一个线程进入,现在却能同时有 16 个写线程执行,并发性能的提升是显而易见的。
08 ThreadLocal内存泄漏分析
ThreadLocal造成内存泄漏的原因?
ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来, ThreadLocalMap 中就会出现key为null的Entry。假如我们不做任何措施的话,value 永远无法被GC 回收,这个
时候就可能会产生内存泄露。ThreadLocalMap实现中已经考虑了这种情况,在调用 set() 、 get() 、 remove() 方法的时候,会清理掉 key 为 null 的记录。使用完ThreadLocal 方法后 最好手动调用 remove() 方法
ThreadLocal内存泄漏解决方案?
- 每次使用完ThreadLocal,都调用它的remove()方法,清除数据。
- 在使用线程池的情况下,没有及时清理ThreadLocal,不仅是内存泄漏的问题,更严重的是可能导致业务逻辑出现问题。所以,使用ThreadLocal就跟加锁完要解锁一样,用完就清理
面试题部分
引用地址 :https://www.bilibili.com/video/BV1yT411H7YK?p=95&spm_id_from=pageDriver&vd_source=08ac522c6603c56e243d5e129a309a60
线程的基础知识
线程进程区别
类别 | 进程 | 线程 |
---|---|---|
本质 | 操作系统资源分配的基本单位 | 处理器任务调度和执行的基本单位 |
资源开销 | 有独立代码和数据空间,进程间切换开销大 | ,同一类线程共享代码和数据空间,每个线程有自己独立的运行栈和pc,开销小 |
包含关系 | 一个进程可以包含1-n个线程 | 线程是进程的一部分,轻量级的进程 |
内存分配 | 进程之间的地址空间和资源是相互独立 | 同一进程的线程共享本进程的地址空间和资源 |
影响关系 | 进程崩溃,保护模式下对其他无影响,健壮 | 线程崩溃,整个进程挂 |
执行过程 | 程序运行的入口、顺序执行序列和程序出口 | 线程不能独立执行,必须依存在应用程序中 |
- 进程是正在运行程序的实例,进程里包括线程,每个线程执行不同的任务,每个进程包括至少一个线程;
- 不同进程有不同的内存空间,当前进程的所有线程可以共享内存空间,但线程都有自己的栈和PC
- 线程更轻量,上下文切换的成本一般而言更低;
并行并发区别
并行 : 真正意义上的同步执行,多核CPU同时执行多个线程的任务,同一时间动手做多件事情
并发 ; 逻辑意义的同时执行,多线程等待时间片资源,交替执行任务,只不过切换时间快,给人同时执行的假象,同一时间应对多件事情的能力,多线程轮流使用一个或者多个CPU
线程创建方式
一, 继承 Thread 类,重写run方法, start开启
二, 实现 Runnable 接口,重写run方法, start开启
三, 实现 Callable 接口,
四, Executor (线程池创建线程)
线程状态
new runnable blocked waiting time_waiting terminated
- 创建线程对象是新建状态new
- 调用start()方法变为可执行状态
- 线程拿到 CPU 执行权,执行结束是终止状态
- 可执行状态过程里,如果没有拿到 CPU 执行权,可能切换其他状态
- 没有锁进入阻塞状态,获得锁再换为可执行状态
- wait()方法进行等待状态,释放锁和资源,调用唤醒方法切换为可执行状态
- sleep() 方法,进入计时等待状态,到时间后切换为 可执行状态;
runable & callable
r 没有返回值, c 的call 方法有返回值,需要 FutureTask 获取结果
r 异常只能内部消化,不能向上抛, c 的call方法允许抛出异常
wait() & sleep()
共同点 ; wait() wait(long)和sleep(long)都能让当前线程暂时放弃 CPU 执行权,进入阻塞状态
不同点 :
方法归属 : 前者属于 Thread的静态方法,后者属于 Object 的成员方法
唤醒时机 :
- 如果有参数,都会在等待相应时间后醒来,
- wait()和wait(long)还可以被 notify唤醒,wait()如果不唤醒就一直等,
- 都可以被打断唤醒
锁特性不同(重要)
- wait方法调用必须获取wait对象锁,sleep没有此限制
- wait方法执行后会释放锁,允许其他线程获得该对象锁
- 但是sleep人如果在 synchronized 代码块执行,并不释放对象锁 (放弃CPU,但你们也用不了)
线程之间如何保证顺序执行
t1 -> t2 ->t3
新建t1线程
t1.join(); 在t2线程代码块的首行
t2.join(); 在t3线程代码块的首行
notify() 和 notifyAll() 的区别
唤醒一个,唤醒所有
run() 和 start() 区别
start() 开启线程,只调用一次,调用 run方法,所定义的逻辑代码
run() 封装了要被线程执行的代码,可调用多次
如何停止一个正在运行的线程
- 退出标志,线程正常退出
- stop方法强行终止
- interrupt方法中断线程
- 打断阻塞线程 sleep wait join 线程,抛异常InterruptedEXception
- 打断正常线程,可根据打断状态标记是否退出线程
线程并发安全
synchronized底层
- Synchronized(对象锁) 采用互斥的方式让同一时刻最多只能有一个线程能够持有对象锁
- 底层是 monitor 实现, monitor 是JVM级别的对象(C++实现),线程获得锁需要使用对象锁关联 monitor
- monitor 内部有三个属性,分别是 owner entrylist waitset
- 其中 owner 关联获得锁的线程,并且只能关联一个线程, entrylist关联阻塞状态的线程, waitset是处于等待
在JDK1.6版本以后,有偏向锁、轻量级锁、重量级锁,分别对应锁只被一个线程持有,不同线程交替持有锁,多线程竞争锁的三种情况;
分类 | desc |
---|---|
偏向锁 | 当很长时间只有一个线程使用锁,可用偏向锁,第一次获得时,CAS操作,之后该线程再获取锁,需要判断 mark word 里是否是自己的线程 id 即可,而不是开销相对较大的CAS命令 |
轻量级锁 | 当线程加锁时间错开(无竞争),可用轻量级锁优化,修改了对象头的锁标志,相比重量级锁性能提升很多,每次修改都是CAS操作,保证原子性 |
重量级锁 | 底层 Monitor 实现,涉及到用户态内核态切换,进程上下文切换,成本较高,性能比较低 |
一旦锁发生竞争,就会升级为重量级锁
JMM
- JMM (Java Memory Model)Java内存模型,定义了共享内存中多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性;
- JMM 把内存分为两块,一块是私有线程的工作区域(工作内存),一块是所有线程的共享区域(主内存);
- 线程之间相互隔离,线程之间的交互需要通过主内存;
CAS
- CAS 全称是 :** Compare And Swap (比较再交换);体现的一种乐观锁**的思想,在无锁状态下保证线程操作数据的原子性;
- CAS 使用的地方 : AQS框架,AtomicXXX 类
- 操作共享变量的时候用的自旋锁,效率上更高一些
- CAS 底层调用的 Unsafe 类中的方法,都是操作系统提供的,其他语言实现
乐观锁和悲观锁的区别
- CAS 基于乐观的思想: 最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,吃点亏再重试;
- synchronized 基于悲观锁的思想:悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,必须等我改完了解锁,你们才有机会用;
AQS
- 多线程的队列同步器,锁机制,像 ReentrantLock 和信号量基于AQS实现的
- 内部维护一个先进先出的双向队列,存储的是排队的线程
- 内部有一个属性 state ,相当于一个资源,默认是 0 (无锁状态),如果队列有一个线程修改成功 state 为1,则当前线程获取资源
- 对state 修改时候用 CAS 操作,保证多线程修改情况下的原子性
ReentrantLock实现原理
- 表示支持重新进入的锁,调用 Lock 方法获取锁之后,再调用 lock 不会阻塞
- 主要利用 CAS + AQS 队列实现
- 支持公平锁和非公平锁,提供的构造器中无参默认是非公平锁,也可以传参设置为公平锁
synchronized & Lock
死锁产生条件
死锁诊断
volatile
问题一 : JVM有一个 JIT (即时编译器)给代码做了优化
//问题一 : JVM有一个 JIT (即时编译器)给代码做了优化
while(!stop){
i++;
}
// 优化之后
while(true){
i++;
}
//解决方案一: 程序运行时候加上 vm 参数 -Xint表示禁用即时编译器,不推荐
//解决方案二: 在修改 stop 变量时候加上 volatile 当前告诉JIT,不要对volatile修饰的变量做优化
:::info
写变量 让 volatile 修饰的变量在代码最后位置
读变量 让 voaltile 修饰的变量在代码最开始位置
:::
①保证线程间的可见性
用volatile修饰共享变量,能防止编译器优化等发生,让一个线程对共享变量的修改对另一个线程可见
②禁止指令重排序
指令重排:volatile修饰共享变量会在读、写共享变量时加入不同屏障,阻止其它读写操作越过屏障,从而阻止重排序的效果
ConcurrentHashMap
导致并发程序出现问题的根本原因
原子性
可见性
指令重排序
线程池
线程池核心参数(执行原理)
线程池常见的阻塞队列
核心线程数的确定
线程池种类
不建议用 Executors 创建线程池的原因
使用场景
线程池使用场景(项目中哪里用到线程池CountDownLatch、Future)
场景一(ES数据批量导入)
项目上线之前,需要把数据库的数据一次性同步到 ES 索引库,但是当时数据好像是1KW左右,一次性读取可能OOM异常,用线程池方式导入,利用闭锁(倒计时锁)来控制,避免一次性加载过多,防止内存溢出
场景二(数据汇总)
场景三 : 比如主线程进行搜索的时候,异步调用其它线程来保存搜索记录;
批量导入 : 线程池 + 闭锁 批量把数据库的数据导入到 ES 中,避免 OOM
数据汇总:调用多个接口汇总数据,如果所有接口(或者部分接口没有依赖关系),就可以使用线程池 + future 来提升性能
异步线程(线程池),避免下一级方法影响上一级方法(性能考虑),可用异步线程调用下一个方法(不需要下一级方法返回值),可以提升方法响应时间
控制某个方法允许并发访问线程的数量
工具类 信号量,并发情况下,可以控制方法访问量文章来源:https://www.toymoban.com/news/detail-457743.html
- 创建信号量对象,可以给一个容量
- acquire()可以请求一个信号量,这时候信号量个数-1
- release()释放一个信号量,此时信号量个数+1
ThreadLocal
文章来源地址https://www.toymoban.com/news/detail-457743.html
到了这里,关于第二章--Java多线程高并发+面试题的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!