Java多线程
2021-08-18 10:22:54 0 举报
AI智能生成
Java多线程
作者其他创作
大纲/内容
Java内存模型
对象的创建过程, 内存的分配
对象的创建过程
1. 在堆分配对象(markword, class pointer, 数据, padding), 对象的值为默认值0 null ,
2. 在栈上分配成员变量
3. 调用类的构造方法, 初始化对象的指定初始值
4. 将栈上的成员变量指向堆上的对象
1. 在堆分配对象(markword, class pointer, 数据, padding), 对象的值为默认值0 null ,
2. 在栈上分配成员变量
3. 调用类的构造方法, 初始化对象的指定初始值
4. 将栈上的成员变量指向堆上的对象
对象在内存中的存储布局
对象头 markword 8字节
锁信息
偏向锁位
第三个bit
锁标志位
第1,2个bit
偏向锁 101
存储着这个锁偏向的线程的指针
轻量级锁 00
指向线程栈中Lock Record的指针
重量级锁 10
指向互斥量mutex的指针
hashcode
GC信息
分代年龄 4bit 所以最多15次就需要到老年代
类型指针 class pointer 4字节
实例数据
对齐 padding
并发基础
并发编程三要素是什么?
- 原子性:原子,即一个不可再被分割的颗粒。原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。
- 可见性:一个线程对共享变量的修改,另一个线程能够立刻看到。(synchronized,volatile)
- 有序性:程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序)
- 可见性:一个线程对共享变量的修改,另一个线程能够立刻看到。(synchronized,volatile)
- 有序性:程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序)
子主题
线程 进程 协程/线程
run() start()
run()
每个线程都是通过某个特定Thread对象所对应的方法run()来完成其操作的,run()方法称为线程体
run() 可以重复调用
通过此Thread类调用方法run()来完成其运行状态, run()方法运行结束, 此线程终止。然后CPU再调度其它线程。
run()方法是在本线程里的,只是线程里的一个函数,而不是多线程的。 如果直接调用run(),其实就相当于是调用了一个普通函数而已,直接待用run()方法必须等待run()方法执行完毕才能执行下面的代码,所以执行路径还是只有一条,根本就没有线程的特征,所以在多线程执行时要使用start()方法而不是run()方法。
start()
通过调用Thread类的start()方法来启动一个线程。
start() 只能调用一次
调用start()方法无需等待run方法体代码执行完毕,可以直接继续执行其他的代码; 此时线程是处于就绪状态,并没有运行。
runnable vs callable
相同点
- 都是接口
- 都可以编写多线程程序
- 都采用Thread.start()启动线程
- 都可以编写多线程程序
- 都采用Thread.start()启动线程
区别
- Runnable 接口 run 方法无返回值;Callable 接口 call 方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果
- Runnable 接口 run 方法只能抛出运行时异常,且无法捕获处理;Callable 接口 call 方法允许抛出异常,可以获取异常信息
- 注:Callalbe接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。
- Runnable 接口 run 方法只能抛出运行时异常,且无法捕获处理;Callable 接口 call 方法允许抛出异常,可以获取异常信息
- 注:Callalbe接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。
sleep() vs wait()
两者都可以暂停线程的执行
- 类的不同:sleep() 是 Thread线程类的静态方法,wait() 是 Object类的方法。
- 是否释放锁:sleep() 不释放锁;wait() 释放锁。
- 用途不同:Wait 通常被用于线程间交互/通信,sleep 通常被用于暂停执行。
- 用法不同:wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。
- sleep() 方法执行完成后,线程会自动苏醒。或者可以使用wait(long timeout)超时后线程会自动苏醒。
- 类的不同:sleep() 是 Thread线程类的静态方法,wait() 是 Object类的方法。
- 是否释放锁:sleep() 不释放锁;wait() 释放锁。
- 用途不同:Wait 通常被用于线程间交互/通信,sleep 通常被用于暂停执行。
- 用法不同:wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。
- sleep() 方法执行完成后,线程会自动苏醒。或者可以使用wait(long timeout)超时后线程会自动苏醒。
sleep() vs yield()
(1) sleep()方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会;yield()方法只会给相同优先级或更高优先级的线程以运行的机会;
(2) 线程执行 sleep()方法后转入阻塞(blocked)状态,而执行 yield()方法后转入就绪(ready)状态;
(3)sleep()方法声明抛出 InterruptedException,而 yield()方法没有声明任何异常;
(4)sleep()方法比 yield()方法(跟操作系统 CPU 调度相关)具有更好的可移植性,通常不建议使用yield()方法来控制并发线程的执行。
(2) 线程执行 sleep()方法后转入阻塞(blocked)状态,而执行 yield()方法后转入就绪(ready)状态;
(3)sleep()方法声明抛出 InterruptedException,而 yield()方法没有声明任何异常;
(4)sleep()方法比 yield()方法(跟操作系统 CPU 调度相关)具有更好的可移植性,通常不建议使用yield()方法来控制并发线程的执行。
FutureTask
FutureTask 表示一个异步运算的任务。FutureTask 里面可以传入一个 Callable 的具体实现类,可以对这个异步运算的任务的结果进行等待获取、判断是否已经完成、取消任务等操作。
只有当运算完成的时候结果才能取回,如果运算尚未完成 get 方法将会阻塞。一个 FutureTask 对象可以对调用了 Callable 和 Runnable 的对象进行包装,由于 FutureTask 也是Runnable 接口的实现类,所以 FutureTask 也可以放入线程池中。
只有当运算完成的时候结果才能取回,如果运算尚未完成 get 方法将会阻塞。一个 FutureTask 对象可以对调用了 Callable 和 Runnable 的对象进行包装,由于 FutureTask 也是Runnable 接口的实现类,所以 FutureTask 也可以放入线程池中。
启动线程的三种方式
implements Runnable
new Thread(new MyRunnable()).start()
需要新建一个Thread对象并且传入实现了Runnable接口的类的对象然后调用start()
extends Thread
new MyThread().start()
新建这个继承了Thread类的类对象调用start()方法
lambda: new Thread(()->{})
implements Callable
public class MyCallable implements Callable<Integer> 类Override call方法
FutureTask<Integer> futureTask = new FutureTask<Integer>(new MyCallable());
Thread thread = new Thread(futureTask);
thread.start();
FutureTask<Integer> futureTask = new FutureTask<Integer>(new MyCallable());
Thread thread = new Thread(futureTask);
thread.start();
Executors
ExecutorService executorService = Executors.newSingleThreadExecutor();
MyRunnable runnableTest = new MyRunnable();
executorService.execute(runnableTest);
MyRunnable runnableTest = new MyRunnable();
executorService.execute(runnableTest);
sleep yeild wait
sleep
sleep的时候可以让别的线程运行,线程执行 sleep()方法后转入阻塞(blocked)状态
yeild
当前线程进入等待队列, 让出CPU 返回就绪状态
join
t.join() t线程去运行, 运行完了当前线程继续运行
wait
sleep wait区别
两者都可以暂停线程的执行
- 类的不同:sleep() 是 Thread线程类的静态方法,wait() 是 Object类的方法。
- 是否释放锁:sleep() 不释放锁;wait() 释放锁。
- 用途不同:Wait 通常被用于线程间交互/通信,sleep 通常被用于暂停执行。
- 用法不同:wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。
- sleep() 方法执行完成后,线程会自动苏醒。或者可以使用wait(long timeout)超时后线程会自动苏醒。
两者都可以暂停线程的执行
- 类的不同:sleep() 是 Thread线程类的静态方法,wait() 是 Object类的方法。
- 是否释放锁:sleep() 不释放锁;wait() 释放锁。
- 用途不同:Wait 通常被用于线程间交互/通信,sleep 通常被用于暂停执行。
- 用法不同:wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。
- sleep() 方法执行完成后,线程会自动苏醒。或者可以使用wait(long timeout)超时后线程会自动苏醒。
线程的状态
new
就绪状态
Ready
被CPU选出执行->Running
Running
线程被挂起, CPU时间片运行完了 ->Ready
Thread.yeild()->Ready
TimedWaiting
ThreadSleep(time)
o.wait(time)
t.join(time)
LockSupport.partNanos()
LockSupport.parkUntil()
Waiting
o.wait()
t.join()
LockSupport.park()
Blocked
等待进入同步代码块的锁
Terminated
terminated的线程不能重新start()运行
系统底层保证指令有序
内存屏障
java代码层面: volatile
字节码层面: ACC_VOLATILE
JVM内存屏障
LoadLoad
Load2访问前 Load1已经读取完成
StoreStore
Store2写入前, Store1写入操作对其他处理器可见
LoadStore
Store2写入前, Load1读取的数据读取完毕
StoreLoad
Load2读取前, Store1写入对所有处理器可见
对于volatile遍历的读写操作前后都需要加屏障
Hotspot具体的实现
并不是使用了系统层面的语而是一个锁的指令
系列层面
lfence: load读屏障
sfence: save写屏障
mfence: 全屏障
DCL(Double Check Lock)
DCL单例是否需要加volatile?
- 对象的创建过程
1. 在堆分配对象(markword, class pointer, 数据, padding), 对象的值为默认值0 null ,
2. 在栈上分配成员变量
3. 调用类的构造方法, 初始化对象的指定初始值
4. 将栈上的成员变量指向堆上的对象
- 为什么需要volatile
1. 线程1new 对象 申请堆中的内存, 变量赋默认值 0 null
2. 这时发生了指令重排序
3. 先建立了栈上变量与堆中对象的关系
4. 线程2 进入判断对象是否为空, 这时不为空, 因为已经建立了栈上成员变量与堆上对象的关系
5. 但是栈上成员变量实际指向的是一个半初始化状态的对象,使用的时候会发生空指针异常或者数据不一致
6. 再调用构造方法给成员变量赋初始值
1. 在堆分配对象(markword, class pointer, 数据, padding), 对象的值为默认值0 null ,
2. 在栈上分配成员变量
3. 调用类的构造方法, 初始化对象的指定初始值
4. 将栈上的成员变量指向堆上的对象
- 为什么需要volatile
1. 线程1new 对象 申请堆中的内存, 变量赋默认值 0 null
2. 这时发生了指令重排序
3. 先建立了栈上变量与堆中对象的关系
4. 线程2 进入判断对象是否为空, 这时不为空, 因为已经建立了栈上成员变量与堆上对象的关系
5. 但是栈上成员变量实际指向的是一个半初始化状态的对象,使用的时候会发生空指针异常或者数据不一致
6. 再调用构造方法给成员变量赋初始值
并发关键字
CAS
ABA问题
版本号
- AtomicMarkableReference(通过引入一个 boolean来反映中间有没有变过),
boolean类型
- AtomicStampedReference(通过引入一个 int 来累加来反映中间有没有变过)
循环时间长开销大
- 自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。
- pause指令有两个作用:
- 第一,它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;
- 第二,它可以避免在退出循环的时候因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而提高CPU的执行效率。
- pause指令有两个作用:
- 第一,它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;
- 第二,它可以避免在退出循环的时候因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而提高CPU的执行效率。
只能保证一个共享变量的原子操作
- 当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。
- 还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij
- 还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij
什么是CAS
CAS 是 compare and swap 的缩写,即我们所说的比较交换。底层是一个 lock 多CPU需要上锁 cmpxchg 字节码指令 是一个原子操作
- cas 是一种基于锁的操作,而且是乐观锁。在 java 中锁分为乐观锁和悲观锁。
- 悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。
- 而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加 version 来获取数据,性能较悲观锁有很大的提高。
- CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和 A 的值是一样的,那么就将内存里面的值更新成 B。CAS是通过无限循环来获取数据的,如果在第一轮循环中,a 线程获取地址里面的值被b 线程修改了,那么 a 线程需要自旋,到下次循环才有可能机会执行。
竞争较低的时候使用CAS比较合适, 但是需要消耗CPU资源
- cas 是一种基于锁的操作,而且是乐观锁。在 java 中锁分为乐观锁和悲观锁。
- 悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。
- 而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加 version 来获取数据,性能较悲观锁有很大的提高。
- CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和 A 的值是一样的,那么就将内存里面的值更新成 B。CAS是通过无限循环来获取数据的,如果在第一轮循环中,a 线程获取地址里面的值被b 线程修改了,那么 a 线程需要自旋,到下次循环才有可能机会执行。
竞争较低的时候使用CAS比较合适, 但是需要消耗CPU资源
synchronized
锁升级过程
无锁
偏向锁
对象markword记录指向当前拿到锁的线程
轻量级锁
产生竞争, markword记录指向拥有锁的线程栈中锁记录
重量级锁
竞争加剧, markword记录指向mutex互斥锁的指针, 竞争线程进入锁的等待队列不消耗CPU
锁
静态方法 静态代码块
用的是类对象
非静态方法, 非静态代码块
用的是当前对象
可重入性
对于同一个线程, 拿到了对象的锁, 可以进入该对象方法中所有的非静态方法和同步代码块 否则会出现死锁
一个同步方法 想要调用当前对象的另一个同步方法, 必须可以否则就死锁, 因为使用的是一把锁, 所以可以调用
锁消除
对于不存在线程安全的情况下, 消除锁, 这样节省了加锁解锁的消耗
StringBuffer.append().append() 保证这个StringBuffer对象只在一个方法中调用, 不存在被多个线程共享的情况下可以 消除锁
锁粗化
加锁的粒度比较细, 可能只是一行代码, 但是一行代码可能执行了很多次, 导致多次的上锁解锁, 直接扩大锁的范围, 只需要一次加锁解锁即可
synchronized实现原理
java代码
synchronized
java字节码
monitorenter
monitorexit
monitorexit
执行过程中自动升级
偏向锁, 轻量级锁, 重量级锁
汇编层级
lock cmpxchg
使用场景: 高并发, 竞争激烈, 其他等待的线程进入等待队列不消耗CPU资源
volatile
保证线程之间的可见性
线程读取共享资源需要先从主存中读取而不是直接使用本地的变量备份, 保证了一个线程对于共享变量的更改能够让其他线程看见
禁止指令重排序
volatile的内存屏障
- volatile store操作
- 之前的store操作执行完毕 (StoreStore)
- volatile写操作完成后续的读可见 (StoreLoad)
- volatile load操作
- 之前load操作执行完毕 (LoadLoad)
- volatile读操作完成以后后续的store操作才可以执行(LoadStore)
- 之前的store操作执行完毕 (StoreStore)
- volatile写操作完成后续的读可见 (StoreLoad)
- volatile load操作
- 之前load操作执行完毕 (LoadLoad)
- volatile读操作完成以后后续的store操作才可以执行(LoadStore)
内存屏障
java代码层面: volatile
字节码层面: ACC_VOLATILE
JVM内存屏障
LoadLoad
Load2访问前 Load1已经读取完成
StoreStore
Store2写入前, Store1写入操作对其他处理器可见
LoadStore
Store2写入前, Load1读取的数据读取完毕
StoreLoad
Load2读取前, Store1写入对所有处理器可见
对于volatile遍历的读写操作前后都需要加屏障
Hotspot具体的实现
并不是使用了系统层面的语而是一个锁的指令
系列层面
lfence: load读屏障
sfence: save写屏障
mfence: 全屏障
超线程
CPU的一个核对应两组PC+Register 可以同时存在两个线程, ALU只需要在两组PC+Register切换,
cache line 缓存行对齐 伪共享
- 读取数据 内存->L3 -> L2 -> L1
- 局部性原理, 读取了x可能马上就会用x后面的数据
- 读取数据按块读, 一次读一个缓存行 64字节
- 如果两个volatile修饰的变量存在于一个缓存行, 不同的线程再对于两个变量做修改的时候每次都需要通知另一个线程这个缓存行已经修改了, 需要取内存中读取最新的数据才可以读到最新的数据, 这样会降低相率,
- CPU的可见性是以一个缓存行为单位的
- 局部性原理, 读取了x可能马上就会用x后面的数据
- 读取数据按块读, 一次读一个缓存行 64字节
- 如果两个volatile修饰的变量存在于一个缓存行, 不同的线程再对于两个变量做修改的时候每次都需要通知另一个线程这个缓存行已经修改了, 需要取内存中读取最新的数据才可以读到最新的数据, 这样会降低相率,
- CPU的可见性是以一个缓存行为单位的
final
出现异常会释放锁
JOL 内存布局
强软弱虚引用
强
类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
软
- 一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常
- 如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。
**用处:** 软引用在实际中有重要的应用,例如浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从缓存中取出呢?这就要看具体的实现策略了。
- (1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建
- (2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出
- 如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。
**用处:** 软引用在实际中有重要的应用,例如浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从缓存中取出呢?这就要看具体的实现策略了。
- (1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建
- (2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出
弱
那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象
虚
作用: 管理堆外内存
- JVM DirectByteBuffer分配的内存在JVM堆外,
- JVM内没有指向DBB的时候, 需要回收这块区域, 和他关联的堆外内存需要同时回收
- 对这样的对象挂一个虚引用, 对象被回收的时候, 这个虚引用被放入一个Queue, 这个Queue有虚引用就会去回收这个虚引用对应的堆外内存
- JVM DirectByteBuffer分配的内存在JVM堆外,
- JVM内没有指向DBB的时候, 需要回收这块区域, 和他关联的堆外内存需要同时回收
- 对这样的对象挂一个虚引用, 对象被回收的时候, 这个虚引用被放入一个Queue, 这个Queue有虚引用就会去回收这个虚引用对应的堆外内存
Lock
AQS
state(锁)
volatile修饰的int变量, 子类自己去定义, 如state=0可以获取锁, state=1不可以获取锁
因为volatile修饰, 保证的线程之间的可见性, 相当于一个判别是否能获取锁的标志
同步队列
同步队列是一个个线程封装的结点Node组成的双向链表
每个结点存储着一个线程, 表示等待获取锁的线程
获取锁失败的线程会被封装成一个结点加入同步队列
加入队列是通过CAS的方法加入到队列 compareAndSetHead() compareAndSetTail() 加入的操作时原子操作, 保证安全性
获取锁失败的线程会被封装成一个结点加入同步队列
加入队列是通过CAS的方法加入到队列 compareAndSetHead() compareAndSetTail() 加入的操作时原子操作, 保证安全性
CAS
尝试获取锁的时候是通过CAS将state的值+1
尝试将等待锁的线程封装成Node加入到同步队列的时候也是通过CAS的方式加入
总结:
AQS提供了一些模板方法, acquire tryAcquire release tryRelease , 以及一个volatile修饰的int变量state
实现锁的时候 通过重写这些提供的模板方法以及定义state表示的不同含义来实现不同的锁
实现锁的时候 通过重写这些提供的模板方法以及定义state表示的不同含义来实现不同的锁
等待队列与同步队列
等待队列
等待获取锁的线程会放在一个等待队列
同步队列
队列, 实现了同步的机制, 多线程可以安全的访问
ThreadLoacal
线程本地对象, 对象与线程绑定
线程存在, 本地变量一直存在
线程存在, 本地变量一直存在
线程有一个ThreadLocalMap
- key = ThreadLocal
- value = 对象
new一个ThreadLocal 调用set(对象) 就把 创建的ThreadLocal 和 对象放入到Thread的map里, 这样这个对象就是线程私有, 因为存到到了线程局部变量的map中
- 这时候会有一个强引用指向 new出来的ThreadLocal对象
- 调用ThreadLocalMap.set方法将 ThreadLocal-value pair放入map的时候实际上创建了Entry<key,value> 放入map
- 但是这个Entry继承了WeakReference 这个key是一个弱引用指向new 出来的ThreadLocal key
这时候会存在内存泄露的问题
- 当强引用不在指向new 出来的ThreadLocal对象被垃圾回收的时候, map里的弱引用指向了null
- 那么ThreadLocalMap中的这条记录 null-value就无法被访问,
- 所以需要remove掉这条记录, 否则这个value对象就无法释放, 导致内存泄露
为什么ThreadLocal中的key要是弱引用
- 否则new ThreadLocal时的强引用释放的时候, 还有一个ThreadLocal中的key强引用指向这个ThreadLocal对象, 对象无法被释放造成内存泄露
- key = ThreadLocal
- value = 对象
new一个ThreadLocal 调用set(对象) 就把 创建的ThreadLocal 和 对象放入到Thread的map里, 这样这个对象就是线程私有, 因为存到到了线程局部变量的map中
- 这时候会有一个强引用指向 new出来的ThreadLocal对象
- 调用ThreadLocalMap.set方法将 ThreadLocal-value pair放入map的时候实际上创建了Entry<key,value> 放入map
- 但是这个Entry继承了WeakReference 这个key是一个弱引用指向new 出来的ThreadLocal key
这时候会存在内存泄露的问题
- 当强引用不在指向new 出来的ThreadLocal对象被垃圾回收的时候, map里的弱引用指向了null
- 那么ThreadLocalMap中的这条记录 null-value就无法被访问,
- 所以需要remove掉这条记录, 否则这个value对象就无法释放, 导致内存泄露
为什么ThreadLocal中的key要是弱引用
- 否则new ThreadLocal时的强引用释放的时候, 还有一个ThreadLocal中的key强引用指向这个ThreadLocal对象, 对象无法被释放造成内存泄露
应用
Spring @transactional
方法里都需要建立数据库连接, 进行SQL操作, 但是需要保证拿到的是同一个连接, 否则就不是事务了
通过ThreadLocal使得connection 是线程私有的就一定拿到的是同一个连接
方法里都需要建立数据库连接, 进行SQL操作, 但是需要保证拿到的是同一个连接, 否则就不是事务了
通过ThreadLocal使得connection 是线程私有的就一定拿到的是同一个连接
总结
ThreadLocal就是创建一个ThreadLocal对象, 将需要和线程绑定的对象以key(ThreadLoacal)-val(需要与线程绑定的对象) 放入到当前线程的ThreadLocalMap中
这样get set 都是从线程私有的ThreadLocalMap中获取修改这个变量, 只要线程存在, 本地变量就存在, 也就是和线程绑定
这样get set 都是从线程私有的ThreadLocalMap中获取修改这个变量, 只要线程存在, 本地变量就存在, 也就是和线程绑定
ReentrantLock
特点
显式锁
可以实现公平锁和非公平锁
tryLock()
lockInterruptibly()
可以被打断
结构
内部类sync继承了AQS
nonfairTryAcquire(int acquires)
基于CAS实现获取锁
基于CAS实现获取锁
可以获取锁 通过CAS设置state的值并且将当前线程设置成锁的唯一拥有者
当前线程已经获得了锁, 获取锁的次数计数器+1 代表可重入
tryRelease(int releases)
当前线程不拥有锁, 抛出异常
当前线程拥有锁, 计数器-release 如果=0 就释放锁, 并且设置锁的拥有者为null
class NonfairSync extends Sync
非公平的锁的类, 对应的方法都是在实现非公平锁的时候调用
class FairSync extends Sync
公平的锁的类, 对应的方法都是在实现公平锁的时候调用
ReentrantReadWriteLock
readLock() 共享锁
writeLock() 排他锁
writeLock() 排他锁
内部结构
class ReadLock implements Lock
lock 调用的是sync.acquireShared(1);
unlock调用的是 sync.releaseShared(1);
class WriteLock implements Lock
sync.acquire(1);
sync.release(1);
class Sync extends AbstractQueuedSynchronizer
写锁
boolean tryAcquire(int acquires)
boolean tryRelease(int releases)
boolean tryWriteLock()
读锁
boolean tryReleaseShared(int unused)
int tryAcquireShared(int unused)
boolean tryReadLock()
class FairSync extends Sync
class NonfairSync extends Sync
总结
通过内部类sync继承AQS 实现了共享锁 tryAcquireShared 排他锁 tryAcquire 为读写锁提供加锁解锁的具体实现, 实现也是通过CAS的方法实现的
对于读写锁类, 都是调用对应的ReentrantReadWriteLock内部类sync的对应的共享锁排他锁的方法实现加锁
也还有内部的 公平与非公平的类实现的非公平与公平锁
查看当前同步队列是否有等待的线程,来确定当前的读写锁是否应该blocked即入队
公平锁总是查看, 非公平锁, 写锁return false 读锁保证写锁不会一直处于等待状态
公平锁总是查看, 非公平锁, 写锁return false 读锁保证写锁不会一直处于等待状态
Condition
Condition的本质是有多个等待队列, 可以针对性的唤醒指定队列的线程
synchronized 只有一个队列, 生产者可能唤醒消费者也可能唤醒生产者, 反之亦然
synchronized 只有一个队列, 生产者可能唤醒消费者也可能唤醒生产者, 反之亦然
等待队列
- 等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用,该线程就是在Condition对象上等待的线程,
- 如果一个线程调用了Condition.await()方法,那么该线程将会释放锁、构造成节点加入等待队列并进入等待状态。
- 事实上,节点的定义复用了同步器中节点的定义,也就是说,同步队列和等待队列中节点类型都是同步器的静态内部类AbstractQueuedSynchronizer.Node。
- 一个Condition包含一个等待队列,Condition拥有首节点(firstWaiter)和尾节点(lastWaiter)。
- 当前线程调用Condition.await()方法,将会以当前线程构造节点,并将节点从尾部加入等待队列
- Condition拥有首尾节点的引用,而新增节点只需要将原有的尾节点nextWaiter指向它,并且更新尾节点即可。
- 上述节点引用更新的过程并没有使用CAS保证,原因在于调用await()方法的线程必定是获取了锁的线程,也就是说该过程是由锁来保证线程安全的。
- 如果一个线程调用了Condition.await()方法,那么该线程将会释放锁、构造成节点加入等待队列并进入等待状态。
- 事实上,节点的定义复用了同步器中节点的定义,也就是说,同步队列和等待队列中节点类型都是同步器的静态内部类AbstractQueuedSynchronizer.Node。
- 一个Condition包含一个等待队列,Condition拥有首节点(firstWaiter)和尾节点(lastWaiter)。
- 当前线程调用Condition.await()方法,将会以当前线程构造节点,并将节点从尾部加入等待队列
- Condition拥有首尾节点的引用,而新增节点只需要将原有的尾节点nextWaiter指向它,并且更新尾节点即可。
- 上述节点引用更新的过程并没有使用CAS保证,原因在于调用await()方法的线程必定是获取了锁的线程,也就是说该过程是由锁来保证线程安全的。
await()
- 调用Condition的await()方法(或者以await开头的方法),会使当前线程进入等待队列并释放锁,同时线程状态变为等待状态。
- 当从await()方法返回时,当前线程一定获取了Condition相关联的锁。
- 如果从队列(同步队列和等待队列)的角度看await()方法,当调用await()方法时,相当于同步队列的首节点(获取了锁的节点)移动到Condition的等待队列中。
- 然后释放同步状态,唤醒同步队列中的后继节点,然后当前线程会进入等待状态。(同步队列的首节点并不会直接加入等待队列,而是通addConditionWaiter()方法把当前线程构造成一个新的节点并将其加入等待队列中。)
- 当等待队列中的节点被唤醒,则唤醒节点的线程开始尝试获取同步状态。如果不是通过其他线程调用Condition.signal()方法唤醒,而是对等待线程进行中断,则会抛出InterruptedException。
- 当从await()方法返回时,当前线程一定获取了Condition相关联的锁。
- 如果从队列(同步队列和等待队列)的角度看await()方法,当调用await()方法时,相当于同步队列的首节点(获取了锁的节点)移动到Condition的等待队列中。
- 然后释放同步状态,唤醒同步队列中的后继节点,然后当前线程会进入等待状态。(同步队列的首节点并不会直接加入等待队列,而是通addConditionWaiter()方法把当前线程构造成一个新的节点并将其加入等待队列中。)
- 当等待队列中的节点被唤醒,则唤醒节点的线程开始尝试获取同步状态。如果不是通过其他线程调用Condition.signal()方法唤醒,而是对等待线程进行中断,则会抛出InterruptedException。
signal
- 调用Condition的signal()方法,将会唤醒在等待队列中等待时间最长的节点(首节点),在唤醒节点之前,会将节点移到同步队列中。
- 通过调用同步器的enq(Node node)方法,等待队列中的头节点线程安全地移动到同步队列。当节点移动到同步队列后,当前线程再使用LockSupport唤醒该节点的线程。
- 被唤醒后的线程,将从await()方法中的while循环中退出(isOnSyncQueue(Node node)方法返回true,节点已经在同步队列中),进而调用同步器的acquireQueued()方法加入到获取同步状态的竞争中。
- 成功获取同步状态(或者说锁)之后,被唤醒的线程将从先前调用的await()方法返回,此时该线程已经成功地获取了锁。
- Condition的signalAll()方法,相当于对等待队列中的每个节点均执行一次signal()方法,效果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程。
- 通过调用同步器的enq(Node node)方法,等待队列中的头节点线程安全地移动到同步队列。当节点移动到同步队列后,当前线程再使用LockSupport唤醒该节点的线程。
- 被唤醒后的线程,将从await()方法中的while循环中退出(isOnSyncQueue(Node node)方法返回true,节点已经在同步队列中),进而调用同步器的acquireQueued()方法加入到获取同步状态的竞争中。
- 成功获取同步状态(或者说锁)之后,被唤醒的线程将从先前调用的await()方法返回,此时该线程已经成功地获取了锁。
- Condition的signalAll()方法,相当于对等待队列中的每个节点均执行一次signal()方法,效果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程。
CountDownLatch
CountDownLatch是JDK提供的一个同步工具,它可以让一个或多个线程等待,一直等到其他线程中执行完成一组操作。
await()
当调用await方法时,如果计数器大于0时,线程会被阻塞,一直到计数器被countDown方法减到0时,线程才会继续执行
countDown()
CountDownLatch在初始化时,需要指定用给定一个整数作为计数器。当调用countDown方法时,计数器会被减1;
wait() notify() notifyAll()
wait()
同步代码块使用 释放锁
notify()
唤醒一个等待在锁上的线程, 但是不会释放锁, 等到同步代码块结束才会释放锁
notifyAll()
唤醒所有等待在锁上的线程, 但是不会释放锁, 等到同步代码块结束才会释放锁
LockSupport
park()
当前线程阻塞, 无需上锁, wait() 必须在同步代码块中才可以
unpark()
- 传入线程对象参数, 就可以指定线程停止阻塞继续运行
- unpark()可以先于park()调用, 并且可以让park()阻塞取消
- unpark()可以先于park()调用, 并且可以让park()阻塞取消
是为了代替之前的 suspend() resume() 因为之前的这两个方法容易产生死锁
semaphore
permit
传入的值代表同时获得锁的线程数量 , permit=1相当于mutex
acquire()
阻塞方法, 将信号量的值-1, 不能-1就阻塞
release()
将信号量的值+1
乐观锁与悲观锁
乐观锁
CAS
悲观锁
synchronized
公平锁与非公平锁
公平锁
新来等待锁的线程检查等待队列是否有线程, 有就排在等待队列的最后
new ReentrantLock(true)
非公平锁
新来等待锁的线程不检查同步队列, 直接抢锁, 抢到了就获得了锁
死锁与活锁
锁升级原理
无锁
偏向锁
对象markword记录指向当前拿到锁的线程
轻量级锁
产生竞争, markword记录指向拥有锁的线程栈中锁记录
重量级锁
竞争加剧, markword记录指向mutex互斥锁的指针, 竞争线程进入锁的等待队列不消耗CPU
并发容器
ConcurrentHashMap
Node数组+链表+红黑树结构
采用CAS + synchronized实现更加细粒度的锁。
锁的级别控制在了更细粒度的哈希桶数组元素级别,也就是说只需要锁住这个链表头节点(红黑树的根节点),就不会影响其他的哈希桶数组元素的读写,大大提高了并发度
锁的级别控制在了更细粒度的哈希桶数组元素级别,也就是说只需要锁住这个链表头节点(红黑树的根节点),就不会影响其他的哈希桶数组元素的读写,大大提高了并发度
CopyOnWriteArrayList
在CopyOnWriteArrayList 中,写入将导致创建整个底层数组的副本,而源数组将保留在原地,使得复制的数组在被修改时,读取操作可以安全地执行。
ThreadLocal
BlockingQueue
线程池
手写线程池
Executors
newSingleThreadExecutor
使用单个worker线程的Executor
corePoolSize和maximumPoolSize被设置为1。
SingleThreadExecutor使用无界队列LinkedBlockingQueue作为线程池的工作队列
如果当前运行的线程数少于corePoolSize(即线程池中无运行的线程),则创建一个新线程来执行任务
corePoolSize和maximumPoolSize被设置为1。
SingleThreadExecutor使用无界队列LinkedBlockingQueue作为线程池的工作队列
如果当前运行的线程数少于corePoolSize(即线程池中无运行的线程),则创建一个新线程来执行任务
newFixedThreadPool
corePoolSize和maximumPoolSize都被设置为创建FixedThreadPool时指定的参数nThreads
keepAliveTime设置为0L,意味着多余的空闲线程会被立即终止
如果当前运行的线程数少于corePoolSize,则创建新线程来执行任务。
FixedThreadPool使用无界队列LinkedBlockingQueue作为线程池的工作队列(队列的容量为Integer.MAX_VALUE)
keepAliveTime设置为0L,意味着多余的空闲线程会被立即终止
如果当前运行的线程数少于corePoolSize,则创建新线程来执行任务。
FixedThreadPool使用无界队列LinkedBlockingQueue作为线程池的工作队列(队列的容量为Integer.MAX_VALUE)
newCachedThreadPool
根据需要创建新线程的线程池
corePoolSize被设置为0,即corePool为空
maximumPoolSize被设置为Integer.MAX_VALUE,即maximumPool是无界的。
keepAliveTime设置为60L,意味着CachedThreadPool中的空闲线程等待新任务的最长时间为60秒
CachedThreadPool使用没有容量的SynchronousQueue作为线程池的工作队列,但CachedThreadPool的maximumPool是无界的。这意味着,如果主线程提交任务的速度高于maximumPool中线程处理任务的速度时,CachedThreadPool会不断创建新线程。极端情况下,CachedThreadPool会因为创建过多线程而耗尽CPU和内存资源。
corePoolSize被设置为0,即corePool为空
maximumPoolSize被设置为Integer.MAX_VALUE,即maximumPool是无界的。
keepAliveTime设置为60L,意味着CachedThreadPool中的空闲线程等待新任务的最长时间为60秒
CachedThreadPool使用没有容量的SynchronousQueue作为线程池的工作队列,但CachedThreadPool的maximumPool是无界的。这意味着,如果主线程提交任务的速度高于maximumPool中线程处理任务的速度时,CachedThreadPool会不断创建新线程。极端情况下,CachedThreadPool会因为创建过多线程而耗尽CPU和内存资源。
newScheduledThreadPool
DelayQueue是一个无界队列
1. 当调用ScheduledThreadPoolExecutor的scheduleAtFixedRate()方法或者scheduleWith-FixedDelay()方法时,会向ScheduledThreadPoolExecutor的DelayQueue添加一个实现了RunnableScheduledFutur接口的ScheduledFutureTask。
2. 线程池中的线程从DelayQueue中获取ScheduledFutureTask,然后执行任务。
1. 当调用ScheduledThreadPoolExecutor的scheduleAtFixedRate()方法或者scheduleWith-FixedDelay()方法时,会向ScheduledThreadPoolExecutor的DelayQueue添加一个实现了RunnableScheduledFutur接口的ScheduledFutureTask。
2. 线程池中的线程从DelayQueue中获取ScheduledFutureTask,然后执行任务。
ThreadPoolExecutor
corePoolSize
核心线程数,线程数定义了最小可以同时运行的线程数量。
maximumPoolSize
线程池中允许存在的工作线程的最大数量
KeepAliveTime
线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁;
unit
keepAliveTime 参数的时间单位。
workQueue
当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,任务就会被存放在队列中。
ArrayBlockingQueue:是一个基于数组结构的有界阻塞队列,此队列按FIFO(先进先出)原则对元素进行排序。
LinkedBlockingQueue:一个基于链表结构的无界阻塞队列,此队列按FIFO排序元素,吞吐量通常要高于ArrayBlockingQueue。静态工厂方法Executors.newFixedThreadPool()使用了这个队列。
SynchronousQueue:一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于Linked-BlockingQueue,静态工厂方法Executors.newCachedThreadPool使用了这个队列。
PriorityBlockingQueue:一个具有优先级的无限阻塞队列。
threadFactory
不使用默认的DefaultThreadFactory
名字没有辨识度 都是 Thread-Pool-number
用于设置创建线程的工厂,可以通过线程工厂给每个创建出来的线程设置更有意义的名字。
handler
线程池任务队列超过 maxinumPoolSize 之后的拒绝策略
- ThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。
- ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程运行任务. 不处理新任务, 但是这种策略会降低对于新任务提交速度,影响程序的整体性能。另外,这个策略喜欢增加队列容量。如果您的应用程序可以承受此延迟并且你不能任意丢弃任何一个任务请求的话,你可以选择这个策略。
- ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。
- ThreadPoolExecutor.DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求。
- ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程运行任务. 不处理新任务, 但是这种策略会降低对于新任务提交速度,影响程序的整体性能。另外,这个策略喜欢增加队列容量。如果您的应用程序可以承受此延迟并且你不能任意丢弃任何一个任务请求的话,你可以选择这个策略。
- ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。
- ThreadPoolExecutor.DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求。
线程池的实现原理
线程池的处理流程
1. 判断corePoolSize
1)线程池判断核心线程池里的线程是否都在执行任务。如果不是,则创建一个新的工作线程来执行任务。如果核心线程池里的线程都在执行任务,则进入下个流程。
2. 判断BlockingQueue
2)线程池判断工作队列是否已经满。如果工作队列没有满,则将新提交的任务存储在这个工作队列里。如果工作队列满了,则进入下个流程。
3. 判断maximumPoolSize
3)线程池判断线程池的线程是否都处于工作状态。如果没有,则创建一个新的工作线程来执行任务。如果已经满了,则交给饱和策略来处理这个任务。
向线程池提交任务
execute()方法用于提交不需要返回值的任务,
execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功。execute()方法输入的任务是一个Runnable类的实例。
submit()方法用于提交需要返回值的任务。
- 线程池会返回一个future类型的对象,通过这个future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值,
- get()方法会阻塞当前线程直到任务完成,而使用get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。
- 线程池会返回一个future类型的对象,通过这个future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值,
- get()方法会阻塞当前线程直到任务完成,而使用get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。
关闭线程池
shutdown
首先将线程池的状态设置成STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表
shutdownNow
将线程池的状态设置成SHUTDOWN状态,然后中断所有没有正在执行任务的线程。
- 只要调用了这两个关闭方法中的任意一个,isShutdown方法就会返回true。
- 当所有的任务都已关闭后,才表示线程池关闭成功,这时调用isTerminaed方法会返回true。
- 至于应该调用哪一种方法来关闭线程池,应该由提交到线程池的任务特性决定,通常调用shutdown方法来关闭线程池,如果任务不一定要执行完,则可以调用shutdownNow方法
- 当所有的任务都已关闭后,才表示线程池关闭成功,这时调用isTerminaed方法会返回true。
- 至于应该调用哪一种方法来关闭线程池,应该由提交到线程池的任务特性决定,通常调用shutdown方法来关闭线程池,如果任务不一定要执行完,则可以调用shutdownNow方法
阿里巴巴规约
《阿里巴巴Java开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险
Executors 各个方法的弊端:
- newFixedThreadPool 和 newSingleThreadExecutor:
主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至 OOM。
- newCachedThreadPool 和 newScheduledThreadPool:
主要问题是线程数最大数是 Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至 OOM。
- newFixedThreadPool 和 newSingleThreadExecutor:
主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至 OOM。
- newCachedThreadPool 和 newScheduledThreadPool:
主要问题是线程数最大数是 Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至 OOM。
ThreaPoolExecutor创建线程池方式只有一种,就是走它的构造函数,参数自己指定
线程池都有哪些状态?
- RUNNING:这是最正常的状态,接受新的任务,处理等待队列中的任务。
- SHUTDOWN:不接受新的任务提交,但是会继续处理等待队列中的任务。
- STOP:不接受新的任务提交,不再处理等待队列中的任务,中断正在执行任务的线程。
- TIDYING:所有的任务都销毁了,workCount 为 0,线程池的状态在转换为 TIDYING 状态时,会执行钩子方法 terminated()。
- TERMINATED:terminated()方法结束后,线程池的状态就会变成这个。
- SHUTDOWN:不接受新的任务提交,但是会继续处理等待队列中的任务。
- STOP:不接受新的任务提交,不再处理等待队列中的任务,中断正在执行任务的线程。
- TIDYING:所有的任务都销毁了,workCount 为 0,线程池的状态在转换为 TIDYING 状态时,会执行钩子方法 terminated()。
- TERMINATED:terminated()方法结束后,线程池的状态就会变成这个。
Executor VS ExecutorService VS Executors
Executor vs ExecutorService
1. ExecutorService 接口继承了 Executor 接口,是 Executor 的子接口
2. Executor 接口定义了 execute()方法用来接收一个Runnable接口的对象,而 ExecutorService 接口中的 submit()方法可以接受Runnable和Callable接口的对象。
3. Executor 中的 execute() 方法不返回任何结果,而 ExecutorService 中的 submit()方法可以通过一个 Future 对象返回运算结果。
4. 除了允许客户端提交一个任务,ExecutorService 还提供用来控制线程池的方法。比如:调用 shutDown() 方法终止线程池。
1. ExecutorService 接口继承了 Executor 接口,是 Executor 的子接口
2. Executor 接口定义了 execute()方法用来接收一个Runnable接口的对象,而 ExecutorService 接口中的 submit()方法可以接受Runnable和Callable接口的对象。
3. Executor 中的 execute() 方法不返回任何结果,而 ExecutorService 中的 submit()方法可以通过一个 Future 对象返回运算结果。
4. 除了允许客户端提交一个任务,ExecutorService 还提供用来控制线程池的方法。比如:调用 shutDown() 方法终止线程池。
Executors 类提供工厂方法用来创建不同类型的线程池。
原子类
解决ABA问题的原子类
- AtomicMarkableReference(通过引入一个 boolean来反映中间有没有变过),
- AtomicStampedReference(通过引入一个 int 来累加来反映中间有没有变过)
atomic原理
CAS
LongAdder
类似于分段锁, 将值放到数组里, 例如四个位置,分成了4个锁, 不同的线程去锁不同的位置, 这样同时就可以有4个线程的递增可以执行, 最后把这些数组内的数求和得到的就是最终的结果,
在高并发的情况下有用, 因为总的递增次数是一样的但是并行度变成了原来数组长度的倍数, 效率在高并发的情况下更好
在高并发的情况下有用, 因为总的递增次数是一样的但是并行度变成了原来数组长度的倍数, 效率在高并发的情况下更好
编译器
JIT
Just In Time Compiler 即时编译器
JVM是解释执行, 来一条指令, 解释成机器码执行
对于热点代码, 解释成机器码, 下次再遇到就不需要解释, 直接执行即可, 增加了效率
JVM是解释执行, 来一条指令, 解释成机器码执行
对于热点代码, 解释成机器码, 下次再遇到就不需要解释, 直接执行即可, 增加了效率
https://blog.csdn.net/qq_35190492/article/details/104691668?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522162920107616780255255020%2522%252C%2522scm%2522%253A%252220140713.130102334..%2522%257D&request_id=162920107616780255255020&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~top_positive~default-2-104691668.pc_search_download_positive&utm_term=AQS&spm=1018.2226.3001.4187
0 条评论
下一页