多线程&并发篇之下篇
master,这是我的小站 https://blog.study996.cn ,欢迎访问哦~~
31、CAS 的原理呢?
CAS 叫做 CompareAndSwap,比较并交换,主要是通过处理器的指令来保证操作的原子性,它包含三个操作数: 1. 变量内存地址,V 表示 2. 旧的预期值,A 表示 3. 准备设置的新值,B 表示 当执行 CAS 指令时,只有当 V 等于 A 时,才会用 B 去更新 V 的值,否则就不会执行更新操作。
32、CAS 有什么缺点吗?
CAS 的缺点主要有 3 点:
ABA 问题:ABA 的问题指的是在 CAS 更新的过程中,当读取到的值是 A,然后准备赋值的时候仍然是 A,但是实际上有可能 A 的值被改成了 B,然后又被改回了 A,这个 CAS 更新的漏洞就叫做 ABA。只是 ABA 的问题大部分场景下都不影响并发的最终效果。 Java 中有 AtomicStampedReference 来解决这个问题,他加入了预期标志和更新后标志两个字段,更新时不光检查值,还要检查当前的标志是否等于预期标志,全部相等的话才会更新。
循环时间长开销大:自旋 CAS 的方式如果长时间不成功,会给 CPU 带来很大的开销。
只能保证一个共享变量的原子操作:只对一个共享变量操作可以保证原子性,但是多个则不行,多个可以通过 AtomicReference 来处理或者使用锁 synchronized 实现。
33、引用类型有哪些?有什么区别?
引用类型主要分为强软弱虚四种: 1. 强引用指的就是代码中普遍存在的赋值方式,比如 A a = new A() 这种。强引用关联的对象,永远不会被 GC 回收。 2. 软引用可以用 SoftReference 来描述,指的是那些有用但是不是必须要的对象。系统在发生内存溢出前会对这类引用的对象进行回收。 3. 弱引用可以用 WeakReference 来描述,他的强度比软引用更低一点,弱引用的对象下一次 GC 的时候一定会被回收,而不管内存是否足够。 4. 虚引用也被称作幻影引用,是最弱的引用关系,可以用 PhantomReference 来描述,他必须和 ReferenceQueue 一起使用,同样的当发生 GC 的时候,虚引用也会被回收。可以用虚引用来管理堆外内存。
34、说说 ThreadLocal 原理?
hreadLocal 可以理解为线程本地变量,他会在每个线程都创建一个副本,那么在线程之间访问内部副本变量就行了,做到了线程之间互相隔离,相比于 synchronized 的做法是用空间来换时间。ThreadLocal 有一个静态内部类 ThreadLocalMap,ThreadLocalMap 又包含了一个 Entry 数组,Entry 本身是一个弱引用,他的 key 是指向 ThreadLocal 的弱引用,Entry 具备了保存 key value 键值对的能力。弱引用的目的是为了防止内存泄露,如果是强引用那么 ThreadLocal 对象除非线程结束否则始终无法被回收,弱引用则会在下一次 GC 的时候被回收。但是这样还是会存在内存泄露的问题,假如 key 和 ThreadLocal 对象被回收之后,entry 中就存在 key 为 null,但是 value 有值的 entry 对象,但是永远没办法被访问到,同样除非线程结束运行。但是只要 ThreadLocal 使用恰当,在使用完之后调用 remove 方法删除 Entry 对象,实际上是不会出现这个问题的。
35、线程池原理知道吗?以及核心参数
核心参数
-
最大线程数 maximumPoolSize
-
核心线程数 corePoolSize
-
活跃时间 keepAliveTime
-
阻塞队列 workQueue
-
拒绝策略 RejectedExecutionHandler
执行流程
-
当我们提交任务,线程池会根据 corePoolSize 大小创建若干任务数量线程执行任务
-
当任务的数量超过 corePoolSize 数量,后续的任务将会进入阻塞队列阻塞排队
-
当阻塞队列也满了之后,那么将会继续创建 (maximumPoolSize-corePoolSize) 个数量的线程来执行任务,如果任务处理完成,maximumPoolSize-corePoolSize 额外创建的线程等待 keepAliveTime 之后被自动销毁
-
如果达到 maximumPoolSize,阻塞队列还是满的状态,那么将根据不同的拒绝策略对应处理
36、线程池的拒绝策略有哪些?
主要有 4 种拒绝策略:
-
AbortPolicy:直接丢弃任务,抛出异常,这是默认策略
-
CallerRunsPolicy:只用调用者所在的线程来处理任务
-
DiscardOldestPolicy:丢弃等待队列中最旧的任务,并执行当前任务
-
DiscardPolicy:直接丢弃任务,也不抛出异常
37、说说你对 JMM 内存模型的理解?为什么需要 JMM?
随着 CPU 和内存的发展速度差异的问题,导致 CPU 的速度远快于内存,所以现在的 CPU 加入了高速缓存,高速缓存一般可以分为 L1、L2、L3 三级缓存。基于上面的例子我们知道了这导致了缓存一致性的问题,所以加入了缓存一致性协议,同时导致了内存可见性的问题,而编译器和 CPU 的重排序导致了原子性和有序性的问题,JMM 内存模型正是对多线程操作下的一系列规范约束,因为不可能让陈雇员的代码去兼容所有的 CPU,通过 JMM 我们才屏蔽了不同硬件和操作系统内存的访问差异,这样保证了 Java 程序在不同的平台下达到一致的内存访问效果,同时也是保证在高效并发的时候程序能够正确执行。
原子性:Java 内存模型通过 read、load、assign、use、store、write 来保证原子性操作,此外还有
lock 和 unlock,直接对应着 synchronized 关键字的 monitorenter 和 monitorexit 字节码指令。
可见性:可见性的问题在上面的回答已经说过,Java 保证可见性可以认为通过 volatile、
synchronized、final 来实现。
有序性:由于处理器和编译器的重排序导致的有序性问题,Java 通过 volatile、synchronized 来保证。
happen-before 规则
虽然指令重排提高了并发的性能,但是 Java 虚拟机会对指令重排做出一些规则限制,并不能让所有的指令都随意的改变执行位置,主要有以下几点:
-
单线程每个操作,happen-before 于该线程中任意后续操作
-
volatile 写 happen-before 与后续对这个变量的读
-
synchronized 解锁 happen-before 后续对这个锁的加锁
-
final 变量的写 happen-before 于final 域对象的读,happen-before 后续对final 变量的读
-
传递性规则,A 先于 B,B 先于 C,那么 A 一定先于 C 发生
说了半天,到底工作内存和主内存是什么?
主内存可以认为就是物理内存,Java 内存模型中实际就是虚拟机内存的一部分。而工作内存就是 CPU 缓存,他有可能是寄存器也有可能是 L1\L2\L3 缓存,都是有可能的。
38、多线程有什么用?
一个可能在很多人看来很扯淡的一个问题:我会用多线程就好了,还管它有什么用?在我看来,这个回答更扯淡。所谓"知其然知其所以然","会用"只是"知其然","为什么用"才是"知其所以然",只有达到"知其然知其所以然"的程度才可以说是把一个知识点运用自如。OK,下面说说我对这个问题的看法:
发挥多核 CPU 的优势
随着工业的进步,现在的笔记本、台式机乃至商用的应用服务器至少也都是双核的,4 核、8 核甚至 16 核的也都不少见,如果是单线程的程序,那么在双核 CPU 上就浪费了 50%,在 4 核 CPU 上就浪费了 75%。单核 CPU 上所谓的多线程那是假的多线程,同一时间处理器只会处理一段逻辑,只不过线程之间切换得比较快,看着像多个线程同时运行罢了。多核 CPU 上的多线程才是真正的多线 程,它能让你的多段逻辑同时工作,多线程,可以真正发挥出多核 CPU 的优势来,达到充分利用 CPU 的目的。
防止阻塞
从程序运行效率的角度来看,单核 CPU 不但不会发挥出多线程的优势,反而会因为在单核 CPU 上运行多线程导致线程上下文的切换,而降低程序整体的效率。但是单核 CPU 我们还是要应用多线程,就是为了防止阻塞。试想,如果单核 CPU 使用单线程,那么只要这个线程阻塞了,比方说远程读取某个数据吧,对端迟迟未返回又没有设置超时时间,那么你的整个程序在数据返回回来之前就停止运行了。多线程可以防止这个问题,多条线程同时运行,哪怕一条线程的代码执行读取数据阻塞,也不会影响其它任务的执行。
便于建模
这是另外一个没有这么明显的优点了。假设有一个大的任务 A,单线程编程,那么就要考虑很多,建立整个程序模型比较麻烦。但是如果把这个大的任务 A 分解成几个小任务,任务 B、任务 C、任务 D,分别建立程序模型,并通过多线程分别运行这几个任务,那就简单很多了。
39、说说 CyclicBarrier 和 CountDownLatch 的区别?
两个看上去有点像的类,都在 java.util.concurrent 下,都可以用来表示代码运行到某个点上,二者的区别在于:
(1)CyclicBarrier 的某个线程运行到某个点上之后,该线程即停止运行,直到所有的线程都到达了这个点,所有线程才重新运行;CountDownLatch 则不是,某线程运行到某个点上之后,只是给某个数值 -1 而已,该线程继续运行
(2)CyclicBarrier 只能唤起一个任务,CountDownLatch 可以唤起多个任务
(3)CyclicBarrier 可重用,CountDownLatch 不可重用,计数值为 0 该 CountDownLatch 就不可再用了
40、什么是 AQS?
简单说一下 AQS,AQS 全称为 AbstractQueuedSychronizer,翻译过来应该是抽象队列同步器。如果说 java.util.concurrent 的基础是 CAS 的话,那么 AQS 就是整个 Java 并发包的核心了,ReentrantLock、CountDownLatch、Semaphore 等等都用到了它。AQS 实际上以双向队列的形式连接所有的 Entry,比方说 ReentrantLock,所有等待的线程都被放在一个 Entry 中并连成双向队列,前面一个线程使用 ReentrantLock 好了,则双向队列实际上的第一个 Entry 开始运行。AQS 定义了对双向队列所有的操作,而只开放了 tryLock 和 tryRelease 方法给开发者使用,开发者可以根据自己的实现重写 tryLock 和 tryRelease 方法,以实现自己的并发功能。
41、了解 Semaphore 吗?
emaphore 就是一个信号量,它的作用是限制某段代码块的并发数。Semaphore 有一个构造函数,可以传入一个 int 型整数 n,表示某段代码最多只有 n 个线程可以访问,如果超出了 n,那么请等待,等到某个线程执行完毕这段代码块,下一个线程再进入。由此可以看出如果 Semaphore 构造函数中传入的 int 型整数 n=1,相当于变成了一个 synchronized 了。
42、什么是 Callable 和 Future?
Callable
Callable 接口类似于 Runnable,从名字就可以看出来了,但是 Runnable 不会返回结果,并且无法抛出返回结果的异常,而 Callable 功能更强大一些,被线程执行后,可以返回值,这个返回值可以被 Future 拿到,也就是说,Future 可以拿到异步执行任务的返回值。可以认为是带有回调的 Runnable。
Future
Future 接口表示异步任务,是还没有完成的任务给出的未来结果。所以说 Callable 用于产生结果,Future 用于获取结果。
43、什么是阻塞队列?阻塞队列的实现原理是什么?如何使用阻塞队列来实现生产者 - 消费者模型?
阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作是:在队列为空时,获取元素的线程会等待队列变为非空。当队列满时,存储元素的线程会等待队列可用。阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。 DK7 提供了 7 个阻塞队列。分别是: - ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列。 - LinkedBlockingQueue:一个由链表结构组成的有界阻塞队列。 - PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。 - DelayQueue:一个使用优先级队列实现的无界阻塞队列。 - SynchronousQueue:一个不存储元素的阻塞队列。 - LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。 - LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。
Java 5之前实现同步存取时,可以使用普通的一个集合,然后在使用线程的协作和线程同步可以实现生产者,消费者模式,主要的技术就是用好,wait ,notify,notifyAll,sychronized 这些关键字。而在 java 5 之后,可以使用阻塞队列来实现,此方式大大简少了代码量,使得多线程编程更加容易,安全方面也有保障。BlockingQueue 接口是 Queue 的子接口,它的主要用途并不是作为容器,而是作为线程同步的的工具,因此他具有一个很明显的特性,当生产者线程试图向 BlockingQueue 放入元素时,如果队列已满,则线程被阻塞,当消费者线程试图从中取出一个元素时,如果队列为空,则该线程会被阻塞,正是因为它所具有这个特性,所以在程序中多个线程交替向 BlockingQueue 中放入元素,取出元 素,它可以很好的控制线程之间的通信。阻塞队列使用最经典的场景就是 socket 客户端数据的读取和解析,读取数据的线程不断将数据放入队列,然后解析线程不断从队列取数据解析
44、什么是多线程中的上下文切换?
在上下文切换过程中,CPU 会停止处理当前运行的程序,并保存当前程序运行的具体位置以便之后继续运行。从这个角度来看,上下文切换有点像我们同时阅读几本书,在来回切换书本的同时我们需要记住每本书当前读到的页码。在程序中,上下文切换过程中的“页码”信息是保存在进程控制块(PCB)中的。PCB 还经常被称作“切换桢”(switchframe)。“页码”信息会一直保存到 CPU 的内存中,直到他们被再次使用。上下文切换是存储和恢复 CPU 状态的过程,它使得线程执行能够从中断点恢复执行。上下文切换是多任务操作系统和多线程环境的基本特征。
45、什么是 Daemon 线程?它有什么意义?
所谓后台 (daemon) 线程,也叫守护线程,是指在程序运行的时候在后台提供一种通用服务的线程,并且这个线程并不属于程序中不可或缺的部分。因此,当所有的非后台线程结束时,程序也就终止了,同时会杀死进程中的所有后台线程。反过来说,只要有任何非后台线程还在运行,程序就不会终止。必须在线程启动之前调用 setDaemon() 方法,才能把它设置为后台线程。注意:后台进程在不执行finally 子句的情况下就会终止其 run() 方法。比如:JVM 的垃圾回收线程就是 Daemon 线程,Finalizer 也是守护线程。
46、乐观锁和悲观锁的理解及如何实现,有哪些实现方式?
悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。再比如 Java 里面的同步原语 synchronized 关键字的实现也是悲观锁。
乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于 write_condition 机制,其实都是提供的乐观锁。在 Java 中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。
乐观锁的实现方式:
1、使用版本标识来确定读到的数据与提交时的数据是否一致。提交后修改版本标识,不一致时可以采取丢弃和再次尝试的策略。
2、java 中的 Compare and Swap 即 CAS,当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。CAS 操作中包含三个操作数 —— 需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值 (B)。如果内存位置 V 的值与预期原值 A 相匹配,那么处理器会自动将该位置值更新为新值 B。否则处理器不做任何操作。
CAS 缺点:
-
ABA 问题:比如说一个线程 one 从内存位置 V 中取出 A,这时候另一个线程 two 也从内存中取出 A,并且 two 进行了一些操作变成了 B,然后 two 又将 V 位置的数据变成 A,这时候线程 one 进行 CAS 操作发现内存中仍然是 A,然后 one 操作成功。尽管线程 one 的 CAS 操作成功,但可能存在潜藏的问题。从 Java1.5 开始 JDK 的 atomic 包里提供了一个类 AtomicStampedReference 来解决 ABA 问题。
-
循环时间长开销大:对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized。
-
只能保证一个共享变量的原子操作:当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁。