Java多线程与并发
1,进程与线程
6种状态:初始状态、运行状态、阻塞状态、等待状态、超时等待状态、终止状态
- 线程创建之后它将处于 初始状态(NEW),调用 start() 方法后开始运行,线程这时候处于 可运行状态(READY)。
- 可运行状态的线程获得了 CPU 时间片后就处于 运行状态(RUNNING)。
- 当线程执行 wait()方法之后,线程进入 等待状态(WAITING),进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态【notify()】。 而 超时等待状态(TIME_WAITING)相当于在等待状态的基础上增加了超时限制,【sleep(long millis)/wait(long millis)】,当超时时间到达后 Java 线程将会返回到运行状态。
- 当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到阻塞状态(BLOCKED)。
- 线程在执行 Runnable 的run()方法之后将会进入到 终止状态(TERMINATED)。
1,线程间同步协作(即保证一个共享资源的安全读写)
由syncrhoized同步锁、ReentrantLock(可重入锁)、ReadWriteLock(读写锁)等待同步机制,实现线程之间的同步。
2,线程间通信
线程之间 让出资源、挂起、唤醒 就是通过线程的通信来实现的。
两种方式:
1)syncrhoized加锁的线程的Object类的wait()/notify()/notifyAll()
2)ReentrantLock类加锁的线程的Condition类调用await()/signal()/signalAll()
3,线程的自身动作
1)线程自身可以通过调用 sleep() 方法进入阻塞态,暂时让出CPU资源(但是不释放锁)
2)线程自身可以通过调用 yield() 方法由运行态变为就绪态;
3)父线程中 运行子线程对象.join() 方法把已经启动的其他线程先执行完 ,再继续执行父线程的余下操作。
join内部是wait实现的会释放锁,而sleep不会释放锁。
打断线程
wait()和sleep()都可以通过interrupt()方法 打断线程的暂停状态 ,从而使线程立刻抛出InterruptedException。
进程:在操作系统中能够独立运行,并且作为资源分配的基本单位。它表示运行中的程序。系统运行一个程序就是一个进程从创建、运行到消亡的过程。
线程:是一个比进程更小的执行单位,能够完成进程中的一个功能,也被称为轻量级进程。一个进程在其执行的过程中可以产生多个线程。
【注】线程与进程不同的是:同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多。
为什么程序计数器、虚拟机栈和本地方法栈是线程私有的呢?为什么堆和方法区是线程共享的呢?
程序计数器为什么是私有的?
程序计数器主要有下面两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
(需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。)
所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。
- 虚拟机栈和本地方法栈为什么是私有的?
- 虚拟机栈: 每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
- 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。
堆和方法区是所有线程共享的资源,其中:
- 堆是进程中最大的一块内存,主要用于存放新创建的对象 (所有对象都在这里分配内存)。
- 方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
2,java中的两种锁
1)synchronized的实现原理及锁优化
synchronized 属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层操作系统的 Mutex Lock 来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。庆幸的是在 Java 6 之后 Java 官方从 JVM 层面对 synchronized 进行了较大优化,所以现在的 synchronized 锁效率也优化得很不错了。Java 6 之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁
1)synchronized可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性。
2)Monitor是一个同步工具,相当于操作系统中的互斥量(mutex),即值为1的信号量。
它内置于每一个Object对象中,相当于一个许可证。拿到许可证即可以进行操作,没有拿到则需要阻塞等待。每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。 monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
3)syncrhoized又叫做内置锁,为什么呢?因为使用syncrhoized加锁的同步代码块在字节码引擎中执行时,其实是通过锁对象的monitor的取用与释放来实现的。
4)锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。
5)锁优化:
自旋锁:让不满足条件的线程等待一会看能不能获得锁,通过占用处理器的时间来避免线程切换带来的开销。自旋等待的时间或次数是有一个限度的,如果自旋超过了定义的时间仍然没有获取到锁,则该线程应该被挂起。
适应自旋锁:在 JDK1.6 之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
锁消除:锁消除理解起来很简单,它指的就是虚拟机即使编译器在运行时,如果检测到那些共享数据不可能存在竞争,那么就执行锁消除。锁消除可以节省毫无意义的请求锁的时间。
锁粗化:原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小,——直在共享数据的实际作用域才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待线程也能尽快拿到锁。
偏向锁:大多数情况下,锁总是由同一个线程多次获得。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,偏向锁是一个可重入的锁。如果锁对象头的Mark Word里存储着指向当前线程的偏向锁,无需重新进行CAS操作来加锁和解锁。当有其他线程尝试竞争偏向锁时,持有偏向锁的线程(不处于活动状态)才会释放锁。偏向锁无法使用自旋锁优化,因为一旦有其他线程申请锁,就破坏了偏向锁的假定进而升级为轻量级锁。
轻量级锁:倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的)。轻量级锁不是为了代替重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗,因为使用轻量级锁时,不需要申请互斥量。另外,轻量级锁的加锁和解锁都用到了CAS操作。
轻量级锁能够提升程序同步性能的依据是“对于绝大部分锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥操作的开销。但如果存在锁竞争,除了互斥量开销外,还会额外发生CAS操作,因此在有锁竞争的情况下,轻量级锁比传统的重量级锁更慢!如果锁竞争激烈,那么轻量级将很快膨胀为重量级锁!
重量级锁:通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实 现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。线程竞争不使用自旋,不会消耗CPU。但是线程会进入阻塞等待被其他线程被唤醒,响应时间缓慢。
2)ReentrantLock
实现Lock的主要类是ReentrantLock,这是一个功能比较复杂的类,主要都功能有1.公平锁;2.尝试获取锁;3.可以中断等待状态;4.读写锁;这些相比较sync锁更多的操作主要是因为他的内部是根据AQS来实现的,所以能够进行更多的操作,本身是一个jdk的类,能够提供更多的方法。
公平锁
在ReentrantLock中有三个内部类,分别是一个父类sync类和两个子类,这两个子类一个实现了公平锁,另一个是非公平锁。具体的实现方法是在ReentrantLock在将线程插入AQS的时候,非公平锁可以尝试立即抢占资源,而公平锁必须去排队。
尝试获取锁
使用tryLock的方式加锁,可以设置时间,在超过时间没有获取到锁的情况下可以放弃锁,进入其他逻辑。
中断等待状态
ReentrantLock中提供了一个方法可以中断等待中的线程,在sync中是做不到的。
读写锁
ReentrantLock中有一个ReadAndWriteentrantLock可以做读写锁,读锁不限制个数,写锁排他,其实这类实现的方法都是根据AQS,本身封装成工具便于使用。
AQS
AQS是很多线程同步工具的实现类,全称是AbstractQueueSynchronizor,抽象同步队列,虽然说是一个抽象类,但是实际上他实现了所有的方法,我们只需要重写获取和释放两个方法,就能达到不同的效果。AQS维护一个队列,这个队列中存放着线程,还维护一个资源,一般来说是一个计数器,来计数有多少线程此时在使用。新添加的元素放到队尾,队头的线程获取资源,后面的线程等待队头释放自己才能获取,以此循环实现一个队列的概念。非公平锁就是在插入队尾时可以先看一下队头有没有使用,没有使用则直接抢占。
Reentrantlock原理
在lock的构造函数中,定义了一个NonFairSync,
static final class NonfairSync extends Sync
NonfairSync 又是继承于Sync
abstract static class Sync extends AbstractQueuedSynchronizer
一步一步往上找,找到了
这个鬼AbstractQueuedSynchronizer(简称AQS),最后这个鬼,又是继承于AbstractOwnableSynchronizer(AOS),AOS主要是保存获取当前锁的线程对象,代码不多不再展开。最后我们可以看到几个主要类的继承关系:
FairSync 与 NonfairSync的区别在于,是不是保证获取锁的公平性,因为默认是NonfairSync(非公平性),我们以这个为例了解其背后的原理。
其他几个类代码不多,最后的主要代码都是在AQS中,我们先看看这个类的主体结构。
最后我们可以发现锁的存储结构就两个东西:"双向链表" + "int类型状态"。
简单来说,ReenTrantLock的实现是一种自旋锁,通过循环调用CAS操作来实现加锁。它的性能比较好也是因为避免了使线程进入内核态的阻塞状态。想尽办法避免线程进入内核的阻塞状态是我们去分析和理解锁设计的关键钥匙。
需要注意的是,他们的变量都被"transient和volatile修饰。
lock.lock()怎么获取锁?
可以看到调用的是,NonfairSync.lock()
看到这里,我们基本有了一个大概的了解,还记得之前AQS中的int类型的state值,
这里就是通过CAS(乐观锁)去修改state的值(锁状态值)。lock的基本操作还是通过乐观锁来实现的。
获取锁通过CAS,那么没有获取到锁,等待获取锁是如何实现的?我们可以看一下else分支的逻辑,acquire方法:
这里干了三件事情:
tryAcquire:会尝试再次通过CAS获取一次锁。
addWaiter:将当前线程加入上面锁的双向链表(等待队列)CAS确保能够在线程安全的情况下,将当前线程加入到链表的尾部。
acquireQueued:通过自旋,判断当前队列节点是否可以获取锁。当前线程到头部的时候,尝试CAS更新锁状态,如果更新成功表示该等待线程获取成功。从头部移除。
每一个线程都在 自旋+CAS
总结
- lock的存储结构:一个int类型状态值(用于锁的状态变更),一个双向链表(用于存储等待中的线程)
- lock获取锁的过程:本质上是通过CAS来获取状态值修改,如果当场没获取到,会将该线程放在线程等待链表中。
- lock释放锁的过程:修改状态值,调整等待链表。
- 简述总结:
- 总体来讲线程获取锁要经历以下过程(非公平):
- 1、调用lock方法,会先进行cas操作看下可否设置同步状态1成功,如果成功执行临界区代码
- 2、如果不成功获取同步状态,如果状态是0那么cas设置为1.
- 3、如果同步状态既不是0也不是自身线程持有会把当前线程构造成一个节点。
- 4、把当前线程节点CAS的方式放入队列中,行为上线程阻塞,内部自旋获取状态。
- (acquireQueued的主要作用是把已经追加到队列的线程节点进行阻塞,但阻塞前又通过tryAccquire重试是否能获得锁,如果重试成功能则无需阻塞,直接返回。)
- 5、线程释放锁,唤醒队列第一个节点,参与竞争。重复上述。
Lock底层实现
Lock底层实现基于AQS实现,采用线程独占的方式,在硬件层面依赖特殊的CPU指令(CAS)。
简单来说,ReenTrantLock的实现是一种自旋锁,通过循环调用CAS操作来实现加锁。它的性能比较好也是因为避免了使线程进入内核态的阻塞状态。想尽办法避免线程进入内核的阻塞状态是我们去分析和理解锁设计的关键钥匙。
3,Java并发
1,原子性
由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
2,可见性:
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
当然通过synchronized和Lock也能够保证可见性。
3,有序性
很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
happens-before原则(先行发生原则):
1)程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
2)锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
3)volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
4)传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
死锁
死锁是指多个线程同时请求对方的资源被卡死的情况,这种情况要在编程的时候注意会不会发生,如果怀疑自己的线程有死锁情况,可以dump一下Thread文件查看线程运行情况。死锁发生的条件是,每个线程占有资源,资源不共享,不能抢占,不会一段时间后按时释放资源等,造成了死锁情况。解决死锁的方法很多,一种是从逻辑上找到问题,尽量让所有的线程按照同样的顺序请求资源。其他的方法是设置线程共享变量,或者final字段。
4,Java并发工具
1,等待多线程完成的CountDownLatch,类似join
2,同步屏障CyclicBarrier
它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续运行。 CyclicBarrier默认的构造方法是CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用await方法告诉CyclicBarrier我已经到达了屏障,然后当前线程被阻塞。但阻塞数到达设置的拦截参数,则线程一起越过屏障。
3,控制并发线程数的Semaphore
Semaphore(信号量)是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源。
4,线程间交换数据的Exchanger
5,线程池
如果每个线程执行完毕之后就释放,再新建就会非常浪费资源。这是个时候我们就想能不能把线程都存放在一起,进行复用,这就是线程池的概念。
核心线程数,当前线程池稳定维护的线程数;
最大线程数,当前线程池最大可以容纳的线程数;
等待时间,等待时间过了而且线程池中的线程超过核心的数量,就不断减少;
阻塞队列,选用不同的阻塞队列存放待执行的任务,大概有Array,Linked,Synchronou的BlockingQueue几种情况;
拒绝模式,当队列饱和的时候如何处理其他任务,有直接丢弃,有抛出异常,有丢弃队列中最近的一个任务,先执行新任务,不断重复塞入等操作。
Executors中静态工厂
创建一个单线程的线程池。这个线程池只有一个线程在工作,也就是相当于单线程串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行。
创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。
创建一个可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60秒不执行任务)的线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务。此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小。
关闭多线程
我们可以通过调用线程池的shutdown或shutdownNow方法来关闭线程池,但是它们的实现原理不同,shutdown的原理是只是将线程池的状态设置成SHUTDOWN状态,然后中断所有没有正在执行任务的线程。shutdownNow的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务可能永远无法终止。
ThreadLocal
threadLocal是一个线程私有的变量,对于每一个线程都拥有自己的一个ThreadLocal值,原理很简单,是在堆上创建了一个HAshmap,键是自己的ThreadID,而值是要存放的内容。在一些处理不当的情况下,可能会发生内存泄漏的情况。
6,CAS 与 Atomic*类型实现原理
6.1 CAS与Unsafe
CAS是一种类似于乐观锁的机制,或者说是:比较交换、if-then。在AQS和Atomic原子类等很多地方广泛应用此种方式进行加锁。计算机能够保证CAS的原子性是很关键的一个点,Unsafe是CAS调用的真正方法,这个类能够对内存进行分配,NIO的分配就基于此类。
6.2原子类
AtomicInteger
AtomicInteger类可以对int数据做原子的加减操作,在内部原子类是通过CAS进行多线程并发处理的。
LongAdder
LongAdder和AtomicInteger没有太多区别,只不过LongAdder采用类似hashmap的方式把自己的long分成了很多cell,每个线程对自己的cell操作,需要合并的时候调用sum方法。
一、锁机制的缺点
悲观锁存在的问题:
在多线程的竞争下,加锁和释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
一个线程持有锁会导致其他所有需要此锁的线程被挂起。
乐观锁的好处:认为在并发场景下大部分时间都不会产生冲突,因此每次读写数据不加锁而是假设没有冲突去完成某项操作,如果因冲突失败就重试,直到成功为止。用到的机制就是CAS。
二、CAS的实现原理
CAS操作包括三个操作数–内存位置V,预期原值A,新值B。
分为三个步骤:
读取内存中的值
将读取的值和预期的值进行比较
如果比较的结果符合预期,则写入新值;如果不符合,则什么都不做
原子操作就是靠CAS算法保证的,那***一个CPU下会不会同时两个线程同时比较且同时替换***呢?不会。
因为CAS是一种系统原语:
原语由若干条指令组成的,用于完成一定功能的一个过程。
原语的执行必须是连续的,在执行过程中不允许被中断。
补充操作系统知识:在x86 平台上,CPU提供了在指令执行期间对总线加锁的手段。CPU芯片上有一条引线#HLOCK pin,如果汇编语言的程序中在一条指令前面加上前缀’LOCK’,经过汇编以后的机器代码就使CPU在执行这条指令的时候把#HLOCK pin的电位拉低,持续到这条指令结束时放开,从而把总线锁住,这样同一总线上别的CPU就暂时不能通过总线访问内存了,保证了这条指令在多处理器环境中的原子性。
总之,原子操作的基石是:CPU对总线加锁,加锁的方式叫:拉低电位。
CAS是基于乐观锁的,也就是说当写入的时候,如果寄存器旧值已经不等于现值,说明有其他CPU在修改,那就继续尝试。所以这就保证了操作的原子性。因此在大请求量的性能表现上,CAS乐观锁也是可以大大提高吞吐量的。
Atomic正是采用了CAS算法,所以可以在多线程环境下安全的操作对象。
重点!!!在多CPU下,不能保证两个线程同时CAS,那如何CAS机制又是如何保证线程安全的呢?
比如在cpu1下执行完比较准备修改时,cpu2火速完成了一次CAS从而让内存中的值发生了变化,此时cpu1再写入明显就不对了。
解决:使用volatile。
volatile的特性:
可见性:volatile修饰的对象在加载时会告知JVM,对象在cpu缓存上对多个线程是同时可见的。
顺序性:保证线程操作对象时时顺序执行的,不会进行指令重排序。
一致性:可以保证多个线程读取数据时,读到的数据是最新的。
解决以上问题的关键就在volatile的一致性,volatile的写操作是安全的,因为他在写入的时候lock会锁住cpu总线导致其他cpu不能访问内存(现在多用缓存一致性协议,即处理器嗅探总线上传播的数据来判断自己缓存的值是否过期),所以当cpu2火速修改了变量的值时,这就让该变量在所有cpu上缓存的值都失效了,cpu1在进行写操作时,发现自己缓存的值已经失效了,那么CAS操作失败,(Java的AutomicInteger中,会不停的CAS直到成功)。所以即使在多cpu多线程下,CAS机制也能保证线程安全。
CAS存在的问题:
1. ABA问题:
CAS会检查值有没有发生变化,没有则更新,但如果一个值A,变成了B,又变成了A,CAS检查时会发现它没有变化,但实际是变化了。
解决:使用版本号,每次变量更新把版本号加1,A-B-A 就会变成1A-2B-3A。
JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全相等,则以原子的方式将该引用和该标志的值设置为给定的更新值。
2.循环时间长,开销大:
自旋CAS如果长时间不成功,会不断尝试,长时间占用CPU,带来非常大的执行开销。
解决:如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
3.只能保证一个共享变量的原子操作:
当对一个共享变量执行操作时,可以使用循环CAS来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性了。这个时候就要用到锁了。
或者把多个共享变量合并成一个共享变量来操作,比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
7、什么是上下文切换?
即使单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切换线程执行,让我们感觉多个线程是同时执行的。(时间片一般是几十毫秒)
CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到加载的过程就是一次上下文切换。上下文切换会影响多线程的执行速度。
8、并发与并行?
并发指的是多个任务交替进行,并行则是指真正意义上的“同时进行”。
实际上,如果系统内只有一个CPU,使用多线程时,在真实系统环境下不能并行,只能通过切换时间片的方式交替进行,从而并发执行任务。真正的并行只能出现在拥有多个CPU的系统中。
9、什么是线程死锁?如何避免死锁?
多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
假如线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
避免死锁的几个常见方法:
- 避免一个线程同时获取多个锁
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
- 尝试使用定时锁,使用 lock.tryLock(timeout) 来代替使用内部锁机制。
- 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。
- 答:死锁是一种特定的程序状态,一般是多线程场景下两个以上的线程互相持有对方需要的锁而处于的永久阻塞状态。
- 定位方法:jstack分析线程的栈信息可以定位出来; 或者使用ThreadMXBean相关的api在程序中打印出相关的死锁信息;
- 修复和规避:死锁问题一般无法在线解决,一般紧急的先重启应用保证可用;然后在开发环境采用互相代码审查,使用预防性工具比如finBugs提前发现可能发生死锁的程序,修复程序本身的问题。
死锁是如何产生的?如何定位?如何修复和避免?
jstack分析死锁
1, 找出运行程序的进程ID, ps -ef | grep java
2, 使用jstack pid 来分析线程的状态;
如何避免死锁
1, 同一段代码尽量避免使用多个锁;
2,一定要使用多个锁,必须注意顺序;
3,尽量使用带超时时间的等待方法;
4,使用辅助工具,比如findbugs提前发现可能发生死锁的代码段,扼杀在摇篮里。
10、sleep() 方法和 wait() 方法区别和共同点?(重要!)
相同点:
两者都可以暂停线程的执行,都会让线程进入等待状态。
不同点:
- sleep()方法没有释放锁,而 wait()方法释放了锁。
- sleep()方法属于Thread类的静态方法,作用于当前线程;而wait()方法是Object类的实例方法,作用于对象本身。
- 执行sleep()方法后,可以通过超时或者调用interrupt()方法唤醒休眠中的线程;执行wait()方法后,通过调用notify()或notifyAll()方法唤醒等待线程。
11、为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?
new 一个 Thread,线程进入初始状态;调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 而直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
总结: 调用 start 方法可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行。
12、多线程开发带来的问题与解决方法?(重要)
使用多线程主要会带来以下几个问题:
(一)线程安全问题
线程安全问题指的是在某一线程从开始访问到结束访问某一数据期间,该数据被其他的线程所修改,那么对于当前线程而言,该线程就发生了线程安全问题,表现形式为数据的缺失,数据不一致等。
线程安全问题发生的条件:
1)多线程环境下,即存在包括自己在内存在有多个线程。
2)多线程环境下存在共享资源,且多线程操作该共享资源。
3)多个线程必须对该共享资源有非原子性操作。
线程安全问题的解决思路:
1)尽量不使用共享变量,将不必要的共享变量变成局部变量来使用。
2)使用synchronized关键字同步代码块,或者使用jdk包中提供的Lock为操作进行加锁。
3)使用ThreadLocal为每一个线程建立一个变量的副本,各个线程间独立操作,互不影响。
(二)性能问题
线程的生命周期开销是非常大的,一个线程的创建到销毁都会占用大量的内存。同时如果不合理的创建了多个线程,cup的处理器数量小于了线程数量,那么将会有很多的线程被闲置,闲置的线程将会占用大量的内存,为垃圾回收带来很大压力,同时cup在分配线程时还会消耗其性能。
解决思路:
利用线程池,模拟一个池,预先创建有限合理个数的线程放入池中,当需要执行任务时从池中取出空闲的先去执行任务,执行完成后将线程归还到池中,这样就减少了线程的频繁创建和销毁,节省内存开销和减小了垃圾回收的压力。同时因为任务到来时本身线程已经存在,减少了创建线程时间,提高了执行效率,而且合理的创建线程池数量还会使各个线程都处于忙碌状态,提高任务执行效率,线程池还提供了拒绝策略,当任务数量到达某一临界区时,线程池将拒绝任务的进入,保持现有任务的顺利执行,减少池的压力。
(三)活跃性问题
1)死锁,假如线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。多个线程环形占用资源也是一样的会产生死锁问题。
解决方法:
- 避免一个线程同时获取多个锁
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
- 尝试使用定时锁,使用 lock.tryLock(timeout) 来代替使用内部锁机制。
想要避免死锁,可以使用无锁函数(cas)或者使用重入锁(ReentrantLock),通过重入锁使线程中断或限时等待可以有效的规避死锁问题。
2)饥饿,饥饿指的是某一线程或多个线程因为某些原因一直获取不到资源,导致程序一直无法执行。如某一线程优先级太低导致一直分配不到资源,或者是某一线程一直占着某种资源不放,导致该线程无法执行等。
解决方法:
与死锁相比,饥饿现象还是有可能在一段时间之后恢复执行的。可以设置合适的线程优先级来尽量避免饥饿的产生。
3)活锁,活锁体现了一种谦让的美德,每个线程都想把资源让给对方,但是由于机器“智商”不够,可能会产生一直将资源让来让去,导致资源在两个线程间跳动而无法使某一线程真正的到资源并执行,这就是活锁的问题。
(四)阻塞
阻塞是用来形容多线程的问题,几个线程之间共享临界区资源,那么当一个线程占用了临界区资源后,所有需要使用该资源的线程都需要进入该临界区等待,等待会导致线程挂起,一直不能工作,这种情况就是阻塞,如果某一线程一直都不释放资源,将会导致其他所有等待在这个临界区的线程都不能工作。当我们使用synchronized或重入锁时,我们得到的就是阻塞线程,如论是synchronized或者重入锁,都会在试图执行代码前,得到临界区的锁,如果得不到锁,线程将会被挂起等待,直到其他线程执行完成并释放锁且拿到锁为止。
解决方法:
可以通过减少锁持有时间,读写锁分离,减小锁的粒度,锁分离,锁粗化等方式来优化锁的性能。
临界区:
临界区是用来表示一种公共的资源(共享数据),它可以被多个线程使用,但是在每次只能有一个线程能够使用它,当临界区资源正在被一个线程使用时,其他的线程就只能等待当前线程执行完之后才能使用该临界区资源。
比如办公室办公室里有一支笔,它一次只能被一个人使用,假如它正在被甲使用时,其他想要使用这支笔的人只能等甲使用完这支笔之后才能允许另一个人去使用。这就是临界区的概念。
13、 synchronized 关键字
synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
synchronized关键字最主要的三种使用方式:修饰实例方法:、修饰静态方法、修饰代码块。
- 对于普通同步方法,锁是当前实例对象。
- 对于静态同步方法,锁是当前类的Class对象。
- 对于同步代码块,锁是synchronized括号里配置的对象。
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。
synchronized在JVM里是怎么实现的?
synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor的持有权。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因)
synchronized用的锁是存在哪里的?
synchronized用到的锁是存在Java对象头里的。
14、说说 JDK1.6 之后的synchronized 关键字底层做了哪些优化,可以详细介绍一下这些优化吗
JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
15、synchronized和 Lock 的区别?(重要)
1)Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现;
2)synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;
3)Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;
4)通过Lock可以知道有没有成功获取锁(tryLock()方法:如果获取锁成功,则返回true),而synchronized却无法办到。
5)Lock可以提高多个线程进行读操作的效率。
在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时Lock的性能要远远优于synchronized。所以说,在具体使用时要根据适当情况选择。
16、synchronized和ReentrantLock(重入锁) 的区别?
- 两者都是可重进入锁,就是能够支持一个线程对资源的重复加锁。sychnronized关键字隐式的支持重进入,比如一个sychnronized修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获取该锁。ReentrantLock虽然没能像sychnronized关键字一样隐式的重进入,但是在调用lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞。
- 线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于0时表示锁已经成功被释放。
- synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API。ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成)
- ReentrantLock 比 synchronized 增加了一些高级功能,主要有3点:①等待可中断;②可实现公平锁;③可实现选择性通知(锁可以绑定多个条件)
- ReentrantLock提供了一种能够中断等待锁的线程的机制,也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。通过lock.lockInterruptibly()来实现这个机制。
- ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。(公平锁就是先等待的线程先获得锁)
- synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition() 方法。用ReentrantLock类结合Condition实例可以实现“选择性通知” 。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition实例的signalAll()方法 只会唤醒注册在该Condition实例中的所有等待线程
17、volatile关键字
保证共享变量的“可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
把变量声明为volatile,这就指示 JVM每次使用它都到主存中进行读取。
在前文中已经提及过,线程本身并不直接与主内存进行数据的交互,而是通过线程的工作内存来完成相应的操作。这也是导致线程间数据不可见的本质原因。因此要实现volatile变量的可见性,直接从这方面入手即可。对volatile变量的写操作与普通变量的主要区别有两点:
(1)修改volatile变量时会强制将修改后的值刷新的主内存中。
(2)修改volatile变量后会导致其他线程工作内存中对应的变量值失效。因此,再读取该变量值的时候就需要重新从读取主内存中的值。
通过这两个操作,就可以解决volatile变量的可见性问题。
有volatile修饰的共享变量进行写操作的时候会加上一个lock前缀。
有序性
指令重排是指处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证各个语句的执行顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。
程序执行到volatile修饰变量的读操作或者写操作时,在其前面的操作肯定已经完成,且结果已经对后面的操作可见,在其后面的操作肯定还没有进行。
volatile限制重排序
volatile的内存屏障策略:
- 会在volatile读:前面加入LoadLoad屏障、后面LoadStore屏障;
- 在volatile写:前面加入StoreStore屏障、后面加入StoreLoad屏障。
LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
什么是happen-before
JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。
具体的定义为:
1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
具体的规则:
(1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
(2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
(3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
(4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
(5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
(6)Join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
(7)程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。
(8)对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。
总线风暴
MESI (缓存一致性协议)
当CPU写数据时,如果发现操作的变量是共享变量,即在其它CPU中也存在该变量的副本,会发出信号通知其它CPU将该变量的缓存行为置为无效状态,因此当其它CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么就会从内存中重新读取。
如何发现数据是否失效?——嗅探
每个处理器通过在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
嗅探的缺点——总线风暴
由于Volatile的MESI缓存一致性协议,需要不断地从主内存嗅探和CAS不断循环,无效交互会导致总线带宽达到峰值。
所以不要大量使用Volatile,至于什么时候使用Volatile时候锁,什么时候使用锁,根据场景区分。
缓存一致性协议
为了解决缓存不一致的问题,我们需要一种机制来约束各个核,也就是缓存一致性协议。
我们常用的缓存一致性协议都是属于“snooping(窥探)”协议,各个核能够时刻监控自己和其他核的状态,从而统一管理协调。窥探的思想是:CPU的各个缓存是独立的,但是内存却是共享的,所有缓存的数据最终都通过总线写入同一个内存,因此CPU各个核都能“看见”总线,即各个缓存不仅在进行内存数据交换的时候访问总线,还可以时刻“窥探”总线,监控其他缓存在干什么。因此当一个缓存在往内存中写数据时,其他缓存也都能“窥探”到,从而按照一致性协议保证缓存间的同步。
MESI协议
MESI协议是一种常用的缓存一致性协议,它通过定义一个状态机来保证缓存的一致性。在MESI协议中有四种状态,这些状态都是针对缓存行(缓存由多个缓存行组成,缓存行的大小单位与机器的位数相关)。
- (I)Invalid状态:缓存行无效状态。要么该缓存行数据已经过时,要么缓存行数据已经不在缓存中。对于无效状态,可直接认为缓存行未加载进缓存。
- (S)Shared状态:缓存行共享状态。缓存行数据与内存中对应数据保持一致,多个缓存中的相应缓存行都是共享状态。该状态下的缓存行只允许读取,不允许写。
- (E)Exclusive状态:缓存行独有状态。该缓存行中的数据与内存中对应数据保持一致,当某缓存行是独有状态,其他缓存对应的缓存行都必须为无效状态。
- (M)Modified状态:缓存行已修改状态。缓存行中的数据为脏数据,与内存中的对应数据不一致。如果一个缓存行为已修改状态,那么其他缓存中对应缓存行都必须为无效状态。另外,如果该状态下的缓存行状态被修改为无效,那么脏段必须先回写入内存中。
MESI协议的定律:所有M状态下的缓存行(脏数据)回写后,任意缓存级别中的缓存行的数据都与内存保持一致。另外,如果某个缓存行处于E状态,那么在其他的缓存中就不会存在该缓存行。
MESI协议保证了缓存的强一致性,在原理上提供了完整的顺序一致性。可以说在MESI协议实现的内存模型下,缓存是绝对一致的,但是这也会导致一些效率的问题,我们平时使用的机器往往都不会采用这种强内存模型,而是在这个基础上去使用较为弱一些的内存模型:如允许CPU读写指令的重排序等。这些弱内存模型可以带来一定的效率提升,但是也引入了一些语义上的问题。
18、synchronized 关键字和 volatile 关键字的区别
- volatile关键字是线程同步的轻量级实现,所以volatile性能比synchronized关键字要好。但是volatile关键字只能用于变量而synchronized关键字可以修饰方法以及代码块。
- 多线程访问volatile关键字不会发生阻塞,而synchronized关键字可能会发生阻塞。、
- volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized关键字解决的是多个线程之间访问资源的同步性。
- volatile关键字能保证数据的可见性,但不能保证数据的原子性。synchronized关键字两者都能保证。
19、使用线程池的好处以及缺点?
- 降低资源消耗。通过重复利用已创建的线程,降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。
缺点:
1.非核心线程的创建时机
1.1) 核心线程的数量是 corePoolSize 的值,非核心线程的数量是 maxinumPoolSize - corePoolSize ;
1.2) 非核心线程创建的触发时机是:当前线程池中核心线程已满,且没有空闲的线程,还有任务等待队列已满,
满足上面的所有条件,才会去创建线程去执行新提交的任务;
1.3) 如果线程池中的线程数量达到 maxinumPoolSize 的值,此时还有任务进来,就会执行拒绝策略,抛弃任务或者其他
如果拒绝策略是抛弃任务的话,有一种场景,就会造成大量任务的丢弃,就是瞬时冲高的情况下。
2.排队任务调度策略
当线程池中核心线程数量已达标,且没有空闲线的情况下,在产生的任务,会加入到等待队列中去,这样一直持续下去,
等到等待队列已满,在来的任务,会创建非核心线程去执行新提交的任务,那么就产生一种结果,在等待队列中的任务是先提
交的任务,反而没有在此时提交的任务先执行。
任务的执行顺序和任务的提交顺序不一致,如果业务需求的任务是有先后依赖关系的,就会降低线程的调度效率
二.优化策略
1. 非核心线程的创建时机
Java线程池的调度策略是,当等待队列已满,才会去创建分核心线程去执行新的任务
优化方案:是自定义一个等待队列,当队列的值达到一定的阈值,就开始创建分核心线程去执行,
而不是等到队列已满,才去创建
2.任务调度策略 - 希望任务调度是有序的
基本思路:
2.1) 采用任务代理类,将任务绑定时机延迟到任务执行时,而不是添加时
2.2) 增加新的任务队列,按添加顺序保存真正的执行任务
2.3) 运行时,动态从新增任务队列中获取头部任务,做到FIFO
20、说一说几种常见的线程池及适用场景?(重要)
对应参数作用如下:
int corePoolSize:线程池中维护的核心线程数数量,这里维护的线程是即便线程空闲状态也会保持持有不释放的。
int maximumPoolSize:线程池最多能够持有的线程数,如果任务过多,线程数会超过corePoolSize,此时会继续创建新的线程来处理任务,但是最终线程数不能超过maximumPoolSize。
long keepAliveTime:当线程数超过corePoolSize时,超出部分线程在任务执行完毕后不会马上释放,而是会进入等待,但是不会一直等待,当等待时间超过keepAliveTime后会回收对应线程。
TimeUnit unit: 这个很简单,就是keepAliveTime的单位
BlockingQueue<Runnable> workQueue:这是一个队列,当外部调用线程池的execute方法请求线程池执行对应任务的时候,可能不一定能够马上获取的执行所需的线程,那么此时会先将这些提交过来的任务放到这个workQueue中,当后续有线程资源空出来后再从队列中取出等待的任务执行。
ThreadFactory threadFactory: 这个是一个线程创建的工厂类,通过它对外开放线程创建的逻辑,因为可能某些任务需要创建一些封装的特殊线程,此时可以通过自定义一个线程创建工厂,传入线程池创建构造函数中。
RejectedExecutionHandler handler: 这个是设置拒绝策略的,因为有可能我们传入的队列设置了队列的最大长度或者是提交任务的时候还没来得及启动线程池已经要改变状态为SHUTDOWN/STOP,因为以上原因导致任务提交失败,此时就会调用我们设置好的拒绝策略来进行处理。
了解了这些构造参数作用后,我们再来看四种线程池的构造方式和分别对应的应用场景。
可以创建(Executors.newXXX)3种类型的ThreadPoolExecutor:FixedThreadPool、SingleThreadExecutor、CachedThreadPool。
1 FixedThreadPool:可重用固定线程数的线程池。(适用于负载比较重的服务器)
FixedThreadPool使用无界队列LinkedBlockingQueue作为线程池的工作队列
该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
可以看到其中corePoolSize和maximumPoolSize大小一样,并且keepAliveTime=0意思就是不过期,说明构造的是一个定长的线程池,线程创建后会一直存在,这样的好处是可以很好的控制资源使用,适用于负载比较高的场景。
2 SingleThreadExecutor:只会创建一个线程执行任务。(适用于需要保证顺序执行各个任务;并且在任意时间点,没有多线程活动的场景。)
SingleThreadExecutorl也使用无界队列LinkedBlockingQueue作为工作队列
若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
可以看到这是一个长度为1的固定线程池,缓存任务的队列是LinkedBlockingQueue,意味着可以无限制的追加等待的任务,由于线程池内部只有一个线程,所以可以理解为任务执行是依据提交顺序顺序执行的,可以保证任务执行顺序,所以对于后台需要单个线程执行并且保证提交任务顺序性的场景,比较适合使用这种线程池。
3 CachedThreadPool:是一个会根据需要调整线程数量的线程池。(大小无界,适用于执行很多的短期异步任务的小程序,或负载较轻的服务器)
CachedThreadPool使用没有容量的SynchronousQueue作为线程池的工作队列,但CachedThreadPool的maximumPool是无界的。
线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
可以看到构造函数中核心线程数为0,线程最大数为Integer.MAX_VALUE,线程回收阀值为60s,采用的是SynchronousQueue这个同步队列。那么这种类型的意思就是说,默认没任务的时候是不会有核心线程的,只有有任务进来之后才会创建线程执行任务,然后任务执行完毕之后60s内如果有其它任务提交,则直接用还没回收的空闲线程来执行,这种模式比较适合的场景是:很多短期异步任务的任务需要提交到线程池执行的情况。
4 ScheduledThreadPool:继承自ThreadPoolExecutor。它主要用来在给定的延迟之后运行任务,或者定期执行任务。使用DelayQueue作为任务队列。
首先,这种线程池中的线程也是创建后一直存在的,比较特殊的是它的队列是一个DelayedWorkQueue队列,这个队列的作用是可以对任务进行延迟执行任务。
21、线程池都有哪几种工作队列?(重要)
- ArrayBlockingQueue:是一个基于数组结构的有界阻塞队列,此队列按FIFO(先进先出)原则对元素进行排序。
- LinkedBlockingQueue:是一个基于链表结构的阻塞队列,此队列按FIFO排序元素,吞吐量通常要高于ArrayBlockingQueue。静态工厂方法Executors.newFixedThreadPool()使用了这个队列。
- SynchronousQueue:是一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于Linked-BlockingQueue,静态工厂方法Executors.newCachedThreadPool使用了这个队列。
- PriorityBlockingQueue:一个具有优先级的无限阻塞队列。
22、创建线程的几种方式?(重要)
有4种方式:继承Thread类、实现Runnable接口、实现Callable接口、使用Executor框架来创建线程池。
(1)通过继承Thread类创建线程
public class MyThread extends Thread {//继承Thread类
//重写run方法
public void run(){
}
}
public class Main {
public static void main(String[] args){
new MyThread().start(); //创建并启动线程
}
}
(2)通过实现Runnable接口来创建线程
public class MyThread2 implements Runnable {//实现Runnable接口
//重写run方法
public void run(){
}
}
public class Main {
public static void main(String[] args){
//创建并启动线程
MyThread2 myThread=new MyThread2();
Thread thread=new Thread(myThread);
thread().start();
//或者 new Thread(new MyThread2()).start();
}
}
不管是继承Thread还是实现Runnable接口,多线程代码都是通过运行Thread的start()方法来运行的。
(3)实现Callable接口来创建线程
与实现Runnable接口类似,和Runnable接口不同的是,Callable接口提供了一个call() 方法作为线程执行体,call()方法比run()方法功能要强大:call()方法可以有返回值、call()方法可以声明抛出异常。
public class Main {
public static void main(String[] args){
MyThread3 th=new MyThread3();
//使用Lambda表达式创建Callable对象
//使用FutureTask类来包装Callable对象
FutureTask<Integer> future=new FutureTask<Integer>(
(Callable<Integer>)()->{
return 5;
}
);
new Thread(task,"有返回值的线程").start();//实质上还是以Callable对象来创建并启动线程
try{
System.out.println("子线程的返回值:"+future.get());//get()方法会阻塞,直到子线程执行结束才返回
}catch(Exception e){
ex.printStackTrace();
}
}
}
(4)使用Executor框架来创建线程池
Executors.newXXXX: newFixedThreadPool(int )、newSingleThreadExecutor、newCachedThreadPool、newScheduledThreadPool(int)
通过Executors的以上四个静态工厂方法获得 ExecutorService实例,而后可以执行Runnable任务或Callable任务。
- Executor执行Runnable任务:
通过Executors的以上四个静态工厂方法获得 ExecutorService实例,而后调用该实例的execute(Runnable command)方法即可。一旦Runnable任务传递到execute()方法,该方法便会自动在一个线程上。
public class TestCachedThreadPool{
public static void main(String[] args){
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < 5; i++){
executorService.execute(new TestRunnable());
System.out.println("************* a" + i + " *************");
}
executorService.shutdown();
}
} class TestRunnable implements Runnable{
//重写run方法 public void run(){
System.out.println(Thread.currentThread().getName() + "线程被调用了。");
}
- Executor执行Callable任务:
当将一个Callable的对象传递给ExecutorService的submit方法,则该call方法自动在一个线程上执行,并且会返回执行结果Future对象。同样,将Runnable的对象传递给ExecutorService的submit方法,则该run方法自动在一个线程上执行,并且会返回执行结果Future对象,但是在该Future对象上调用get方法,将返回null。
public class CallableDemo{
public static void main(String[] args){
ExecutorService executorService = Executors.newCachedThreadPool();
List<Future<String>> resultList = new ArrayList<Future<String>>();
//创建10个任务并执行
for (int i = 0; i < 10; i++){
//使用ExecutorService执行Callable类型的任务,并将结果保存在future变量中
Future<String> future = executorService.submit(new TaskWithResult(i));
//将任务执行结果存储到List中 resultList.add(future);
}
//遍历任务的结果
for (Future<String> fs : resultList){
try{
while(!fs.isDone);//Future返回如果没有完成,则一直循环等待,直到Future返回完成
System.out.println(fs.get()); //打印各个线程(任务)执行的结果
}catch(InterruptedException e){
e.printStackTrace();
}catch(ExecutionException e){
e.printStackTrace();
}finally{
//启动一次顺序关闭,执行以前提交的任务,但不接受新任务 executorService.shutdown();
}
}
}
} class TaskWithResult implements Callable<String>{
private int id;
public TaskWithResult(int id){
this.id = id;
}
// 重写call()方法
public String call() throws Exception {
System.out.println("call()方法被自动调用!!! " + Thread.currentThread().getName());
//该返回结果将被Future的get方法得到
return "call()方法被自动调用,任务返回的结果是:" + id + " " + Thread.currentThread().getName();
}
}
实现Runnable接口和Callable接口的区别?
Runnable接口或Callable接口实现类都可以被ThreadPoolExecutor或ScheduledThreadPoolExecutor执行。两者的区别在于 Runnable 接口不会返回结果但是 Callable 接口可以返回结果。
执行execute()方法和submit()方法的区别是什么呢?
1) execute() 方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;
2) submit() 方法用于提交需要返回值的任务。线程池会返回一个Future类型的对象,通过这个Future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。
23.线程池参数?
①corePoolSize:线程池的基本大小,当提交一个任务到线程池时,线程池会创建一个线程来执行任务,即使其他空闲的基本线程能够执行新任务也会创建线程,等到需要执行的任务数大于线程池基本大小时就不再创建。说白了就是,即便是线程池里没有任何任务,也会有corePoolSize个线程在候着等任务。
②maximumPoolSize:最大线程数,不管你提交多少任务,线程池里最多工作线程数就是maximumPoolSize。
③keepAliveTime:线程的存活时间。当线程池里的线程数大于corePoolSize时,如果等了keepAliveTime时长还没有任务可执行,则线程退出。
⑤unit:这个用来指定keepAliveTime的单位,比如秒:TimeUnit.SECONDS。
⑥workQueue:用于保存等待执行任务的阻塞队列,提交的任务将会被放到这个队列里。
⑦threadFactory:线程工厂,用来创建线程。主要是为了给线程起名字,默认工厂的线程名字:pool-1-thread-3。
⑧handler:拒绝策略,即当线程和队列都已经满了的时候,应该采取什么样的策略来处理新提交的任务。默认策略是AbortPolicy(抛出异常),其他的策略还有:CallerRunsPolicy(只用调用者所在线程来运行任务)、DiscardOldestPolicy(丢弃队列里最近的一个任务,并执行当前任务)、DiscardPolicy(不处理,丢弃掉)
配置线程池参数 线程池监控
24.线程池执行流程?
任务被提交到线程池,会先判断当前线程数量是否小于corePoolSize,如果小于则创建线程来执行提交的任务,否则将任务放入workQueue队列,如果workQueue满了,则判断当前线程数量是否小于maximumPoolSize,如果小于则创建线程执行任务,否则就会调用handler,以表示线程池拒绝接收任务。
25、如何停止一个线程
使用volatile变量终止正常运行的线程 + 抛异常法/Return法
组合使用interrupt方法与interruptted/isinterrupted方法终止正在运行的线程 + 抛异常法/Return法
使用interrupt方法终止 正在阻塞中的 线程
26、主线程等待子线程运行完毕再运行的方法
(1). Join
Thread提供了让一个线程等待另一个线程完成的方法 — join()方法。当在某个程序执行流程中调用其它线程的join()方法时,调用线程将被阻塞,直到被join()方法加入的join线程执行完毕为止,在继续运行。join()方法的实现原理是不停检查join线程是否存活,如果join线程存活则让当前线程永远等待。直到join线程完成后,线程的this.notifyAll()方法会被调用。
(2). CountDownLatch
Countdown Latch允许一个或多个线程等待其他线程完成操作。CountDownLatch的构造函数接收一个int类型的参数作为计数器,如果你想等待N个点完成,这里就传入N。当我们调用countDown方法时,N就会减1,await方法会阻塞当前线程,直到N变成0。这里说的N个点,可以使用N个线程,也可以是1个线程里的N个执行步骤。
27怎么实现不同线程的可见性
1.Java的内存模型
Java Memory Model (JAVA 内存模型)描述线程之间如何通过内存(memory)来进行交互。 具体说来, JVM中存在一个主存区(Main Memory或Java Heap Memory),对于所有线程进行共享,而每个线程又有自己的工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝,线程对所有变量的操作并非发生在主存区,而是发生在工作内存中,而线程之间是不能直接相互访问,变量在程序中的传递,是依赖主存来完成的。
2、内存可见性
从上图可知,如果线程A对共享变量X进行了修改,但是线程A没有及时把更新后的值刷入到主内存中,而此时线程B从主内存读取共享变量X的值,所以X的值是原始值,那么我们就说对于线程B来讲,共享变量X的更改对线程B是不可见的。
3.如何实现可见?
实现可见有四类关键字,volatile,java.util.concurrent中实现的原子操作类,synchronized,还有final
volatile
volatile赋予了变量可见——禁止编译器对成员变量进行优化,它修饰的成员变量在每次被线程访问时,都强迫从内存中重读该成员变量的值;而且,当成员变量发生变化时,强迫线程将变化值回写到共享内存,这样在任何时刻两个不同线程总是看到某一成员变量的同一个值,这就是保证了可见性。简单来说就是读必须从主内存读,写必须写到主内存中。
由于使用volatile屏蔽掉了VM中必要的代码优化,所以在效率上比较低,因此一定在必要时才使用此关键字。 就跟C中的一样 禁止编译器进行优化。
使用建议:
1.在上面的asleep状态变量的例子中,非常适合用volatile关键字,它用来检测某个状态变量以判断是否进行下一步操作。它常常用做中断或状态的标志。
2.当要访问的变量已在synchronized代码块中,或者为final时,不必使用。原因很简单,因为synchronized和final都能让变量可见。
synchronized
synchronized的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store和write操作)”这条规则获得的。
Java.util.concurrent中实现的原子操作类
Java.util.concurrent中实现的原子操作类包括:
AtomicBoolean、AtomicInteger、AtomicLong、AtomicReference。
其底层就是volatile和CAS 共同作用的结果:
首先使用了volatile 保证了内存可见性。
然后使用了CAS(compare-and-swap)算法 保证了原子性。
其中CAS算法的原理就是里面包含三个值:内存值A 预估值V 更新值 B 当且仅当 V == A 时,V = B; 否则,不会执行任何操作。
final
被final修饰的字段是构造器一旦初始化完成,并且构造器没有把“this”引用传递出去,那么在其它线程中就能看见final字段的值。
28如何保证线程安全?
考察点:线程
通过合理的时间调度,避开共享资源的存取冲突。另外,在并行任务设计上可以通过适当的策略,保证任务与任务之间不存在共享资源,设计一个规则来保证一个客户的计算工作和数据访问只会被一个线程或一台工作机完成,而不是把一个客户的计算工作分配给多个线程去完成。
29 start 与 与 run 区别
1、 start()方法来启动线程,真正实现了多线程运行。这时无需等待 run 方法体代码执行完毕,可以直接继续执行下面的代码。
2、通过调用 Thread 类的 start()方法来启动一个线程, 这时此线程是处于就绪状态, 并没有运行。
3、方法 run()称为线程体,它包含了要执行的这个线程的内容,线程就进入了运行状态,开始运行 run() 方法当中的代码。 run()方法运行结束, 此线程终止。然后 CPU 再调度其它线程。
线程的run()方法是由java虚拟机直接调用的,如果我们没有启动线程(没有调用线程的start()方法)而是在应用代码中直接调用run()方法,那么这个线程的run()方法其实运行在当前线程(即run()方法的调用方所在的线程)之中,而不是运行在其自身的线程中,从而违背了创建线程的初衷;
30什么时候使用多线程
如果你的应用程序需要采取以下的操作,那么你尽可在编程的时候考虑多线程机制:
(1) 连续的操作,需要花费忍无可忍的过长时间才可能完成
(2) 并行计算
(3) 为了等待网络、文件系统、用户或其他I/O响应而耗费大量的执行时间
所以说,在动手之前,先保证自己的应用程序中是否出现了以上3种情形。
为什么需要多线程(解释何时考虑使用线程)
从用户的角度考虑,就是为了得到更好的系统服务;从程序自身的角度考虑,就是使目标任务能够尽可能快的完成,更有效的利用系统资源。综合考虑,一般以下场合需要使用多线程:
1、 程序包含复杂的计算任务时
主要是利用多线程获取更多的CPU时间(资源)。
2、 处理速度较慢的外围设备
比如:打印时。再比如网络程序,涉及数据包的收发,时间因素不定。使用独立的线程处理这些任务,可使程序无需专门等待结果。
3、 程序设计自身的需要
WINDOWS系统是基于消息循环的抢占式多任务系统,为使消息循环系统不至于阻塞,程序需要多个线程的来共同完成某些任务。
多线程的缺点:
1. 如果有大量的线程,会影响性能,因为操作系统需要在它们之间切换.
2. 更多的线程需要更多的内存空间
3. 线程中止需要考虑对程序运行的影响.
4. 通常块模型数据是在多个线程间共享的,需要防止线程死锁情况的发生
31 synchronized的修饰方法和修饰代码块区别
同步方法和同步块:
同步方法就是在方法前加关键字synchronized,然后被同步的方法一次只能有一个线程进入,其他线程等待。
而同步块则是在方法内部使用大括号使得一个代码块得到同步。同步块会有一个同步的”目标“,使得同步块更加灵活一些(同步块可以通过”目标“决定需要锁定的对象)。一般情况下,如果此”目标“为this,那么同步方法和同步块没有太大的区别。
非静态和静态的区别主要在于(以同步方法为例):非静态的同步方法是锁定类的实例的,而静态的同步方法是锁定类的;
也就是说,对于非静态的同步方法,在同一时刻,一个类的一个实例中,只有一个线程能进入同步的方法。但是对于多个实例,每一个实例的一个线程都可以进入同一同步的方法。
类锁
类锁,是用来锁类的,我们知道一个类的所有对象共享一个class对象,共享一组静态方法,类锁的作用就是使持有者可以同步地调用静态方法。当synchronized指定修饰静态方法或者class对象的时候,拿到的就是类锁,类锁是所有对象共同争抢一把。
对象锁
对象锁,是用来对象的,虚拟机为每个的非静态方法和非静态域都分配了自己的空间,不像静态方法和静态域,是所有对象共用一组。
所以synchronized修饰非静态方法或者this的时候拿到的就是对象锁,对象锁是每个对象各有一把的,即同一个类如果有两个对象。
如果synchronized加在一个类的普通方法上,那么相当于synchronized(this)。
如果synchronized加载一个类的静态方法上,那么相当于synchronized(Class对象)。
同步方法直接在方法上加synchronized实现加锁,同步代码块则在方法内部加锁,很明显,同步方法锁的范围比较大,而同步代码块范围要小点,一般同步的范围越大,性能就越差,一般需要加锁进行同步的时候,肯定是范围越小越好,这样性能更好
32 concurrent包有啥内容
concurrent包是jdk1.5引入的重要的包,主要代码由大牛Doug Lea完成,其实是在jdk1.4时代,由于java语言内置对多线程编程的支持比较基础和有限,所以他写了这个,因为实在太过于优秀,所以被加入到jdk之中;
通常所说的concurrent包基本有3个package组成
java.util.concurrent:提供大部分关于并发的接口和类,如BlockingQueue,Callable,ConcurrentHashMap,ExecutorService, Semaphore等
java.util.concurrent.atomic:提供所有原子操作的类, 如AtomicInteger, AtomicLong等;
java.util.concurrent.locks:提供锁相关的类, 如Lock, ReentrantLock, ReadWriteLock, Condition等;
concurrent包的优点:
1. 首先,功能非常丰富,诸如线程池(ThreadPoolExecutor),CountDownLatch等并发编程中需要的类已经有现成的实现,不需要自己去实现一套; 毕竟jdk1.4对多线程编程的主要支持几乎就只有Thread, Runnable,synchronized等
- concurrent包里面的一些操作是基于硬件级别的CAS(compare and swap),就是在cpu级别提供了原子操作,简单的说就可以提供无阻塞、无锁定的算法; 而现代cpu大部分都是支持这样的算法的;
Java并发包(java.util.concurrent及其子包)提供的并发工具类
- 比synchronized更加高级的各种同步结构,如:Semaphore,CyclicBarrier, CountDownLatch
- 各种线程安全的容器(主要有四类:Queue,List,Set,Map),如:ConcurrentHashMap,ConcurrentSkipListMap,CopyOnWriteArrayList
- 各种并发队列的实现,如各种BlockingQueue的实现(ArrayBlockingQueue, LinkedBlockingQueue, SynchorousQueue, PriorityBlockingQueue,DelayQueue,LinkedTranferQueue)等。
- Executor框架与线程池
33 BlockingQueue原理
1.前言:
在新增的Concurrent包中,BlockingQueue很好的解决了多线程中,如何高效安全“传输”数据的问题。通过这些高效并且线程安全的队列类,为我们快速搭建高质量的多线程程序带来极大的便利。本文详细介绍了BlockingQueue家庭中的所有成员,包括他们各自的功能以及常见使用场景。
2.认识BlockingQueue
阻塞队列,顾名思义,首先它是一个队列,而一个队列在数据结构中所起的作用大致如下图所示:
从上图我们可以很清楚看到,通过一个共享的队列,可以使得数据由队列的一端输入,从另外一端输出;
常用的队列主要有以下两种:(当然通过不同的实现方式,还可以延伸出很多不同类型的队列,DelayQueue就是其中的一种)
先进先出(FIFO):先插入的队列的元素也最先出队列,类似于排队的功能。从某种程度上来说这种队列也体现了一种公平性。
后进先出(LIFO):后插入队列的元素最先出队列,这种队列优先处理最近发生的事件。
多线程环境中,通过队列可以很容易实现数据共享,比如经典的“生产者”和“消费者”模型中,通过队列可以很便利地实现两者之间的数据共享。假设我们有若干生产者线程,另外又有若干个消费者线程。如果生产者线程需要把准备好的数据共享给消费者线程,利用队列的方式来传递数据,就可以很方便地解决他们之间的数据共享问题。但如果生产者和消费者在某个时间段内,万一发生数据处理速度不匹配的情况呢?理想情况下,如果生产者产出数据的速度大于消费者消费的速度,并且当生产出来的数据累积到一定程度的时候,那么生产者必须暂停等待一下(阻塞生产者线程),以便等待消费者线程把累积的数据处理完毕,反之亦然。然而,在concurrent包发布以前,在多线程环境下,我们每个程序员都必须去自己控制这些细节,尤其还要兼顾效率和线程安全,而这会给我们的程序带来不小的复杂度。好在此时,强大的concurrent包横空出世了,而他也给我们带来了强大的BlockingQueue。(在多线程领域:所谓阻塞,在某些情况下会挂起线程(即阻塞),一旦条件满足,被挂起的线程又会自动被唤醒)
下面两幅图演示了BlockingQueue的两个常见阻塞场景:
如上图所示:当队列中没有数据的情况下,消费者端的所有线程都会被自动阻塞(挂起),直到有数据放入队列。
如上图所示:当队列中填满数据的情况下,生产者端的所有线程都会被自动阻塞(挂起),直到队列中有空的位置,线程被自动唤醒。
这也是我们在多线程环境下,为什么需要BlockingQueue的原因。作为BlockingQueue的使用者,我们再也不需要关心什么时候需要阻塞线程,什么时候需要唤醒线程,因为这一切BlockingQueue都给你一手包办了。既然BlockingQueue如此神通广大,让我们一起来见识下它的常用方法:
3.BlockingQueue的核心方法
3.1放入数据:
offer(anObject):表示如果可能的话,将anObject加到BlockingQueue里,即如果BlockingQueue可以容纳,
则返回true,否则返回false.(本方法不阻塞当前执行方法的线程)
则返回true,否则返回false.(本方法不阻塞当前执行方法的线程)
offer(E o, long timeout, TimeUnit unit),可以设定等待的时间,如果在指定的时间内,还不能往队列中加入BlockingQueue,则返回失败。
put(anObject):把anObject加到BlockingQueue里,如果BlockQueue没有空间,则调用此方法的线程被阻断,直到BlockingQueue里面有空间再继续.
3.2获取数据:
poll(time):取走BlockingQueue里排在首位的对象,若不能立即取出,则可以等time参数规定的时间,
取不到时返回null;
poll(long timeout, TimeUnit unit):从BlockingQueue取出一个队首的对象,如果在指定时间内,队列一旦有数据可取,则立即返回队列中的数据。否则知道时间超时还没有数据可取,返回失败。
take():取走BlockingQueue里排在首位的对象,若BlockingQueue为空,阻断进入等待状态直到BlockingQueue有新的数据被加入;
drainTo():一次性从BlockingQueue获取所有可用的数据对象(还可以指定获取数据的个数),
通过该方法,可以提升获取数据效率;不需要多次分批加锁或释放锁。
4.常见BlockingQueue
在了解了BlockingQueue的基本功能后,让我们来看看BlockingQueue家庭大致有哪些成员?
1. ArrayBlockingQueue
基于数组的阻塞队列实现,在ArrayBlockingQueue内部,维护了一个定长数组,以便缓存队列中的数据对象,这是一个常用的阻塞队列,除了一个定长数组外,ArrayBlockingQueue内部还保存着两个整形变量,分别标识着队列的头部和尾部在数组中的位置。
ArrayBlockingQueue在生产者放入数据和消费者获取数据,都是共用同一个锁对象,由此也意味着两者无法真正并行运行,这点尤其不同于LinkedBlockingQueue;按照实现原理来分析,LinkedBlockingQueue完全可以采用分离锁,从而实现生产者和消费者操作的完全并行运行。Doug Lea之所以没这样去做,也许是因为ArrayBlockingQueue的数据写入和获取操作已经足够轻巧,以至于引入独立的锁机制,除了给代码带来额外的复杂性外,其在性能上完全占不到任何便宜。 ArrayBlockingQueue和LinkedBlockingQueue间还有一个明显的不同之处在于,前者在插入或删除元素时不会产生或销毁任何额外的对象实例,而后者则会生成一个额外的Node对象。这在长时间内需要高效并发地处理大批量数据的系统中,其对于GC的影响还是存在一定的区别。而在创建ArrayBlockingQueue时,我们还可以控制对象的内部锁是否采用公平锁,默认采用非公平锁。
2.LinkedBlockingQueue
基于链表的阻塞队列,同ArrayListBlockingQueue类似,其内部也维持着一个数据缓冲队列(该队列由一个链表构成),当生产者往队列中放入一个数据时,队列会从生产者手中获取数据,并缓存在队列内部,而生产者立即返回;只有当队列缓冲区达到最大值缓存容量时(LinkedBlockingQueue可以通过构造函数指定该值),才会阻塞生产者队列,直到消费者从队列中消费掉一份数据,生产者线程会被唤醒,反之对于消费者这端的处理也基于同样的原理。而LinkedBlockingQueue之所以能够高效的处理并发数据,还因为其对于生产者端和消费者端分别采用了独立的锁来控制数据同步,这也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。
作为开发者,我们需要注意的是,如果构造一个LinkedBlockingQueue对象,而没有指定其容量大小,LinkedBlockingQueue会默认一个类似无限大小的容量(Integer.MAX_VALUE),这样的话,如果生产者的速度一旦大于消费者的速度,也许还没有等到队列满阻塞产生,系统内存就有可能已被消耗殆尽了。
3. DelayQueue
DelayQueue中的元素只有当其指定的延迟时间到了,才能够从队列中获取到该元素。DelayQueue是一个没有大小限制的队列,因此往队列中插入数据的操作(生产者)永远不会被阻塞,而只有获取数据的操作(消费者)才会被阻塞。
使用场景:
DelayQueue使用场景较少,但都相当巧妙,常见的例子比如使用一个DelayQueue来管理一个超时未响应的连接队列。
4. PriorityBlockingQueue
基于优先级的阻塞队列(优先级的判断通过构造函数传入的Compator对象来决定),但需要注意的是PriorityBlockingQueue并不会阻塞数据生产者,而只会在没有可消费的数据时,阻塞数据的消费者。因此使用的时候要特别注意,生产者生产数据的速度绝对不能快于消费者消费数据的速度,否则时间一长,会最终耗尽所有的可用堆内存空间。在实现PriorityBlockingQueue时,内部控制线程同步的锁采用的是公平锁。
5. SynchronousQueue
一种无缓冲的等待队列,类似于无中介的直接交易,有点像原始社会中的生产者和消费者,生产者拿着产品去集市销售给产品的最终消费者,而消费者必须亲自去集市找到所要商品的直接生产者,如果一方没有找到合适的目标,那么对不起,大家都在集市等待。相对于有缓冲的BlockingQueue来说,少了一个中间经销商的环节(缓冲区),如果有经销商,生产者直接把产品批发给经销商,而无需在意经销商最终会将这些产品卖给那些消费者,由于经销商可以库存一部分商品,因此相对于直接交易模式,总体来说采用中间经销商的模式会吞吐量高一些(可以批量买卖);但另一方面,又因为经销商的引入,使得产品从生产者到消费者中间增加了额外的交易环节,单个产品的及时响应性能可能会降低。
声明一个SynchronousQueue有两种不同的方式,它们之间有着不太一样的行为。公平模式和非公平模式的区别:
如果采用公平模式:SynchronousQueue会采用公平锁,并配合一个FIFO队列来阻塞多余的生产者和消费者,从而体系整体的公平策略;
但如果是非公平模式(SynchronousQueue默认):SynchronousQueue采用非公平锁,同时配合一个LIFO队列来管理多余的生产者和消费者,而后一种模式,如果生产者和消费者的处理速度有差距,则很容易出现饥渴的情况,即可能有某些生产者或者是消费者的数据永远都得不到处理。
34 ThreadLocal
ThreadLocal和Synchronized都是为了解决多线程中相同变量的访问冲突问题,不同的点是
- Synchronized是通过线程等待,牺牲时间来解决访问冲突
- ThreadLocal是通过每个线程单独一份存储空间,牺牲空间来解决冲突,并且相比于Synchronized,ThreadLocal具有线程隔离的效果,只有在线程内才能获取到对应的值,线程外则不能访问到想要的值。
当某些数据是以线程为作用域并且不同线程具有不同的数据副本的时候,就可以考虑采用ThreadLocal。
什么是ThreadLocal
ThreadLocal提供了线程的局部变量,每个线程都可以通过set()和get()来对这个局部变量进行操作,但不会和其他线程的局部变量进行冲突,实现了线程的数据隔离~。
ThreadLocal在每个线程中对该变量会创建一个副本,即每个线程内部都会有一个该变量,且在线程内部任何地方都可以使用,线程之间互不影响,这样一来就不存在线程安全问题,也不会严重影响程序执行性能。
简要言之:往ThreadLocal中填充的变量属于当前线程,该变量对其他线程而言是隔离的。
ThreadLocal应用场景
1管理Connection
最典型的是管理数据库的Connection:当时在学JDBC的时候,为了方便操作写了一个简单数据库连接池,需要数据库连接池的理由也很简单,频繁创建和关闭Connection是一件非常耗费资源的操作,因此需要创建数据库连接池~
那么,数据库连接池的连接怎么管理呢??我们交由ThreadLocal来进行管理。为什么交给它来管理呢??ThreadLocal能够实现当前线程的操作都是用同一个Connection,保证了事务!
2避免一些参数传递
避免一些参数的传递的理解可以参考一下Cookie和Session:
- 每当我访问一个页面的时候,浏览器都会帮我们从硬盘中找到对应的Cookie发送过去。
- 浏览器是十分聪明的,不会发送别的网站的Cookie过去,只带当前网站发布过来的Cookie过去
浏览器就相当于我们的ThreadLocal,它仅仅会发送我们当前浏览器存在的Cookie(ThreadLocal的局部变量),不同的浏览器对Cookie是隔离的(Chrome,Opera,IE的Cookie是隔离的【在Chrome登陆了,在IE你也得重新登陆】),同样地:线程之间ThreadLocal变量也是隔离的….
那上面避免了参数的传递了吗??其实是避免了。Cookie并不是我们手动传递过去的,并不需要写<input name= cookie/>来进行传递参数…
在编写程序中也是一样的:日常中我们要去办理业务可能会有很多地方用到身份证,各类证件,每次我们都要掏出来很麻烦而如果用了ThreadLocal的话,ThreadLocal就相当于一个机构,ThreadLocal机构做了记录你有那么多张证件。用到的时候就不用自己掏了,问机构拿就可以了。在咨询时的时候就告诉机构:来,把我的身份证、房产证、学生证通通给他。在办理时又告诉机构:来,把我的身份证、房产证、学生证通通给他。…
ThreadLocal底层原理
首先,我们来看一下ThreadLocal的set()方法,因为我们一般使用都是new完对象,就往里边set对象了。
ThreadLocalMap是ThreadLocal的一个内部类。用Entry类来进行存储
我们的值都是存储到这个Map上的,key是当前ThreadLocal对象!
如果该Map不存在,则初始化一个:
如果该Map存在,则从Thread中获取!
Thread维护了ThreadLocalMap变量
从上面又可以看出,ThreadLocalMap是在ThreadLocal中使用内部类来编写的,但对象的引用是在Thread中!
于是我们可以总结出:Thread为每个线程维护了ThreadLocalMap这么一个Map,而ThreadLocalMap的key是LocalThread对象本身,value则是要存储的对象
有了上面的基础,我们看get()方法就一点都不难理解了:
ThreadLocal原理总结
- 每个Thread维护着一个ThreadLocalMap的引用
- ThreadLocalMap是ThreadLocal的内部类,用Entry来进行存储
- 调用ThreadLocal的set()方法时,实际上就是往ThreadLocalMap设置值,key是ThreadLocal对象,值是传递进来的对象
- 调用ThreadLocal的get()方法时,实际上就是往ThreadLocalMap获取值,key是ThreadLocal对象
- ThreadLocal本身并不存储值,它只是作为一个key来让线程从ThreadLocalMap获取value。
正因为这个原理,所以ThreadLocal能够实现“数据隔离”,获取当前线程的局部变量值,不受其他线程影响~
35 AQS原理
那么在 ReentrantLock 中,也一定会存在这样的需要去解决的问题。就是在多线程竞争重入锁时,竞争失败的线程是如何实现阻塞以及被唤醒的呢?
AQS:AbstractQueuedSynchronizer,即队列同步器。它是构建锁或者其他同步组件的基础框架(如ReentrantLock、ReentrantReadWriteLock、Semaphore等)。
在 Lock 中,用到了一个同步队列 AQS,全称 AbstractQueuedSynchronizer,它是一个同步工具也是 Lock 用来实现线程同步的核心组件。如果你搞懂了 AQS,那么 J.U.C 中绝大部分的工具都能轻松掌握。
AQS 的两种功能
从使用层面来说,AQS 的功能分为两种:独占和共享。
独占锁:每次只能有一个线程持有锁,比如前面给大家演示的 ReentrantLock 就是以独占方式实现的互斥锁;
共享锁:允 许 多 个 线 程 同 时 获 取 锁 , 并 发 访 问 共 享 资 源 , 比 如ReentrantReadWriteLock
AQS 队列内部维护的是一个 FIFO 的双向链表,这种结构的特点是每个数据结构都有两个指针,分别指向直接的后继节点和直接前驱节点。所以双向链表可以从任意一个节点开始很方便的访问前驱和后继。每个 Node 其实是由线程封装,当线程争抢锁失败后会封装成 Node 加入到 ASQ 队列中去;当获取锁的线程释放锁以后,会从队列中唤醒一个阻塞的节点(线程)。
释放锁以及添加线程对于队列的变化
当出现锁竞争以及释放锁的时候,AQS 同步队列中的节点会发生变化,首先看一下添加节点的场景。
这里会涉及到两个变化
1. 新的线程封装成 Node 节点追加到同步队列中,设置 prev 节点以及修改当前节点的前置节点的 next 节点指向自己
2. 通过 CAS 讲 tail 重新指向新的尾部节点
head 节点表示获取锁成功的节点,当头结点在释放同步状态时,会唤醒后继节点,如果后继节点获得锁成功,会把自己设置为头结点,节点的变化过程如下
这个过程也是涉及到两个变化
1. 修改 head 节点指向下一个获得锁的节点
2. 新的获得锁的节点,将 prev 的指针指向 null
设置 head 节点不需要用 CAS,原因是设置 head 节点是由获得锁的线程来完成的,而同步锁只能由一个线程获得,所以不需要 CAS 保证,只需要把 head 节点设置为原首节点的后继节点,并且断开原 head 节点的 next 引用即可
36 Wait() notify() notifyAall()
调用某个对象的wait()方法,相当于让当前线程交出(释放)此对象的monitor,然后进入等待状态,等待后续再次获得此对象的锁(Thread类中的sleep方法使当前线程暂停执行一段时间,从而让其他线程有机会继续执行,但它并不释放对象锁);
wait()方法是让线程释放对象锁,让其他线程拿到锁之后去优先执行,当其他全程唤醒wait()中的线程 或者 拿到对象锁的线程都执行完释放了对象锁之后,wait()中的线程才会再次拿到对象锁从而执行。
sleep()方法是让线程睡眠,此时并没有释放对象锁,其他想要拿到睡眠线程的对象锁的线程也就拿不到相应的对象锁,从而不能抢在它前面执行。
wait 方法:
使当前线程一直处于等待的状态,直到另一个线程在调用了notify()或者notifyAll()方法。
当前线程拥有当前对象的锁,即wait()方法应该在sychronized{}方法或者代码块中。调用wait()方法后,释放对当前对象锁的拥有。
notify()方法:
唤醒处于等待状态的线程,这里应该注意是,如果有多个线程处于等待的状态,那么调用一次notify()方法后,具体释放的是哪一个线程是不确定的,执行的过程是java虚拟机来实现的。
notifyAll方法 唤醒所有等待的线程
37 synchronized("字符串常量"){...}
在Java中是有常量池缓存的功能的,就是说如果我先声明了一个String str1 = “a”; * 再声明一个一样的字符串的时候,取值是从原地址去取的,也就是说是同一个对象。这也就导致了在锁字符串对象的时候,可以会取得意料之外的结果( * 字符串一样会取得相同锁)
因为t1和t2都锁住同一个固定字符串,那么t1持有锁之后,t2永远不会执行 修改测试方法 这里将线程t1和线程t2传进来的参数用new方式创建对象。这样就可以保证不是同一个对象。从而打印的会是异步打印
38 读写锁
锁(Lock)是java一个很重要的同步组件,Lock提供了跟synchronized关键字一样的功能,相比synchronized更加灵活,但是实现也更加复杂。
锁的分类:
锁主要分为排他锁和读写锁。
- 排他锁:在同一时刻只允许一个线程进行访问,其他线程等待;
- 读写锁:在同一时刻允许多个读线程访问,但是当写线程访问,所有的写线程和读线程均被阻塞。读写锁维护了一个读锁加一个写锁,通过读写锁分离的模式来保证线程安全,性能高于一般的排他锁。
java并发包提供了读写锁的具体实现ReentrantReadWriteLock,它主要提供了一下特性:
- 公平性选择:支持公平和非公平(默认)两种获取锁的方式,非公平锁的吞吐量优于公平锁;
- 可重入:支持可重入,读线程在获取读锁之后能够再次获取读锁,写线程在获取了写锁之后能够再次获取写锁,同时也可以获取读锁;
- 锁降级:线程获取锁的顺序遵循获取写锁,获取读锁,释放写锁,写锁可以降级成为读锁。
关于读写状态的设计
①作为已经实现的同步组件,读写锁同样是需要实现同步器来实现同步功能,同步器的同步状态就是读写锁的读写状态,只是读写锁的同步器需要在同步状态上维护多个读线程和写线程的状态。使用按位切割的方式将一个整形变量按照高低16位切割成两个部分。对比下图,低位值表示当前获取写锁的线程重入两次,高位的值表示当前获取读锁的线程重入一次。读写锁的获取伴随着读写状态值的更新。当低位为0000_0000_0000_0000的时候表示写锁已经释放,当高位为0000_0000_0000_0000的时候表示读锁已经释放。
②从下面的划分得到:当state值不等于0的时候,如果写状态(state & 0x0000FFFF)等于0的话,读状态是大于0的,表示读锁被获取;如果写状态不等于0的话,读锁没有被获取。这个特点也在源码中实现。
谈谈 ReadWriteLock 的应用场景?
答:首先 Lock 相对于 synchronized 来说更加面向对象,多个线程想要执行同步互斥就必须使用同一把 Lock 对象锁。而 ReadWriteLock(实现类 ReentrantReadWriteLock)读写锁提供了读锁和写锁接口,多个读锁不互斥,读锁与写锁互斥,多个写锁互斥,这些特性都是 JVM 控制的。
而多线程并发场景中对同一份数据进行读写操作会涉及到线程并发安全问题,也就是说会出现一个线程读数据的时候另一个线程在写数据的情况,或者一个线程在写数据的时候另一个线程也在写的情况,这样就会导致数据的不一致性。
而并发读写操作保证安全的常规解决办法是在读写操作上加入互斥锁,这种情况下并发读写效率会打折扣,因为大多数情况下我们对同一数据读操作的频率会高于写操作,而线程与线程间的并发读操作是不涉及并发安全问题的,所以没有必要给读操作加互斥锁,只要保证读写、写写并发操作上锁是互斥的就行。
由此读写锁就诞生了,ReentrantReadWriteLock 从名字上就可以看出来是可重入的读写锁,其允许多个线程获得 ReadLock,但只允许一个线程获得 WriteLock,所以读写锁的特点就是并发读读不互斥、并发读写互斥、并发写写互斥。
ReentrantReadWriteLock 内部有一个读锁和一个写锁,线程进入读锁的前提条件是没有其他线程占用写请求或者有写请求但调用线程和持有锁的线程是同一个线程;而线程进入写锁的前提条件是没有其他线程占用读锁,也没有其他线程占用写锁。
可以看到 ReentrantReadWriteLock 不支持锁升级,只支持锁降级。锁降级的意思就是对同一线程从写锁变成读锁,锁升级的意思就是对同一线程从读锁变成写锁。而读锁是多线程共享锁,写锁是多线程互斥锁,所以写锁的并发限制比读锁高,故升降就是从这个特性来的。
问:为什么可以锁降级,也就是说,为什么释放写锁之前可以获取读锁?
答:你既然拿到写锁了,其他线程就没法拿到读锁或者写锁,你再(在拿到写锁的线程中)拿读锁,其实不会和其他线程的写锁发送冲突的,因为你拿到写锁到写锁释放的这段时间,其他线程是无法拿到任何锁的。
问:为什么不可以锁升级,即为什么获取读锁之后不能再获取写锁?
答:锁升级就没法做到读写互斥了。两个线程都拿到了读锁,前一个线程升级成写锁
读写锁必须用在读并发且大量读少量写的场景,大量读,读并发,多读少写,这三个条件缺一不可,不然就没必要用读写锁,互斥锁就好,,后一个线程的读锁又没释放,所以就没法做到读写互斥了。
39 并行与并发
并行:
- 并行性是指同一时刻内发生两个或多个事件。
- 并行是在不同实体上的多个事件
并发:
- 并发性是指同一时间间隔内发生两个或多个事件。
- 并发是在同一实体上的多个事件
由此可见:并行是针对进程的,并发是针对线程的。

浙公网安备 33010602011771号