-
进程是程序的一次执行过程,是系统运行程序的基本单位。系统运行程序是一个进程从创建到消亡的过程。在java中,当我们启动main函数,其实就是启动了一个jvm的进程。main函数所在的线程就是这个进程中的一个线程,这个线程也加主线程。
-
线程是一个比进程更小的执行单位,一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
请简要描述线程与进程的关系,区别及优缺点?
-
一个进程中可以有多个线程,多个线程共享进程的堆、方法区(jdk1.8之后的元空间),但是每个线程有自己的程序计数器,虚拟机栈和本地方法栈。
-
线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。
程序计数器为什么是私有的?
-
字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制。
-
在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
-
如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。
-
程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。
虚拟机栈和本地方法栈为什么是私有的?
虚拟机栈: 每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
一句话简单了解堆和方法区?
堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、以及编译器编译后的代码等数据。
说说并发与并行的区别?
并发:某一个时间段同时进行多个事情,但是同一时刻可能并没有同时进行 在计算机科学中,多个线程或者是多个进程同时在单核或者多核cpu上执行时,执行路径具有不确定性的这种情形。
并行:同一时刻,同时进行多个事情。
为什么要使用多线程呢?
从计算机底层来说: 单核时代,主要是为了提高单线程利用CPU和IO系统的效率,当线程被IO阻塞时,能充分利用CPU。多核时代,主要是为了提高单线程对多核CPU能力的利用,例如,假设在多核CPU上只有一个线程执行,其他CPU资源就会被浪费掉。
使用多线程可能带来什么问题?
并发编程是为了提高程序的执行效率和运行速度(多核多线程),但是多线程不一定都能提高运行速度(单核多线程)。并且多线程可能会遇到内存泄漏、死锁、线程不安全等问题。
说说线程的生命周期和状态?
-
线程在执行过程中,会有自己的运行条件和状态,例如程序计数器,栈信息等。当出现如下条件时,线程会从CPU占用状态退出。
-
调用了阻塞类系统中断,比如IO,线程被阻塞、被终止或结束运行
-
前面三种情况会发生线程上下文切换
什么是线程死锁?如何避免死锁?
-
死锁:两个及以上的线程同时阻塞,并互相等待对方释放对方占用的资源,并且会一直等待下去,导致程序无法正常结束。
-
死锁产生必须具备以下四个条件:
-
互斥条件:该资源任意一个时刻只由一个线程占用
-
请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放
-
不剥夺条件: 线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源
-
循环等待条件: 若干进程之间形成一种头尾相接的循环等待资源关系
-
-
如何预防死锁?破坏死锁的产生的必要条件即可:
-
破坏请求与保持条件(不释放条件) :一次性申请所有的资源
-
破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源
-
破坏循环等待条件 :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。例如要求按照A、B、C的顺序申请A、B、C三个资源,则不会发生死锁
-
-
-
避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。
-
安全状态: 指的是系统能够按照某种进程推进顺序(P1、P2、P3.....Pn)来为每个进程分配所需资源,直到满足每个进程对资源的最大需求,使每个进程都可顺利完成。称<P1、P2、P3.....Pn>序列为安全序列.
-
-
两者最主要的区别在于:sleep() 方法没有释放锁,而 wait() 方法释放了锁 。
-
两者都可以暂停线程的执行。
-
wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停执行。
-
wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。sleep()方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout) 超时后线程会自动苏醒。
为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?
-
new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
-
总结: 调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。
-
synchronized 关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
-
-
因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock(互斥锁) 来实现的,Java 的线程是映射到操作系统的原生线程之上的,而线程切换是需要从用户态切换到内核态,这个状态转换的时间成本比较高。
-
DK1.6 对锁的实现引入了大量的优化,如自旋锁、自适应锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
-
所以,目前不论是各种开源框架还是 JDK 源码都大量使用了 synchronized 关键字。
-
-
修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁
-
修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁,静态方法锁和实例方法锁可以同时调用,一个锁类,一个锁实例,不会形成互斥
-
修饰代码块 :指定加锁对象,对给定对象/类加锁
双重校验锁实现对象单例,为什么需要用volatile关键字修饰成员变量?
-
new对象分为三步完成:
-
将分配的地址空间指向新对象
-
-
new对象的过程存在指令重拍,如果顺序为1->3->2可能导致其他线程获取到没有正常初始化的对象。
-
使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。
构造方法可以使用 synchronized 关键字修饰么?
-
当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权
-
在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1
-
在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
-
不过两者的本质都是对对象监视器 monitor 的获取。
说说 JDK1.6 之后的 synchronized 关键字底层做了哪些优化,可以详细介绍一下这些优化吗?
-
JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、自适应性锁、锁消除、锁粗化等技术来减少锁操作的开销。
-
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
-
-
线程获取到锁对象,如果此时没有其他线程占用锁对象,就将锁对象头中的标志位置为01,并将自己的线程ID记录在锁对象头的Mark Work的偏向锁线程ID中,同时将偏向锁状态置为1。
-
当前当前线程再次加锁,直接锁加1,可重入。如果其他线程竞争,判断当前线程是否还需要锁,需要则释放偏向锁,升级为轻量级锁。如果不需要锁,则释放锁,其他线程竞争,继续偏向锁。
-
-
- 当有另外一个线程竞争获取这个锁时,由于该锁已经是偏向锁,当发现对象头 Mark Word 中的线程 ID 不是自己的线程 ID,就会进行 CAS 操作获取锁,如果获取成功,直接替换 Mark Word 中的线程 ID 为自己的 ID,该锁会保持偏向锁状态;如果获取锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。
-
- 当前锁处于轻量级锁时,如果其他线程来竞争锁,此时会进行自旋。自旋锁重试之后如果抢锁依然失败,轻量级锁就会升级至重量级锁,锁标志位改为 10。在这个状态下,未抢到锁的线程都会进入 Monitor,之后会被阻塞在 _WaitSet 队列中。
-
偏向锁:适用于单线程适用锁的情况
轻量级锁:适用于竞争较不激烈的情况(这和乐观锁的使用范围类似)
重量级锁:适用于竞争激烈的情况
-
两者都是可重入锁,是指线程可以再次获取自己已经获取的锁。比如一个线程已经获取到了A对象上的锁,此时这个线程还能再次获取A对象上的锁,只是锁计数加1,释放锁时,依次释放直到锁计数为0,才算释放锁。如果不支持重入的话,会造成死锁。
-
-
等待可中断: ReentrantLock提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情
-
可实现选择性通知(锁可以绑定多个条件): 只会唤醒注册在该Condition实例中的所有等待线程
-
-
保证共享变量在多线程环境下的可见性
为什么要使用CPU缓存?
-
CPU缓存在寄存器和主存直接设置了一层CPU缓存,寄存器每次都先从寄存器中获取数据,如果获取不到才到主存中读取数据。
-
CPU缓存为了解决CPU处理速度和内存处理速度不匹配的问题,内存缓存是用于解决内存访问太慢的问题。
JMM是java内存模型,模型是说java的线程都有自己的本地内存,可以将数据缓存到本地内存中,在线程执行过程中,可以先去本地内存中读取数据,而不是直接去主存中读取。在多线程的环境下,如果一个线程修改了主存中的数据,而另一个线程从它本地内存中读取到的是没有修改过的缓存数据,从而导致数据不一致。
并发编程的三个重要特征?
-
原子性: 一个的操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么所有的操作都执行,要么都不执行。synchronized 可以保证代码片段的原子性。
-
可见性:当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。volatile 关键字可以保证共享变量的可见性。
-
有序性:代码在执行的过程中的先后顺序,Java 在编译器以及运行期间的优化,代码的执行顺序未必就是编写代码时候的顺序。volatile 关键字可以禁止指令进行重排序优化。
-
synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在。
-
volatile 关键字能保证数据的可见性,但不能保证原子性。synchronized 关键字两者都能保证。
-
volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。
-
ThreadLocal是线程保存线程事由变量的对象,在当前线程中对当前线程ThreadLocal中的变量进行修改,只会影响到当前线程的ThreadLocal中的变量,不会影响到其他线程的ThreadLocal变量。
在一个Thread的生命周期中,如果Thread中的某个对象持有一个ThreadLocal对象,那么这个线程就有一个线程私有的变量ThreadLocal。
一个ThreadLocal对象被设置到线程中时,至少有两个引用,一个是ThreadLocalMap中的key对它有弱引用,一个是持有ThreadLocal的对象对它有强引用。所以ThreadLocalMap的key被设置成弱引用,当引用ThreadLocal的对象被回收后,即强引用消失后,ThreadLocal对象只剩弱引用,会被回收掉。ThreadLocalMap没有对外提供操作key、value的api也是为了保证数据被回收的数据不能再被操作。如何让线程持有一个贯穿线程生命周期的ThreadLocal?
-
将ThreadLocal设置成静态变量(static修饰),这样ThreadLocal对象会被类的Class对象持有,会贯穿线程生命周期中。此时多个线程持有一个相同的ThreadLocal,每个ThreadLocal在各自的Thread中ThreadLocalMap中对应的值是不一样的,所以线程私有变量的实现是依赖线程的ThreadLocalMap来实现的。
-
设计为非static的,长对象(比如被spring管理的对象)的内部,也不会被回收。
-
线程池、数据库连接池、Http 连接池等等都是池化技术的应用。池化技术主要是为了减少每次获取资源的消耗,提高对资源的利用率。例如数据库连接,如果每次连接都去创建,开销非常大。
-
线程池提供了对线程资源的限制和管理。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。
-
降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
-
提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
-
提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
-
Runnable 接口 不会返回结果或抛出检查异常。
-
Callable 接口 可以回结果或抛出检查异常。
-
execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否。
-
submit()方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功,并且可以通过 Future 的 get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。
-
- ThreadPoolExecutor一共有三个构造方法,一般使用参数最长的构造方法,自己指定各种参数来创建线程池。
-
-
FixedThreadPool : 该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
-
SingleThreadExecutor: 方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
-
CachedThreadPool: 该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
-
-
《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险:
-
keepAliveTime:当线程池中的线程数大于corePoolSize核心线程数时,线程不会立即销毁,而是等超过keepAliveTime时间后才会销毁
-
CallerRunsPolicy:直接在调用execute方法的线程中运行被拒绝的任务,这种策略会降低对于新任务的提交速度,影响整体性能。如果程序能够承受延迟,并且要求每个任务都被执行,可以使用这种策略。
-
DiscardPolicy:不处理新任务,直接丢弃掉。
-
判断corePoolSize是否已经满了,没有满,创建新线程执行,满了交给workQueue。
-
判断maximumPoolSize是否已经满了,没有满,创建新线程执行,满了交给handler。
-
判断handler使用的哪种拒绝策略,按照拒绝策略进行拒绝。
- 原子类就是提供了一系列不可被中断的操作的类。
-
AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
-
- CAS 的原理是拿期望的值和原本的一个值作比较,如果相同则更新成新的值。
- 另外 value 是一个 volatile 变量,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。
为什么会设计AQS同步器类?
- AQS(AbstractQueuedSynchronizer)同步器的作者在论文中阐明了,几乎任意一个同步器都可以基于它去实现其他类型的同步器。例如我们可以基于ReentrantLock去实现Semaphore,反过来也可以实现。但是这种设计会带来很大的复杂性和不灵活性。所以需要构建一个各种同步器的基础框架,即基于这个基础框架,可以实现各种同步器。
- 由于java内置的同步锁synchronized关键字,存在潜在的饥饿问题,AQS的主要性能指标之一就是要解决饥饿问题,同时AQS也支持非公平的同步器。
AQS是基于CLH实现的,介绍一下CLH?
CLH是排队式自旋锁论文三个作者的首字母,排队时式自旋锁要解决的问题是如何高效的访问多核CPU的共享资源。无论是一致性内存访问架构(CPU -> 高速缓存 -> 总线 -> 内存),还是非一致性内存访问架构(内存 -> CPU -> 总线)多线程之间的共享资源访问在高并发的情况下,都是系统瓶颈。CLH就是为了解决这个问题而设计的。
AQS使用一个int型的成员变量state来表示同步状态,且这个状态是volatile修饰的,保证线程可见的。所有要获取锁的线程都会被封装成一个PNode添加到一个FIFO的CLH队列中,后一个节点总是自旋监视前一个节点的状态,如果前一个节点的状态是释放锁,则后一个节点可以尝试去获取锁。
AQS支持的两种资源共享方式?
-
独占(Exclusive):只有一个线程执行,又可以分为公平锁和非公平锁
- 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
- 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
-
ReentrantReadWriteLock可以看做是组合式,因为读锁是多线程的,写锁是单线程的
- 信号量(Semaphore)-允许多个线程同时访问: synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源
- 倒计时器(CountDownLatch): CountDownLatch 是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行
CountDownLatch 的作用就是 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。之前在项目中,有一个使用多线程读取多个文件处理的场景,我用到了 CountDownLatch 。具体场景是下面这样的: 我们要读取处理 6 个文件,这 6 个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。 为此我们定义了一个线程池和 count 为 6 的CountDownLatch对象 。使用线程池处理读取任务,每一个线程处理完之后就将 count-1,调用CountDownLatch对象的 await()方法,直到所有文件读取完之后,才会接着执行后面的逻辑。
- shutdown() :关闭线程池,线程池的状态变为 SHUTDOWN。线程池不再接受新任务了,但是队列里的任务得执行完毕
- shutdownNow() :关闭线程池,线程的状态变为 STOP。线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List
- Timer 只有一个执行线程,因此长时间运行的任务可以延迟其他任务。 ScheduledThreadPoolExecutor 可以配置任意数量的线程。 此外,如果你想(通过提供 ThreadFactory),你可以完全控制创建的线程。
- 在TimerTask 中抛出的运行时异常会杀死一个线程,从而导致 Timer 死机( 即计划任务将不再运行),ScheduledThreadPoolExecutor 不仅捕获运行时异常,还允许在需要时处理它们。抛出异常的任务将被取消,但其他任务将继续正常运行。
- 如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的! CPU 根本没有得到充分利用。
- 如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。
- CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
- I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N
如何判断是 CPU 密集任务还是 IO 密集任务?
CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上
JDK 提供的并发容器总结?
- ConcurrentLinkedQueue : 高效的并发队列,使用链表实现。可以看做一个线程安全的 LinkedList,这是一个非阻塞队列。
- BlockingQueue : 这是一个接口,JDK 内部通过链表、数组等方式实现了这个接口。表示阻塞队列,非常适合用于作为数据共享的通道
- 使用 Collections.synchronizedMap() 方法来包装我们的 HashMap。但这是通过使用一个全局的锁来同步不同线程间的并发访问,因此会带来不可忽视的性能问题。
- 在 ConcurrentHashMap 中,无论是读操作还是写操作都能保证很高的性能:在进行读操作时(几乎)不需要加锁,而在写操作时通过锁分段技术只对所操作的段加锁而不影响客户端对其它段的访问。
- 读操作时,只有是作为红黑树旋转时,才会加锁。其他时候都不用加锁,且value是volatile类型变量,是线程可见的。
- 写操作通过分段加锁,没有hash冲突时,使用cas乐观锁,段出现hash冲突升级成synchronized悲观锁。
- ReentrantReadWriteLock读写锁,解决了读读互斥的问题,但是存在读写互斥
- CopyOnWriteArrayList让所有的写操作互斥,且写时,先将原来的数组复制一份(只能有一个写操作,只能复制一份副本),将数据添加进去,然后用新数据替换原数组,从而达到读读不互斥,读写不互斥,写写互斥。
- 源码分析,读不做操作,写全部加上同步一把重入锁
- Java 提供的线程安全的 Queue 可以分为阻塞队列和非阻塞队列,其中阻塞队列的典型例子是 BlockingQueue,非阻塞队列的典型例子是 ConcurrentLinkedQueue。阻塞队列可以通过加锁来实现,非阻塞队列可以通过 CAS 操作实现。
- 阻塞队列(BlockingQueue)被广泛使用在“生产者-消费者”问题中。
- BlockingQueue 提供了可阻塞的插入和移除的方法。
- 当队列容器已满,生产者线程会被阻塞,直到队列未满。
- 当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。
- ArrayBlockingQueue 一旦创建,容量不能改变。其并发控制采用可重入锁 ReentrantLock ,不管是插入操作还是读取操作,都需要获取到锁才能进行操作。当队列容量满时,尝试将元素放入队列将导致操作阻塞;尝试从一个空队列中取一个元素也会同样阻塞
- ArrayBlockingQueue 默认情况下不能保证线程访问队列的公平性,即线程访问不准寻FIFO。因此可能存在,当 ArrayBlockingQueue 可以被访问时,长时间阻塞的线程依然无法访问到 ArrayBlockingQueue。如果保证公平性,通常会降低吞吐量。如果需要获得公平性的 ArrayBlockingQueue可以在new的时候,通过参数指出
- 底层基于单向链表实现的阻塞队列,可以当做无界队列也可以当做有界队列来使用,同样满足 FIFO 的特性,与 ArrayBlockingQueue 相比起来具有更高的吞吐量
-
- 是一个支持优先级的无界阻塞队列。默认情况下元素采用自然顺序进行排序,也可以通过自定义类实现 compareTo() 方法来指定元素排序规则,或者初始化时通过构造器参数 Comparator 来指定排序规则
- PriorityBlockingQueue 并发控制采用的是可重入锁 ReentrantLock,队列为无界队列,后面插入元素的时候,如果空间不够的话会自动扩容
- 简单地说,它就是 PriorityQueue 的线程安全版本。不可以插入 null 值,同时,插入队列的对象必须是可比较大小的(comparable),否则报 ClassCastException 异常。它的插入操作 put 方法不会 block,因为它是无界队列(take 方法在队列为空的时候会阻塞)
- 跳表又叫跳跃表,是一种可以进行二分查找的有序链表。跳表是在原来的有序链表上添加了多层有序链表实现的。如果严格按照二分查找构建跳表,每次添加数据后都必须重建i>1层的所有链表,会让插入和删除的时间复杂度退化成O(n),所以一般采用插入数据后,随机数据所处在层数,即第1到随机层数都有这个值(只需维护每层这个值的索引关系),从而达到简化跳表插入和删除。
- 由于满足二分查找,所以跳表的查找复杂度是O(logn)
- 跳表是一种以时间换空间的算法
- 线程池、数据库连接池、Http 连接池等等,主要是为了减少每次获取资源的消耗,提高对资源的利用率。
- 线程池提供了对线程使用的限制和管理。 同时每个线程池还维护一些基本统计信息,例如已完成任务的数量
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
线程池在实际项目的使用场景?
线程池一般用于执行多个不相关联的耗时任务,没有多线程的情况下,任务顺序执行,使用了线程池的话可让多个不相关联的任务同时执行。例如:批量发送邮件。
一般是通过 ThreadPoolExecutor 的构造函数来创建线程池,然后提交任务给线程池执行就可以了。创建线程池时需要注意核心线程数,最大线程数,队列长度,拒绝策略。
- 实际使用中需要根据自己机器的性能、业务场景来手动配置线程池的参数比如核心线程数、使用的任务队列、饱和策略等等。
- 我们应该显示地给我们的线程池命名,这样有助于我们定位问题。
- 总结:使用有界队列,控制线程创建数量。
可以利用 ThreadPoolExecutor 的相关 API做一个的监控。打印线程池当前的线程数和活跃线程数、已经执行完成的任务数、正在排队中的任务数等等。
建议不同类别的业务用不同的线程池?
一般建议是不同的业务使用不同的线程池,配置线程池的时候根据当前业务的情况对当前线程池进行配置,因为不同的业务的并发以及对资源的使用情况都不同,重心优化系统性能瓶颈相关的业务。
- 初始化线程池的时候需要显示命名(设置线程池名称前缀),有利于定位问题
CPU 密集型任务(N+1):这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。 I/O 密集型任务(2N):这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。
如何判断是 CPU 密集任务还是 IO 密集任务?
CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。
- AQS 就是一个抽象类,主要用来构建锁和同步器。
- AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
- AQS 使用一个 int 成员变量来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。
AQS 对资源的共享方式?
-
Exclusive(独占):只有一个线程能执行,如 ReentrantLock。又可分为公平锁和非公平锁
- 公平锁 :按照线程在队列中的排队顺序,先到者先拿到锁
- 非公平锁 :当线程要获取锁时,先通过两次 CAS 操作去抢锁,如果没抢到,当前线程再加入到队列中等待唤醒
- 公平锁和非公平锁只有两处不同:
- 非公平锁在调用 lock 后,首先就会调用 CAS 进行一次抢锁,如果这个时候恰巧锁没有被占用,那么直接就获取到锁返回了
- 非公平锁在 CAS 失败后,和公平锁一样都会进入到 tryAcquire 方法,在 tryAcquire 方法中,如果发现锁这个时候被释放了(state == 0),非公平锁会直接 CAS 抢锁,但是公平锁会判断等待队列是否有线程处于等待状态,如果有则不去抢锁,乖乖排到后面
- 公平锁和非公平锁就这两点区别,如果这两次 CAS 都不成功,那么后面非公平锁和公平锁是一样的,都要进入到阻塞队列等待唤醒
- 相对来说,非公平锁会有更好的性能,因为它的吞吐量比较大。当然,非公平锁让获取锁的时间变得更加不确定,可能会导致在阻塞队列中的线程长期处于饥饿状态
- Semaphore 有两种模式,公平模式和非公平模式:
- 公平模式: 调用 acquire() 方法的顺序就是获取许可证的顺序,遵循 FIFO
- 非公平模式: 抢占式的
CountDownLatch 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕,或者允许count线程同时运行。
- 某一线程在开始运行前等待 n 个线程执行完毕
- 当count个线程执行完成后,主线程会被唤醒,然后继续执行
- 实现多个线程开始执行任务的最大并行性
CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用
- 让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活
CyclicBarrier 可以用于多线程计算数据,最后合并计算结果的应用场景。比如我们用一个 Excel 保存了用户所有银行流水,每个 Sheet 保存一个帐户近一年的每笔银行流水,现在需要统计用户的日均银行流水,先用多线程处理每个 sheet 里的银行流水,都执行完之后,得到每个 sheet 的日均银行流水,最后,再用 barrierAction 用这些线程的计算结果,计算出整个 Excel 的日均银行流水。
- CountDownLatch 是计数器,线程完成一个记录一个,只不过计数不是递增而是递减,而 CyclicBarrier 更像是一个阀门,需要所有线程都到达,阀门才能打开,然后继续执行。
支持响应中断、超时、尝试获取锁 |
必须显示的调用unlock释放锁 |