-
线程有哪些状态?
6种 https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/tooldescr034.html
NEW 创建 尚未启动
RUNNABLE 正在运行
BLOCKED 阻塞,等待被监视器锁定
WAITING 无限期等待另一个线程执行特定操作
TIMED_WAITING 线程正在等待另一个线程执行最多指定等待时间的操作。
TERMIMNATED 线程已经退出。 -
创建线程有哪几种方式?
继承Thread类
实现Runable接口
通过Callable和Future创建线程
他们底层都是实现runable接口 -
守护线程是什么?
专门用于服务其他的线程,如果其他的线程(即用户自定义线程)都执行完毕,连main线程也执行完毕,那么jvm就会退出(即停止运行)——此时,连jvm都停止运行了,守护线程当然也就停止执行了。 -
sleep() 和 wait() 有什么区别?
(1)sleep()不释放同步锁,wait()释放同步锁
(2)sleep(milliseconds)可以用时间指定来使他自动醒过来,如果时间不到你只能调用interreput()来强行打断;wait()可以用notify()直接唤起.
(3)sleep是Thread类的静态方法。wait是Object的方法,也就是说可以对任意一个对象调用wait方法,调用wait方法将会将调用者的线程挂起,直到其他线程调用同一个对象的notify方法才会重新激活调用者, -
notify(), notifyAll(),wait(),sleep()有什么区别?
notify():唤醒一个处于等待状态的线程,注意的是在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程。
notifyAll():唤醒所有处入等待状态的线程,注意并不是给所有唤醒线程一个对象的锁,而是让它们竞争。
wait():使一个线程处于等待状态,并且释放所持有的对象的lock。
sleep():使一个正在运行的线程处于睡眠状态,是一个静态方法,调用此方法要捕捉InterruptedException异常。不会释放锁 -
创建线程池有哪几种方式?
(1)创建大小不固定的线程池
(2)创建固定数量线程的线程池
(3)创建单线程的线程池
(4)创建定时线程 -
线程池都有哪些状态?
5种
RUNNING 运行状态 能接收也能处理
SHUTDOWN 不接受 但能处理
STOP 不接受新任务,直接中断当前任务
TIDYING 所有任务执行完毕,线程任务数量为0
TERMINATED 线程池终止 -
线程池中 submit()和 execute()方法有什么区别?
1、接收的参数不一样
2、submit有返回值,而execute没有
3、submit方便Exception处理 -
产生死锁的四个必要条件:
死锁: 是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。
(1) 互斥条件:一个资源每次只能被一个进程使用。
(2)请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
(3)不剥夺条件:进程已获得的资源,在未使用完之前,不能强行剥夺。
(4) 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。 -
如何预防死锁?
设置加锁顺序:多个线程需要相同的一些锁,按照同样的顺序,去获得锁
设置加锁时限:在尝试获取锁的时候加一个超时时间,也就是在尝试获取锁的过程中如果超过了这个时限该线程则放弃对该锁请求。如果一个线程没有在给定的时限内成功获得所有需要的锁,则会进行回退并释放所有已经获得的锁,然后等待一段随机的时间再重试。
开启死锁检测:每当一个线程获得了锁,会在线程和锁相关的数据结构中(map、graph等等)将其记下。除此之外,每当有线程请求锁,也需要记录在这个数据结构中。当一个线程请求锁失败时,这个线程可以遍历锁的关系图看看是否有死锁发生。 -
ThreadLocal 是什么?有哪些使用场景?
ThreadLocal 是线程本地存储,在每个线程中都创建了一个 ThreadLocalMap 对象,每个线程可以访问自己内部 ThreadLocalMap 对象内的 value。
经典的使用场景是为每个线程分配一个 JDBC 连接 Connection。这样就可以保证每个线程的都在各自的 Connection 上进行数据库的操作,不会出现 A 线程关了 B线程正在使用的 Connection; 还有 Session 管理 等问题 -
什么是线程安全
当多个线程访问一个类,无论采用任何调度方式,或者无论这些线程如何交替执行,在主调代码中不需要任何额外的同步这个类都能表现出正确的行为,那么这个类就是线程安全的
线程安全问题都是由全局变量及静态变量引起的。 -
如何在两个线程间共享数据
1,如果每个线程执行的代码相同,可以使用同一个Runnable对象,这个Runnable对象中有那个共享数据.
2,如果每个线程执行的代码不同,这时候需要用不同的Runnable对象
解决方案:
(1):将共享数据封装成另外一个对象,然后将这个对象逐一传递给各个Runnable对象,每个线程对共享数据的操作方法也分配到那个对象身上完成,这样容易实现针对数据进行各个操作的互斥和通信
(2):将Runnable对象作为一个类的内部类,共享数据作为这个类的成员变量,每个线程对共享数据的操作方法也封装在外部类,以便实现对数据的各个操作的同步和互斥,作为内部类的各个Runnable对象调用外部类的这些方法。 -
进程间如何通讯?
共享内存:共享内存就是分配一块能被其他进程访问的内存。共享内存可以说是最有用的进程间通信方式,也是最快的IPC形式。
管道 :管道传递数据是单向性的,只能从一方流向另一方,也就是一种半双工的通信方式;只用于有亲缘关系的进程间的通信,亲缘关系也就是父子进程或兄弟进程;
套接字(socket ) : 套接口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同设备及其间的进程通信。 -
请说出你所知的线程同步的方法
1、同步方法:有synchronized关键字修饰的方法。 注: synchronized关键字也可以修饰静态方法,此时如果调用该静态方法,将会锁住整个类。
2、同步代码块 :即有synchronized关键字修饰的语句块。
3、wait与notify
4、使用特殊域变量(volatile)实现线程同步
5、使用重入锁实现线程同步 ReentrantLock类是可重入、互斥、实现了Lock接口的锁,
6、使用局部变量实现线程同步 如果使用ThreadLocal管理变量,则每一个使用该变量的线程都获得该变量的副本,
副本之间相互独立,这样每一个线程都可以随意修改自己的变量副本,而不会对其他线程产生影响。 -
线程安全在三个方面体现
(1)原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作,(atomic,synchronized);
(2)可见性:一个线程对主内存的修改可以及时地被其他线程看到,(synchronized,volatile);
(3)有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序,。 -
synchronized 和 Lock 有什么区别?
synchronized :
Java的关键字,在jvm层面上
锁状态: 无法判断
锁类型: 可重入 不可中断 非公平
synchronized关键字不能继承。
JDK1.6 对synchronize加入了很多优化措施,有自适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等等。
Lock:
Lock接口:实现类ReentrantLock(可重入锁)
ReadWriteLock接口 : 实现类ReentrantReadWriteLock( ReadLock (实现Lock接口) , WriteLock (实现Lock接口) )
必须在finally中释放锁,不然容易造成线程死锁
锁状态: 可以判断
锁类型: 可重入 可中断 可公平(默认非公平) -
Lock接口的主要方法
lock():获取锁,如果锁被暂用则一直等待
unlock():释放锁
tryLock(): 注意返回类型是boolean,如果获取锁的时候锁被占用就返回false,否则返回true
tryLock(long time, TimeUnit unit):比起tryLock()就是给了一个时间期限,保证等待参数时间 -
解释以下名词:可重入锁,自旋锁,阻塞锁,乐观锁/悲观锁,偏向锁,轻量级锁,公平锁/非公平锁。
(1)重入锁:(ReentrantLock)是一种递归无阻塞的同步机制。重入锁,也叫做递归锁,指的是同一线程 外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受影响。在JAVA环境下 ReentrantLock 和synchronized都是 可重入锁。
(2)自旋锁:采用让当前线程不停的在循环体内执行实现,当循环的条件被其它线程改变时才能进入临界区所以响应速度更快。但当线程数不停增加时,性能下降明显,因为每个线程都需要执行,占用CPU时间。
(3)阻塞锁:让线程进入阻塞状态进行等待,当获得相应的信号(唤醒,时间) 时,才可以进入线程的准备就绪状态,准备就绪状态的所有线程,通过竞争,进入运行状态。
(4)乐观锁/悲观锁
悲观锁(Pessimistic Lock): 每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁。
乐观锁(Optimistic Lock): 每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据。乐观锁适用于多读的应用类型,这样可以提高吞吐量,使用CAS来保证,保证这个操作的原子性
(5)偏向锁: 它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。
如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。
(7)轻量级锁 “轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。
(8)公平锁/非公平锁 加锁前先查看是否有排队等待的线程,有的话优先处理排在前面的线程,先来先得。 -
线程状态,BLOCKED 和 WAITING 有什么区别
BLOCKED :阻塞 在等待monitor锁
WAITING:挂起 主动挂起,等待唤醒 -
提交任务时,线程池队列已满时会发会生什么?
要看拒绝策略 有四种策略
1,直接抛出异常(默认);
2,用调用者所在的线程执行任务;
3,丢弃阻塞队列中靠最前的任务;
4,直接丢弃 -
线程池的关闭方式有几种,各自的区别是什么
shutdown()方法在终止前 允许执行以前提交的任务。
shutdownNow() 方法则是阻止正在任务队列中等待任务的启动并试图停止当前正在执行的任务,返回要停止的任务List。
awaitTermination方法 这个方法有两个参数,一个是timeout即超时时间,另一个是unit即时间单位。这个方法会使线程等待timeout时长,当超过timeout时间后,会监测ExecutorService是否已经关闭,若关闭则返回true,否则返回false。 -
Java中活锁和死锁有什么区别?
由于两个线程互相等待对方的资源而被阻塞,这是死锁
饥饿 指的线程无法访问到它需要的资源而不能继续执行时,引发饥饿最常见资源就是CPU时钟周期。
活锁指的是线程不断重复执行相同的操作,但每次操作的结果都是失败的。尽管这个问题不会阻塞线程,但是程序也无法继续执行。 -
Java中用到的线程调度算法是什么
线程调度模式分为两种——抢占式调度和协同式调度。 Java使用的线程调度是抢占式调度
抢占式调度指的是每条线程执行的时间、线程的切换都由系统控制,系统控制指的是在系统某种运行机制下,可能每条线程都分同样的执行时间片,也可能是某些线程执行的时间片较长,甚至某些线程得不到执行的时间片。在这种机制下,一个线程的堵塞不会导致整个进程堵塞。
协同式调度指某一线程执行完后主动通知系统切换到另一线程上执行,这种模式就像接力赛一样,一个人跑完自己的路程就把接力棒交接给下一个人,下个人继续往下跑。线程的执行时间由线程本身控制,线程切换可以预知,不存在多线程同步问题,但它有一个致命弱点:如果一个线程编写有问题,运行到一半就一直堵塞,那么可能导致整个系统崩溃。 -
怎么检测一个线程是否拥有锁
在java.lang.Thread中有一个方法叫holdsLock(),它返回true如果当且仅当当前线程拥有某个具体对象的锁。 -
说一下 runnable 和 callable 有什么区别?
一个有返回值,一个没有。callable 会发生的问题是 其实就是我们可以通过get方法来获得线程的返回值,但是,在我们调用get的时候,如果线程尚未返回,那么调用者线程将会被阻塞,直到获取返回值 -
如何停止一个正在运行的线程?
this.stop()方法结束线程。 -
join()方法 和 join(number)方法
Thread类中的join方法的主要作用就是同步,它可以使得线程之间的并行执行变为串行执行。
join的意思是使得放弃当前线程的执行,等待指定的线程执行完毕
join(number)方法 最多等待多长时间,超过了就执行本线程
join方法是通过调用线程的wait方法来达到同步的目的的 -
说一下 jvm 的主要组成部分?及其作用?
类加载器(ClassLoader)
运行时数据区(Runtime Data Area)
执行引擎(Execution Engine)
本地库接口(Native Interface)
首先通过类加载器(ClassLoader)会把 Java 代码转换成字节码,运行时数据区(Runtime Data Area)再把字节码加载到内存中,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。 -
说一下 JVM 运行时数据区?(5个)
程序计数器: 用于存储下一条需要执行的字节码指令的地址,每个线程都有独立的程序计数器。
虚拟机栈: 用于存放局部变量,存放基本类型的变量数据和对象的引用,虚拟机栈的生命周期和线程相同.
本地栈: 本地方法栈是执行native方法。
java堆:: 此区域是jvm中最为复杂也是最大的一块的区域,首先堆分为老年代和新生代,新生代又分为Eden区和两个survivor区。
方法区: 存放了所加载的类的信息(名称、修饰符等)、类中的静态变量、类中定义为final类型的常量、类中的Field信息、类中的方法信息, -
说一下堆栈的区别?
功能方面:堆是用来存放对象的,栈是用来执行程序的(本地变量)。
共享性:堆是线程共享的,栈是线程私有的。
空间大小:堆大小远远大于栈。 -
怎么判断对象是否可以被回收?
一般有两种方法来判断:
引用计数器:为每个对象创建一个引用计数,有对象引用时计数器 +1,引用被释放时计数 -1,当计数器为 0 时就可以被回收。它有一个缺点不能解决循环引用的问题;
可达性分析:从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是可以被回收的。 -
垃圾回收算法
标记清除 先标记 标记完之后统一回收,效率不高,碎片过多
标记复制 内存分两块,每次只是用一块,一块内存用完了,将活着的对象复制到另外一块上,再把满的内存一次清理掉 简单高效,但空间利用率低
标记整理 把活着的对象移向另外一端,清理掉边界以外的内存
分代垃圾会后(不同的区会用不同的算法清理)
Young区用复制算法 (新的对象产生速度比较快,但周期比较短,需要及时回收)
Old区用标记清除或者标记整理 (对象周期比较长,) -
简述分代垃圾回收器是怎么工作的?
分代回收器有两个分区:老生代和新生代,新生代默认的空间占比总空间的 1/3,老生代的默认占比是 2/3。
分代回收器有两个分区:老生代和新生代,新生代默认的空间占比总空间的 1/3,老生代的默认占比是 2/3。
新生代使用的是复制算法,新生代里有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1,它的执行流程如下:
把 Eden + From Survivor 存活的对象放入 To Survivor 区;
清空 Eden 和 From Survivor 分区;
From Survivor 和 To Survivor 分区交换,From Survivor 变 To Survivor,To Survivor 变 From Survivor。
每次在 From Survivor 到 To Survivor 移动时都存活的对象,年龄就 +1,当年龄到达 15(默认配置是 15)时,升级为老生代。大对象也会直接进入老生代。
老生代当空间占用到达某个值之后就会触发全局垃圾收回,一般使用标记整理的执行算法。以上这些循环往复就构成了整个分代垃圾回收的整体执行流程。 -
说一下 JVM 调优的工具?
JDK 自带了很多监控工具,都位于 JDK 的 bin 目录下,其中最常用的是 jconsole 和 jvisualvm 这两款视图监控工具。
jconsole:用于对 JVM 中的内存、线程和类等进行监控;
jvisualvm:JDK 自带的全能分析工具,可以分析:内存快照、线程快照、程序死锁、监控内存的变化、gc 变化等。 -
常用的 JVM 调优的参数都有哪些?
(主要调整的是初始堆和堆最大内存调整为一样,避免反复GC)
-Xms2g:初始化推大小为 2g;
-Xmx2g:堆最大内存为 2g;
-XX:NewRatio=4:设置年轻的和老年代的内存比例为 1:4;
-XX:SurvivorRatio=8:设置新生代 Eden 和 Survivor 比例为 8:2;
–XX:+UseParNewGC:指定使用 ParNew + Serial Old 垃圾回收器组合;
-XX:+UseParallelOldGC:指定使用 ParNew + ParNew Old 垃圾回收器组合;
-XX:+UseConcMarkSweepGC:指定使用 CMS + Serial Old 垃圾回收器组合;
-XX:+PrintGC:开启打印 gc 信息;
-XX:+PrintGCDetails:打印 gc 详细信息。 -
说一下 jvm 有哪些垃圾回收器?
串行 : Serial SerialOld (单线程)
并行 : Parallel Scavenge ParallelOld (吞吐量 优先)
并发 CMS G1 (停顿时间优先) -
详细介绍一下 CMS 垃圾回收器?
CMS (并发)是英文 Concurrent Mark-Sweep 的简称,是以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器。对于要求服务器响应速度的应用上,这种垃圾回收器非常适合。在启动 JVM 的参数加上“-XX:+UseConcMarkSweepGC”来指定使用 CMS 垃圾回收器。
CMS 使用的是标记-清除的算法实现的,所以在 gc 的时候回产生大量的内存碎片,当剩余内存不能满足程序运行要求时,系统将会出现 Concurrent Mode Failure,临时 CMS 会采用 Serial Old 回收器进行垃圾清除,此时的性能将会被降低。 -
.java 中都有哪些引用类型?
强引用:发生 gc 的时候不会被回收。
软引用:有用但不是必须的对象,在发生内存溢出之前会被回收。
弱引用:有用但不是必须的对象,在下一次GC时会被回收。
虚引用(幽灵引用/幻影引用):无法通过虚引用获得对象,用 PhantomReference 实现虚引用,虚引用的用途是在 gc 时返回一个通知。 -
说一下 atomic 的原理?
基于CAS和关键字volatile (CAS并不是无阻塞,只是阻塞并非在语言、线程方面,而是在硬件层面) CAS(CAS需要有3个操作数:内存地址V,旧的预期值A,即将要更新的目标值B。) -
原子类分为哪几类?
基本类型: AtomicInteger, AtomicLong, AtomicBoolean ;
数组类型: AtomicIntegerArray, AtomicLongArray, AtomicReferenceArray ;
引用类型: AtomicReference, AtomicStampedRerence, AtomicMarkableReference ;
对象的属性修改类型: AtomicIntegerFieldUpdater, AtomicLongFieldUpdater, AtomicReferenceFieldUpdater 。 -
什么是ABA问题?ABA问题怎么解决?
如果内存地址V初次读取的值是A,并且在准备赋值的时候检查到它的值仍然为A,那我们就能说它的值没有被其他线程改变过了吗?
如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA”问题。Java并发包为了解决这个问题,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性。因此,在使用CAS前要考虑清楚“ABA”问题是否会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。 -
CAS的缺点:
循环时间长开销很大 : 如果CAS失败,会一直进行尝试。如果CAS长时间一直不成功,可能会给CPU带来很大的开销。
只能保证一个共享变量的原子操作。 当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁来保证原子性。
ABA问题。 -
同步器框架(AQS)
CountDownLatch同步辅助类: :通过它可以完成类似阻塞当前线程的功能 ,计数器只能被设置一次,每次减一
Semaphore(信号量 ):可以控制同一时间访问某个资源的个数 ,提供两个核心方法acquire(获取许可,没有就等待)和release(操作完成后释放许可)
CyclicBarrier: 回环栅栏 可以完成多个线程之间相互等待(与CountDownLatch区别),只有当每个线程都准备就绪后才能往下执行 也是使用计数器实现(计数器初始值为零.计数器可以重复使用 与CountDownLatch区别) -
JUC—并发集合类
1,CopyOnWriteArrayList : (ArrayList) 它实现了List接口,内部实现了ReentrantLock。
2,CopyOnWriteArraySet : (HashSet) 它继承于AbstractSet类 内部是通过“动态数组(CopyOnWriteArrayList)”实现的。
3,ConcurrentSkipListSet : (TreeSet) 它继承于AbstractSet,并实现了NavigableSet接口。基于ConcurrentSkipListMap实现的
4,ConcurrentSkipListMap: (TreeMap) ConcurrentSkipListMap是通过“跳表”来实现的
5,ConcurrentHashMap:(HashMap) 它继承于AbstractMap类,并且实现ConcurrentMap接口。ConcurrentHashMap是通过“锁分段”来实现的 -
JUC集合包中Queue的实现类
(1) ArrayBlockingQueue是数组实现的线程安全的有界的阻塞队列。
(2) LinkedBlockingQueue是单向链表实现的(指定大小)阻塞队列,该队列按 FIFO(先进先出)排序元素。
(3) LinkedBlockingDeque是双向链表实现的(指定大小)双向并发阻塞队列,该阻塞队列同时支持FIFO和FILO两种操作方式。
(4) ConcurrentLinkedQueue是单向链表实现的无界队列,该队列按 FIFO(先进先出)排序元素。
(5) ConcurrentLinkedDeque是双向链表实现的无界队列,该队列同时支持FIFO和FILO两种操作方式。 -
跳表和锁分段
跳表:链表加多级索引。 跳表能够实现二分查找的效率。
锁分段: ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。 -
JUC大概分为几类:
1) 原子类(Atomic )
2) 锁框架( locks )
3) 同步器框架 (AbstractQueuedSynchronizer)
4) 线程池(Executor)
5) 并发集合类,队列 -
线程池参数
coprePoolSize:核心线程数量 (有线程就放在里面执行,即便有线程是空闲的,也创建新的线程)
maximumPoolSize:最大线程数 (当workQueue满了才会创建新的线程执行)
workQueue:阻塞队列,存储等待执行的任务,线程池满的时候未执行的线程会放在workQueue中
keepAliveTime:线程没有任务执行时最多保持多久时间终止(核心线程中的线程空闲时间)
threadFactory:线程工厂,用来创建线程
rejectHandler:拒绝策略 workQueue满了.线程池满了,再有新线程提交(有四种策略1,直接抛出异常(默认);2,用调用者所在的线程执行任务;3,丢弃阻塞队列中靠最前的任务;4,直接丢弃) -
线程池执行流程 (先核心线程数,然后队列 ,最大线程数 拒绝策略)
提交一个任务,线程池里存活的核心线程数小于线程数corePoolSize时,线程池会创建一个核心线程去处理提交的任务。
如果线程池核心线程数已满,即线程数已经等于corePoolSize,一个新提交的任务,会被放进任务队列workQueue排队等待执行。
当线程池里面存活的线程数已经等于corePoolSize了,并且任务队列workQueue也满,判断线程数是否达到maximumPoolSize,即最大线程数是否已满,如果没到达,创建一个非核心线程执行提交的任务。
如果当前的线程数达到了maximumPoolSize,还有新的任务过来的话,直接采用拒绝策略处理。
参考:
https://blog.csdn.net/xy3233/article/details/93607956
https://blog.csdn.net/v123411739/article/details/79561458
https://blog.csdn.net/sihai12345/article/details/79465620
https://blog.csdn.net/jackfrued/article/details/44921941
https://blog.csdn.net/wus_shang/article/details/78587906
https://blog.csdn.net/zqz_zqz/article/details/70233767
https://blog.csdn.net/wangyangzhizhou/article/details/41122385