文章目录
- 三、Java并发
- 3.1 进程与线程的区别*
- 3.2 进程之间的通信方式*
- 3.3 多线程与单线程的关系*
- 3.4 多线程编程中常用的函数比较
- 3.5 线程活性故障(死锁、活锁、线程饥饿)
- 3.6 线程安全
- 3.7 synchronized 关键字
- 3.8 volatile关键字
- 3.9 Volatile 和 Synchronized的区别
- 3.10 ReentrantLock 和 Synchronized的区别
- 3.11 Java线程池
- 3.12 ThreadLocal
- 3.13 Atmoic
- 3.14 乐观锁&悲观锁
- 3.15 AQS
- 3.16 为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?
- 3.17 多线程带来的问题
- 3.18 什么是上下文切换?
- 3.19 程序计数器为什么是私有的?
- 3.20 虚拟机栈和本地方法栈为什么是私有的?
- 3.21 CPU缓存一致性 `MESI`
- 3.22 ThreadLocal 内存泄露问题
- 3.33 JUC 结构图
- 3.34 JUC原子类
三、Java并发
从Java 5.0开始,JDK中提供了java.util.concurrent
(简称JUC )包,在此包中增加了并发编程中常用的工具类,用于定义线程的自定义子系统,包括线程池、异步IO 和轻量级任务框架等。
3.1 进程与线程的区别*
-
根本区别
:进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位 -
资源开销
:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小。 -
包含关系
:如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。 -
内存分配
:同一进程的线程共享本进程的地址空间和资源,而进程之间的地址空间和资源是相互独立的 -
影响关系
:一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。 -
执行过程
:每个独立的进程有程序运行的入口、顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制,两者均可并发执行 -
线程上下文的切换比进程上下文切换要快得多
:
①进程切换时
,设计当前进程的CPU环境的保存和新被调度运行进程的CPU环境设置
②线程切换时
,仅需要保存和设置少量的寄存器内容,不涉及存储管理方面的操作
3.2 进程之间的通信方式*
管道/匿名管道(Pipes)
:用于具有亲缘关系的父子进程间或者兄弟进程之间的通信。
-信号(Signal)
:信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生;消息队列(Message Queuing)
:消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识。管道和消息队列的- 通信数据都是先进先出的原则。与管道(无名管道:只存在于内存中的文件;命名管道:存在于实际的磁盘介质或者文件系统)不同的是消息队列存放在内核中,只有在内核重启(即,操作系统重启)或者显示地删除一个消息队列时,该消息队列才会被真正的删除。消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取.比 FIFO 更有优势。消息队列克服了信号承载信息量少,管道只能承载无格式字 节流以及缓冲区大小受限等缺。信号量(Semaphores)
:信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步。这种通信方式主要用于解决与同步相关的问题并避免竞争条件。共享内存(Shared memory)
:使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。可以说这是最有用的进程间通信方式。套接字(Sockets)
: 此方法主要用于在客户端和服务器之间通过网络进行通信。套接字是支持 TCP/IP 的网络通信的基本操作单元,可以看做是不同主机之间的进程进行双向通信的端点,简单的说就是通信的两方的一种约定,用套接字中的相关函数来完成通信过程。
3.3 多线程与单线程的关系*
- 多线程是指在一个进程中,并发执行了多个线程,每个线程都实现了不同的功能
- 在单核CPU中,将CPU分为很小的时间片,在每一个时刻只能有一个线程在执行,是一种微观上轮流占用CPU的机制。由于CPU轮询的速度非常快,所以在宏观角度上看起来像是在“同一时间执行”。
- 多线程会在线程上下文切换,会导致程序执行速度变慢
- 多线程不会提高程序的执行速度,反而会降低速度,但对于用户来说,可以减少用户的等待响应时间,提高了资源的利用率
多线程编程中常用的函数比较:线程的状态有哪些
答:状态有新建状态
,运行状态
,阻塞等待状态
和消亡状态
。其中阻塞等待状态
又分为BLOCKED
, WAITING
和TIMED_WAITING
状态。查看Thread
类源码中关于线程状态的枚举定义:
public enum State {
NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED;
}
State | 说明 |
---|---|
NEW | 一个已经创建的线程,但是还没调用start方法启动的线程所处的状态。 |
RUNNABLE | 该状态包含两种可能:①有可能正在运行②正在等待CPU资源。当我们创建线程并且启动之后,就属于Runnable状态。 |
BLOCKED | 阻塞状态,当线程准备进入synchronized同步块 或同步方法 的时候,需要申请一个监视器锁 而进行的等待,会使线程进入BLOCKED状态。 |
WAITING | 该状态的出现是因为调用了Object.wait() 或者Thread.join() 或者LockSupport.park() 。处于该状态下的线程在等待另一个线程执行一些action来将其唤醒。 |
TIMED_WAITING | 该状态和WAITING状态其实是一样的,是不过其等待的时间是明确的。 |
TERMINATED | 线程执行结束了→run方法执行结束表示线程处于消亡状态了。 |
3.4 多线程编程中常用的函数比较
(1)wait() 和 sleep()
sleep
是Thread中的静态方法,当前线程将睡眠n毫秒,线程进入阻塞状态,当睡眠时间到了,会解除阻塞,进入可运行状态等待CPU,睡眠不释放锁(如果有的话)wait
方法是Object方法,必须与synchronized关键字一起使用。线程进入阻塞状态,当notify或者notifyall被调用后,才会解除阻塞,但只有重新占用互斥锁之后,才会进入可运行状态。睡眠时,会释放互斥锁。
(2)join()
当前线程调用,则其他线程全部停止,等待当前线程执行完毕,接着执行。
(3)yield()
当前线程调用,会使得线程放弃当前分配的CPU时间,但不会进入阻塞状态,即线程处于可执行状态,随时可获得CPU时间片。
3.5 线程活性故障(死锁、活锁、线程饥饿)
解释:由于资源的稀缺(如CPU,数据库连接等)或程序自身的问题导致线程一直处于非Runnable状态,并且其处理任务一直无法完成的现象被称为线程活性故障。常见的活性故障有死锁
、锁死
、活锁
、线程饥饿
。
(1) 线程死锁:多个线程之间相互等待对方而被永远暂停(处于非Runnable)。
①产生死锁的4个必要条件:
- 资源互斥:一个资源每次只能被一个线程使用。
- 请求与保持:一个线程因请求资源而阻塞时,对已获得的资源保持不释放。
- 不剥夺条件:线程以获得的资源,在使用完前,不能强行剥夺。
- 循环等待:若干个线程之间形成一种头尾相连接的循环等待资源关系。
②如何避免死锁?
- 破坏互斥条件 :这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。
- 破坏请求与保持条件 :一次性申请所有的资源。
- 破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
- 破坏循环等待条件 :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
(2)线程锁死:由于唤醒等待线程所需要的条件永远无法成立,或者其他线程无法唤醒这个线程而一直处于非运行状态(线程并未停止)导致任务一直无法进行。这种情况就被称为线程锁死。 其又分为两种:信号丢失锁死
和 嵌套监视器锁死
①信号丢失锁死:没有对应的通知线程来将等待线程唤醒,导致等待线程一直处于等待状态。
eg. 等待线程在执行Object.wait() / Condition.await()前没有对保护条件进行判断,而此时保护条件实际上可能已经成立,此后可能并无其他线程更新相应保护条件涉及的共享变量使其成立并通知等待线程。这就使得等待线程一直处于等待状态,从而使得其任务一直无法进展。
②嵌套监视器锁死:由于嵌套锁导致等待线程永远无法被唤醒。
eg. 一个线程只释放了内层锁Y.wait(),但没有释放外层锁X;但是通知线程必须先获得外层锁X,才可以通过Y.notifyAll()来唤醒等待线程,这就导致了嵌套等待现象。
(3)活锁:一种特殊的线程活性故障,当一个线程一直处于运行状态,但是其所执行的任务却没有任何进展成为活锁。eg.一个线程一直在申请所需要的资源,但是却无法申请成功。
(4)线程饥饿:指线程一直无法获得所需要的资源导致任务一直无法运行。(在线程的非公平调度下会出现)。
3.6 线程安全
- 原子性 : 一个的操作或者多次操作,要么所有的操作全部都得到执行并且不会收到任何因素的干扰而中断,要么所有的操作都执行,要么都不执行。synchronized 可以保证代码片段的原子性。
- 可见性 :当一个变量对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。volatile 关键字可以保证共享变量的可见性。
- 有序性 :代码在执行的过程中的先后顺序,Java 在编译器以及运行期间的优化,代码的执行顺序未必就是编写代码时候的顺序。volatile 关键字可以禁止指令进行重排序优化。
3.6.1 线程安全——原子性
(1)定义
原子性
:指对涉及到共享变量访问的操作,若该操作从执行线程以外的任意线程看是不可分割的,那么该操作就是原子操作,该操作具有原子性,即其他线程不会看到该操作执行了部分的中间结果。
eg:银行转账过程中,A账户减少100元,B增加100元,这两个动作是一个原子操作。我们不会看到A减少100元,但B不增加100元的中间过程。
(2)原子性实现方式
- 利用锁的排他性,保证同一时刻只有一个线程在操作一个共享变量
- 利用CAS(Compare And Swap)保证
- Java语言规范中,保证除了long和double型以外的任何变量操作都是原子操作
- Java语言规范中,volatile关键字修饰的变量可以保证其写操作是原子性
(3)其他
- 原子性针对的是多个线程的共享变量,所以对于局部变量来说不存在共享问题
- 单线程环境下讨论原子性无意义
- volatile关键字仅仅只能保证写操作的原子性,不保证复合操作,比如读写操作的原子性
3.6.2 线程安全——可见性
(1)定义
指一个线程的对于共享变量的更新,对于后续访问该变量的线程是否可见的问题。
(2)可见性如何保证
- 当前处理器需要刷新处理器缓存,使得其余处理器对变量所做的更新可以同步到当前处理器缓存中。
- 当前处理器对共享变量更新之后,需要刷新处理器缓存,使得该更新可以被写入处理器缓存中。
3.6.3 线程安全——有序性
(1)定义
指一个处理器上运行的线程所执行的内存访问操作在另外一个处理器上运行的线程来看是否有序的问题。
(2)重排序
为了提高程序执行的性能,Java编译器在其认为不影响程序正确性的前提下,可能对源代码顺序进行一定的调整,导致程序运行顺序和源代码中的顺序不一致。
重排序是对内存读写操作的一种优化,单线程下不会导致程序的正确性出现问题,但多线程下可能会影响程序的正确性。
eg:Instance instance = new Instance();
当new了一下后,①JVM会在堆内存上分配对象空间②在堆内存上初始化对象③设置instance指向刚分配的内存地址。
②和③可能会发生重排序,导致引用型变量指向一个不为null,但也不完整的对象。
多线程下的单例模式,我们必须通过volatile禁止重排序
3.7 synchronized 关键字
synchronized
是Java中的一个关键字,是一个内部锁
。synchronized 关键字解决的是多个线程之间访问资源的同步性
,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
(1)内部锁底层实现
- 进入时,执行monitorenter,将计数器+1,释放锁monitorexit时,计数器-1。
- 当一个线程判断到计数器为0时,则当前锁空闲,可以占用;反之,当前线程进入等待状态
总结: synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。
(2)synchronized内部锁对可见性的保证:
synchronized内部锁通过写线程冲刷处理器缓存和读线程刷新处理器缓存保证可见性。
- 获得锁之后,需要刷新处理器缓存,使得前面写线程所做的更新可以同步到本线程。
- 释放锁需要冲刷处理器缓存,使得当前线程对共享数据的改变可以被推送到下一个线程处理器的高速缓冲中。
(3)Synchronized内部锁对有序性的保证:
由于原子性和可见性的保证,使得写线程在临界区中所执行的一系列操作在读线程所执行的临界区看起来像是完全按照源代码顺序执行的,即保证了有序性。
(4) Synchronized的使用
synchronized 关键字加到 static 静态方法
和synchronized(class) 代码块
上都是是给 Class 类上锁。
synchronized 关键字加到实例方法
上是给对象实例上锁。
尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能!
(5) JDK1.6 之后的 synchronized 关键字底层做了哪些优化
- JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。
- 锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
扩展1:JVM对资源的调度方式分为公平调度和非公平调度
方式 | 说明 | 优点 | 缺点 |
---|---|---|---|
公平调度 | 按照申请的先后顺序授予资源的独占权 | 线程申请时间偏差较小;不会出现线程饥饿;适合在资源持有线程占用资源的时间相对较长或资源的平均申请时间间隔相对较长的情况下;或对资源申请所需时间偏差有所要求的情况下使用 | 吞吐率较低 |
非公平调度 | 某一资源的持有线程释放时,等待队列中的一个线程会被唤醒,而该线程从被唤醒到其继续执行可能需要一段时间,在该时间内,新来的线程(活跃线程)可以先被授予资源独占权 | 吞吐率较高;单位时间内可以为更多的申请者分配资源 | 资源申请者所需要的时间偏差可能较大,并可能出现线程饥饿的现象 |
扩展2:JVM对synchronized内部锁的调度
JVM对内部锁的调度方式为非公平调度。JVM会给每个内部锁分配一个入口集(Entry Set)
,用于记录等待获得相应内部锁的线程。当锁被持有的线程释放的时候,该锁的入口集中的任意一个线程将会被唤醒,从而得到再次申请锁的机会。被唤醒的线程等待占用处理器运行时可能还有其他新的活跃线程与该线程抢占这个被释放的锁.
3.8 volatile关键字
(1)volatile是一个轻量级锁,可保证可见性和有序性,但是不保证原子性。
- volatile 可以保证主内存和工作内存直接产生交互,进行读写操作,保证可见性;
- volatile 仅保证变量写操作的原子性,不保证读写操作的原子性;
- volatile 可禁止指令重排序(通过插入内存屏障),(如单例模式中的相关使用)
(2)volatile变量的开销
volatile不会导致线程上下文切换,但其读取变量的成本较高,每次都从高速缓存和主内存中读取,无法直接从寄存器读取变量
(3)volatile替代锁
volatile是一个轻量级的锁,适合多个线程共享一个状态变量,锁适合多个线程共享一组状态变量。可以将多个线程共享的一组状态变量合并成一个对象,用一个volatile变量来引用该对象,从而替代锁。
3.9 Volatile 和 Synchronized的区别
synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!
- volatile 关键字是线程同步的轻量级实现,所以volatile 性能肯定比synchronized关键字要好。但是volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块。
- volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
- volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。
3.10 ReentrantLock 和 Synchronized的区别
- ReentrantLock是类, Synchronized是关键字
两者都是可重入锁
: “可重入锁” 指的是自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增 1,所以要等到锁的计数器下降为 0 时才能释放锁。ReentrantLock是显示锁
,其提供了一些内部锁不具备的特性,但并不是内部锁的替代品。显式锁支持公平和非公平的调度方式,默认采用非公平调度。 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成Synchronized
内部锁简单,但是不灵活。显示锁支持在一个方法内申请锁,并且在另一个方法里释放锁。显示锁定义了一个tryLock()
方法,尝试去获取锁,成功返回true,失败并不会导致其执行的线程被暂停而是直接返回false,即可以避免死锁。
3.11 Java线程池
- 线程池的好处
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
线程池是一种通过“池化”
思想,帮助我们管理线程而获取并发性的工具,在Java中的体现是ThreadPoolExecutor
类。
- 参数
核心参数 | 说明 |
---|---|
corePoolSize | 核心线程数,定义了最小可以同时运行的线程数量 |
maximumPoolSize | 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数 |
keepAliveTime | 当线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁; |
unit | 时间单位 |
workQueue | 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。 |
threadFactory | 创建线程的工厂 |
handler | 饱和策略 |
2.1 ThreadPoolExecutor 饱和策略
:
定义:如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任时,ThreadPoolTaskExecutor 定义一些策略:
- ThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。
- ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
- ThreadPoolExecutor.DiscardPolicy: 不处理新任务,直接丢弃掉。
- ThreadPoolExecutor.DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求。
- 创建方式
线程池创建 | 说明 |
---|---|
newCachedThreadPool | 核心线程大小为0,最大线程池大小不受限(无上限);适合来执行大量耗时短的且频率提交高的任务 |
newFixedThreadPool | 固定大小的线程池;当线程池大小达到核心线程池大小,就不会增加也不会减小工作者线程的固定大小的线程池 |
newScheduledThreadPool | 创建一个定长线程池,支持定时及周期性任务执行。 |
newSingleThreadExceutor | 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。便于实现单(多)生产者-消费者模式 |
- 常见方法
常见方法 | 说明 |
---|---|
execute | 提交任务,交给线程池执行 |
submit | 提交任务,能够返回执行结果,其内部execute+Future |
shutdown | 关闭线程池,等待任务执行完 |
shutdownNow | 关闭线程池,不等待任务执行完 |
getTaskCount | 获取已执行和未执行任务的总数量 |
getCompletedTaskCount | 已完成的任务数量 |
getPoolSize | 获取线程池当前线程数量 |
getActiveCount | 当前线程池中正在执行任务的线程数量 |
- 线程池状态
线程状态 | 说明 |
---|---|
RUNNING | 运行状态,指可以接受任务执行队列里的任务 |
SHUTDOWN | 指调用了 shutdown() 方法,不再接受新任务了,但是队列里的任务得执行完毕。 |
STOP | 指调用了 shutdownNow() 方法,不再接受新任务,同时抛弃阻塞队列里的所有任务并中断所有正在执行任务。 |
TIDYING | 所有任务都执行完毕,在调用shutdown() /shutdownNow() 中都会尝试更新为这个状态。 |
TERMINATED | 终止状态,当执行terminated() 后会更新为这个状态。 |
一般使用 threadPool.execute(new Job());
提交任务,execute()源码
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
else if (!addWorker(command, false))
reject(command);
}
可以看到,
①程序会首先获取当前线程池的状态,如果当前线程数量小于 coreSize 时创建一个新的线程运行;
②如果当前线程处于运行状态,并且写入阻塞队列成功。双重检查,再次获取线程状态;
- 如果线程状态变了(非运行状态)就需要从阻塞队列移除任务,并尝试判断线程是否全部执行完毕。同时执行拒绝策略。
- 如果当前线程池为空就新创建一个线程并执行。
③如果在第②步的判断为非运行状态,尝试新建线程,如果失败则执行拒绝策略。
可借助下图理解
- 线程配置
线程池肯定是不是越大越好,通常是需要根据这批任务执行的性质来确定的。
- IO 密集型任务:由于线程并不是一直在运行,所以可以尽可能的多配置线程。
nCPU * 2
- CPU 密集型任务(大量复杂的运算)应当分配较少的线程,和CPU 个数相当的大小。
nCPU + 1
当然这些都是经验值,最好的方式还是根据实际情况测试得出最佳配置。
- 阻塞队列
线程池内部有一个排队策略,任务可能需要在队列中进行排队等候。常见的阻塞队列有如下三种
ArrayBlockingQueue
- 内部使用一个数组作为其存储空间,数组的存储空间是预先分配的
- 优点是 put 和 take操作不会增加GC的负担(因为空间是预先分配的)
- 缺点是 put 和 take操作使用同一个锁,可能导致锁争用,导致较多的上下文切换
- ArrayBlockingQueue适合在生产者线程和消费者线程之间的并发程序较低的情况下使用
LinkedBlockingQueue
- 是一个无界队列(其实队列长度是Integer.MAX_VALUE)
- 内部存储空间是一个链表,并且链表节点所需的存储空间是动态分配的
- 优点是 put 和 take 操作使用两个显式锁(putLock和takeLock)
- 缺点是增加了GC的负担,因为空间是动态分配的
- LinkedBlockingQueue适合在生产者线程和消费者线程之间的并发程序较高的情况下使用
SynchronousQueue
- SynchronousQueue可以被看做一种特殊的有界队列。生产者线程生产一个产品之后,会等待消费者线程来取走这个产品,才会接着生产下一个产品,适合在生产者线程和消费者线程之间的处理能力相差不大的情况下使用
3.12 ThreadLocal
答:ThreadLocal维护变量时,其为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立的改变自己的副本,而不会影响其他线程对应的副本。
ThreadLocal内部实现机制:
- 每个线程内部会维护一个类似HashMap的对象,成为ThreadLocalMap,其包含若干Entry(K-V键值对,K 为ThreadLocal,V为ThreadLocal的弱引用)
- Entry的Key是一个ThreadLocal实例,Value是一个线程特有对象。Entry的作用是为其属主线程建立起一个ThreadLocal实例与一个线程特有对象之间的对应关系。使用 get() 和 set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。
3.13 Atmoic
Java中的原子类都在java.util.concurrent.atomic
包下,如AtomicInteger等。如常使用的++i
操作时线程不安全的,因为它是一个复合操作。在多线程下,就可以使用这些原子类提供的getAndIncrement和incrementAndGet等原子性自增自减操作。因为原子类内部使用了CAS (compare and swap)
+ volatile
和 native
方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。
3.14 乐观锁&悲观锁
3.15 AQS
AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
3.16 为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?
new 一个 Thread,线程进入了新建状态。调用 start()方法
,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。- 但是,
直接执行 run() 方法
,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
3.17 多线程带来的问题
并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。
3.18 什么是上下文切换?
多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换
。
概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换
。
上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。
Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点
,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。
3.19 程序计数器为什么是私有的?
程序计数器主要有下面两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。
所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。
3.20 虚拟机栈和本地方法栈为什么是私有的?
- 虚拟机栈: 每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
- 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。
3.21 CPU缓存一致性 MESI
-
CPU多级缓存:
CPU的频率太快了,快到主存跟不上,这样在处理器始终周期内,CPU常常需要等待主存,浪费资源。
所以Cache的出现是为了缓解主存和CPU之间速度不匹配的问题(结构:cpu->cache->memory) -
CPU缓存的意义:
时间局部性
:如果某个数据被访问,那么在不久的将来它可能会再次被访问。
空间局部性
:如果某个数据被访问,那么与它相邻的数据很快也可能被访问。
缓存一致性(MESI原则)
MESI 是指4种状态的首字母。每个Cache line有4个状态,可用2个bit表示,它们分别是:
状态描述 | 说明 | 描述 |
---|---|---|
M: Modified 修改 | 该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。 | 该缓存行中的内存需要在未来的某个时间点(允许其它CPU读取请主存中相应内存之前)写回(write back)主存。 当被写回主存之后,该缓存行的状态会变成独享(exclusive)状态。 |
E:Exclusive 独享 | 该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。 | 该状态可以在任何时刻当有其它CPU读取该内存时变成共享状态(shared)。 同样地,当CPU修改该缓存行中内容时,该状态可以变成Modified状态。 |
S:Share 共享 | 该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。 | 当有一个CPU修改该缓存行中,其它CPU中该缓存行可以被作废(变成无效状态(Invalid))。 |
I:Invalid 无效 | 该Cache line无效。 | 无 |
- 触发事件:
触发事件 | 描述 |
---|---|
本地读取(Local read) | 本地cache读取本地cache数据 |
本地写入(Local write) | 本地cache写入本地cache数据 |
远端读取(Remote read) | 其他cache读取本地cache数据 |
远端写入(Remote write) | 其他cache写入本地cache数据 |
- cache分类:
前提:所有的cache共同缓存了主内存中的某一条数据。
cache分类 | 解释 |
---|---|
本地cache | 指当前cpu的cache |
触发cache | 触发读写事件的cache |
其他cache | 指既除了以上两种之外的cache |
3.22 ThreadLocal 内存泄露问题
- ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样会导致ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。
- ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后 最好手动调用remove()方法
3.33 JUC 结构图
3.34 JUC原子类
类型 | 举例 |
---|---|
基本类型 | AtomicInteger; AtomicLong;AtomicBoolean |
数组类型 | AtomicIntegerArray:整形数组原子类,AtomicLongArray:长整形数组原子类,AtomicReferenceArray:引用类型数组原子类 |
引用类型 | AtomicReference:引用类型原子类,AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题 。AtomicMarkableReference :原子更新带有标记位的引用类型 |
对象的属性修改类型 | AtomicIntegerFieldUpdater:原子更新整形字段的更新器,AtomicLongFieldUpdater:原子更新长整形字段的更新器,AtomicReferenceFieldUpdater:原子更新引用类型字段的更新器 |
-----------待补充----------------