并发编程手册(精简版)
- 并发变成(精简版)
- java线程和操作系统线程的区别
- 用户态线程
- 内核态线程
- 线程源码
- 什么是进程
- 什么是线程
- 进程和线程的区别?
- 线程分类
- java中有哪些锁
- 乐观锁
- 悲观锁
- 自旋锁
- Synchronized 同步锁
- 锁的优化
并发变成(精简版)
java线程和操作系统线程的区别
- 用户态的线程
- 内核态的线程
- Java 线程源码
用户态线程
初期的多线程,线程是在用户空间下实现的。
什么意思? 我们都知道内存分用户空间和系统空间,系统空间是给操作系统使用的,用户空间是应用程序使用的,应用程序如果需要访问系统空间,需要进行系统调用,从用户态切换到内核态。
那怎么在用户空间实现的多线程呢?
实际上是操作系统按进程维度来调度,操作系统是不去管你用户线程的切换的,应用程序自己在用户空间实现线程的创建、维护和调度。模型如下图:
当线程在用户空间下实现时,操作系统对线程的存在一无所知,操作系统只能看到进程,而不能看到线程。所有的线程都是在用户空间实现。在操作系统看来,每一个进程只有一个线程。
这种方式的好处之一就是即使操作系统不支持线程,也可以通过库函数来支持线程。在JDK1.1中,就用的绿色线程,而不是原始线程。
green threads 是一种由运行环境或虚拟机(VM)调度,而不是由本地底层操作系统调度的线程。绿色线程并不依赖底层的系统功能,模拟实现了多线程的运行,这种线程的管理调配发生在用户空间而不是内核空间,所以它们可以在没有原生线程支持的环境中工作。
在Java 1.1中,绿色线程(至少在 Solaris 上)是JVM 中使用的唯一一种线程模型。 由于绿色线程和原生线程比起来在使用时有一些限制,随后的 Java 版本中放弃了绿色线程,转而使用native threads。
这种模式的优点和缺点都非常明显:
缺点: 因为操作系统不知道线程的存在,CPU的时间片切换是以进程为维度的,如果进程中有某个线程进行了某些耗时长的操作,会阻塞整个进程。另外当一个进程中的某一个线程(绿色线程)进行系统调用时,比如网络IO、缺页中断等操作而导致线程阻塞,操作系统也会阻塞整个进程,即使这个进程中其它线程还在工作。
优点: 使用库函数来实现的线程切换,就免去了用户态到内核态的切换,这个味道熟不熟,对了,Go的协程就有借鉴了一部分这个思想。
内核态线程
在 Java1.2 之后. Linux中的JVM是基于pthread
实现的, 可以直接说 Java 线程就是依赖操作系统实现的,是1:1的关系。
现在的Java中线程的本质,其实就是操作系统中的线程
另外我看很多资料上说 Java线程的实现采用的是LWP(轻量级进程),实际上从Linux 内核2.6开始,就把LinuxThread 换成了新的线程实现方式NPTL,NPTL解决了LinuxThread中绝大多数跟POSIX标准不兼容的特性,并提供了更好的性能,可扩展性及可维护性等等。
LinuxThread使用的是1 * 1模型,即每一个用户态线程都有一个内核的管理实体跟其对应,这个内核对应的管理实体就是进程,又称LWP(轻量级进程)
我们知道,每个线程都有它自己的线程上下文,线程上下文包括线程的ID、栈、程序计数器、通用的寄存器等的合集。总觉得上下文这个词很模棱二可,但是发现也找不到更合适的词来描述。
线程有自己的独立的上下文,由操作系统调度,但是也有一个缺点,那就是线程消耗资源太大了,例如在linux上,一个线程默认的栈大小是1M,单机创建几万个线程就有点吃力了。所以后来在编程语言的层面上,就出现了协程这个东西。
协程的模式有点类似结合了上面二种方式,即是在用户态做线程资源切换,也让操作系统在内核层做线程调度。
协程跟操作系统的线程是有映射关系的,例如我们建了m个协程,需要在N个线程上执行,这就是m: n的方案,这n个线程也是靠操作系统调度实现。
另外协程是按需使用栈内存的,所以理论上可以轻轻松松创建百万级的协程。
目前协程这块支持的最好的是go语言, 不过现在OpenJDK社区也正在为JDK增加协程的支持。
线程源码
我们在Java中调用 new Thread(Runnable ***).start()
方法时,怎么从用户态切到内核态,发送系统调用,在操作系统内核层中创建一个线程的呢?
这个可以一步步往下钻,文章最后会贴源代码地址,这里只把头和尾贴出来,关键点在JVM层系统调用创建线程的源码。
首先是native方法: private native void start0();
下到Thread.c 文件,:
总结来说,回答下文题,现今 Java 中线程的本质,其实就是操作系统中的线程,其线程库和线程模型很大程度上依赖于操作系统(宿主系统)的具体实现,比如在 Windows 中 Java 就是基于 Wind32 线程库来管理线程,且 Windows 采用的是一对一的线程模型。
什么是进程
进程是系统中正在运行的一个程序,是 资源分配的基本单位,每个进程都有独立的地址空间。
进程控制块(Process Control Block,PCB)描述了进程的基本信息和运行状态,所谓的创建和撤销进程,都是指对于 PCB 的操作;
什么是线程
线程是 独立调度的基本单位,被包含在进程中,是进程中的实际运作单位。一个进程中可以有多个线程,所有线程共享该进程的资源。
进程和线程的区别?
- 进程和线程均为并发单元,根本区别在于:进程不共享公共内存,但线程共享进程资源;
- 从系统的角度来看,进程相当于一个独立软件,在其自己的虚拟内存空间中运行。系统通过将内存中的进程分开,这样一旦某一进程失败也不会干扰公共内存来拖累其他进程。因此一般 进程是隔离的,通过进程间通信进行协作,进程间通信由操作系统定义为一种中间 API。
- 而线程是应用程序的一部分,和同一程序的其他线程共享公共内存,通过公共内存从而减少内存开销,能够更快的交换数据和进行线程间协作;
进程是资源分配单位,线程是调度基本单位
线程分类
Java 线程由两种,一种是 用户线程,一种是 守护线程;
守护线程
- 守护线程的特点
守护线程是一个较特殊的线程,主要被用作程序中后台调度以及支持性工作。当 Java 虚拟机中不存在非守护线程时,守护线程才会随着 JVM 一起结束工作;
- Java 中的典型守护线程
GC(垃圾回收器)
- 如何设置守护线程
`Thread.setDaemon(``true``);`
注意: Daemon
属性需要再启动线程前设置,不能再启动后设置;
java中有哪些锁
乐观锁
乐观锁是一种乐观思想,认为 读多写少,遇到并发写的可能性低,每次去拿数据时都认为别人不会修改,所以不会上锁。 但是 在更新时会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新), 若失败则要重复读-比较-写操作。
Java 中的乐观锁基本都是通过 CAS 操作来实现,CAS 是一种更新的原子操作,用于比较当前值与传入值是否一样,一样则更新,否则则失败。
悲观锁
悲观锁是一种悲观思想,认为 写多读少,遇到并发写的可能性高。每次去拿数据时都认为别人会修改,所以每次在读写数据时均上锁,这样别人想读写该数据时就会阻塞直接拿到锁。
Java 中的悲观锁就是 Synchronized,AQS 框架下的锁会先去尝试 CAS 乐观锁去获取锁,如果获取不到就转换为悲观锁。
自旋锁
- 自旋锁原理
若持有锁的线程能在较短时间内释放锁资源,则那些等待竞争锁的线程就不需要做内核态和用户态之间的切换就会进入阻塞挂起状态,他们只需要等上一段时间(自旋),等待持有锁的线程释放锁之后就可以立刻释放锁,从而避免用户线程和内核的切换的消耗。
- 自旋锁优缺点
自旋锁能尽可能的减少线程的阻塞,对于锁的竞争不激烈,而且占用锁时间非常短的代码块而言性能会有大幅度的提升。因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作将导致线程发生两次上下文切换。
但是如果锁的竞争比较激烈,或者持有锁的线程需要长时间的占用锁来执行同步块,此时就不适合使用自旋锁,因为自旋锁在获取锁之前一直都占用 CPU 做无用功。同时大量线程竞争一个锁,将导致获取锁的时间变长,线程自旋的消耗远远大于线程阻塞挂起操作的消耗,其他需要 CPU 的线程又获取不到 CPU,从而造成 CPU 的浪费,此时我们就应该关闭自旋锁。
Synchronized 同步锁
synchronized
能把任意一个非 NULL
的对象当作锁,属于独占式的悲观锁,同时又属于可重入锁。
- Synchronized 作用范围
- 作用于方法时,锁住的是对象实例(
this
); - 作用于静态方法时,锁住
Class
实例,而Class
的相关数据存储在永久代(PermGen),属于全局共享区域,因此静态方法锁相当于类的一个全局锁,将锁住所有调用该方法的线程; synchronized
作用于一个对象实例时,锁住的是所有以该对象为锁的代码块。有多个队列,当多个线程一起访问某个对象监视器时,对象监视器会将这些线程存储在不同的容器中;
- 作用于方法时,锁住的是对象实例(
核心组件
实现方法
JVM 每次从等待队列尾部取出一个数据用于锁竞争候选者(
OnDeck
) ,但在并发情况下,Contention List
会被大量的并发线程进行 CAS 访问,此时,为了降低对队列尾部元素的竞争,JVM 将一部分线程移动到Entry List
中作为候选竞争线程;Owner
线程在unclock
时,将Contention List
中的部分线程迁移到Entry List
,并指定其中的某一线程为OnDeck
线程(一般是最先进去的线程);Owner
线程并不直接把锁传递给OnDeck
线程,而是把锁竞争的权利交给OnDeck
,它需要重新竞争锁。虽然在一定程度上牺牲了公平性,但是能够极大地提高系统吞吐量,在 JVM 中这种选择行为叫做 “竞争切换”;OnDeck
线程获取到锁资源后就会变成Owner
线程,未获取到锁资源的则仍然停留在Entry List
中。如果Owner
线程被wait()
方法阻塞,则转移到Wait Set
队列,直到某一时刻通过notify()/notifyAll()
唤醒,则重新进入Entry List
;处于
Contention List、Entry List、Wait Set
中的线程均处于阻塞状态,该阻塞由操作系统来完成;Synchronized
是非公平锁。Synchronized
在线程进入Contention List
时,等待的线程先尝试自旋获取锁,如果获取不到则进入 Contention List;
锁的优化
- 减少锁持有时间
只用在有线程安全要求的程序上加锁;
- 减小锁粒度
将大对象(被多个线程访问)拆分为小对象,大大增加并行度,降低锁竞争。通过降低锁的竞争,偏向锁,轻量级锁的成功率才会提高,其中典型案例为 ConcurrentHashMap
;
- 锁分离
最常见的锁分离就是读写锁 ReadWriteLock,根据功能进行分离为读锁和写锁,这样一来读读不互斥,读写互斥,写写互斥,既能保证线程安全,又提高了性能;
- 锁粗化
为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。但如果对同一个锁不停地进行请求、同步和释放,其本身所消耗的系统资源也不利用性能优化。
- 锁消除
在即时编译器时,若发现不可能被共享的对象,则可以消除这些对象的锁操作;
说说什么是线程安全?如何实现线程安全?
回答:当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。【摘自深入理解Jvm虚拟机】
实现线程安全的方式有三大种方法,分别是:
- 互斥同步
- 非阻塞同步
- 无同步方案
互斥同步:同步是指多个线程并发访问共享数据时,保证共享数据在同一各时刻只被一条(或一些,当使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界去、互斥量和信号量都是常见的互斥实现方式。Java中实现互斥同步的手段主要有synchronized关键字或ReentrantLock等。
非阻塞同步类似是一种乐观并发的策略,比如CAS。
无同步方案,比如使用ThreadLocal。
追问1:synsynchronized和ReentLock的区别是什么?
相同点:
(1)都是可重入锁
(2)都保证了可见性和互斥性
(3)都可以用于控制多线程对共享对象的访问
不同点:
(1)ReentrantLock等待可中断
(2)synchronized中的锁是非公平的,ReentrantLock默认也是非公平的,但是可以通过修改参数来实现公平锁。
(3)ReentrantLock绑定多个条件
(4)synchronized是Java中的关键字是JVM级别的锁,而ReentrantLock是一个Lock接口下的实现类,是API层面的锁。
(5)synchronized隐式获取锁和释放锁,ReentrantLock显示获取和释放锁,在使用时避免程序异常无法释放锁,需要在finally控制块中进行解锁操作,synchronized不管最终程序是否执行成功,都会对锁进行释放,不会造成死锁现象的发生。
synsynchronized和volatile的区别
- 原子性
- 可见性
- 有序性
synchronized
关键字和 volatile
关键字是两个互补的存在,而不是对立的存在!
- volatile 关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。但是volatile关键字只能用于变量而 synchronized关键字可以修饰方法以及代码块。
- volatile关键字能保证数据的可见性,有序性,但不能保证数据的原子性。synchronized 关键字三者都能保证。
- volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。
Synchronized关键字的原理介绍一下?
我们知道Synchronized是对对象进行加锁,在Jvm中,对象在内存中分为三块区域:对象头、实例数据和对齐填充。在对象头中保存了锁标志位和指向Monitor对象的起始地址,当synchronized中锁住某一个对象之后,那这个对象就关联了底层的一个monitor对象。
当Monitor被某个线程持有后,就会处于锁定状态,Owner部分会指向持有Monitor对象的线程。另外Monitor中还有两个队列,用来存放进入及等待获取锁的线程。
monitor的结构
原理分析:
- 刚开始Monitor中的Owner为null.
- 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一个 Owner,其中monitor是操作系统层面的对象,而obj是java层面的对象。
- 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入
EntryList BLOCKED
,此队列中的线程是没有获取到锁的线程。 - Thread-2 执行完同步代码块的内容,然后唤醒
EntryList
中等待的线程来竞争锁,竞争的时是非公平的 - 图中
WaitSet
中的Thread-0,Thread-1
是之前获得过锁,但条件不满足(也就是除了获取锁,还有其他的资源没有获取到)进入WAITING
状态的线程,后面讲wait-notify
时会分析
Synchronized应用在方法上时,在字节码中是通过方法的AccCC_Synchronized标志来实现的,
Synchronized应用在同步块上时,在字节码中是通过Monitorenter和Monitorexit实现的。
针对Synchronized获取锁的方式,Jvm使用了锁升级的优化方式,就是先使用偏向锁优先同一线程再次获取锁,如果失败,就升级为Cas轻量级锁,如果再失败会短暂自旋,防止线程被系统挂起。最后如果以上都失败就是升级为重量级锁。
synchronized锁升级的过程说一下?
回答:在jdk1.6后Java对synchronize锁进行了升级过程,主要包含无锁,偏向锁、轻量级锁和重量级锁,主要是针对对象头MarkWord的变化。
无锁
java中创建一个线程时候,是不会产生锁的,但是当线程第一次获取到锁之后,就会添加上一个偏向锁。
偏向锁:
为什么要引入偏向锁?
轻量级锁在没有竞争的时候(也就是说只有当前一个线程),每一次仍然需要进行cas操作,开销依然很大。
jdk6中引入偏向锁进行优化,只有第一次使用cas将线程id(可以理解为线程的名字)设置到对象头的mark word头,之后发现这个线程的id是自己的就表示没有竞争,不用重新进行cas操作,以后只要不发生竞争,这个对象就归该线程所有。线程id一般是唯一的,这样可以避免每一次进行cas操作。
因为经过HotSpot的作者大量的研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。
偏向锁的升级
当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
轻量级锁
为什么要引入轻量级锁?
轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。
因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。
轻量级锁什么时候升级为重量级锁?
线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;
如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。
但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。
也就是说如果有超过两个的线程都来抢占锁,因为轻量级锁是没有阻塞队列的,所以需要升级为重量级锁,然后将没有获取到锁的线程阻塞起来。
- 在jdk6之后,自旋锁是自适应的,比如对象刚刚一次自旋操作成功过,那么会认为这次自旋成功的可能性会高,就多旋转几次,反之,就少旋转几次甚至不旋转。
- 自旋会占用cpu的时间,单核的cpu会浪费性能,但是多核的cpu会发挥其优势。
- java 7之后不能控制是否开启自旋功能。
锁小结
创建一个对象最初是无锁状态的,一个对象获取锁后升级为偏向锁,当出现锁竞争的时候,就升级为轻量级锁,轻量级锁然后在升级为重量级锁,但是在轻量级锁升级为重量级锁的过程中,有一个自旋优化的过程,这样可以减小开销。
轻量级锁,在当前线程A创建一个锁记录,然后尝试通过CAS把markword更新为指向线程A的锁记录的指针,如果成功了,那么markword最后两位就变成00(轻量级锁),如果此时又来了一个B线程,那么会在B线程中创建一个锁记录,尝试CAS把markword更新为指向线程B的该锁记录的指针,如果失败的话,会查看markword的指针指向的是不是B线程中的某个栈帧(锁记录),如果是,即A和B是同一个线程,也就是当前操作是重入锁操作,即在当前线程对某个对象重复加锁,这是允许的,也就是可以获取到锁了。如果markword记录的不是B线程中的某个栈帧(锁记录),那么线程B就会尝试自旋,如果自选超过一定次数,就会升级成重量级锁(轻量级锁升级成重量级锁的第一种时机:自选次数超过一定次数),如果B线程在自选的过程中,又来了一个线程C来竞争该锁,那么此时直接轻量级锁膨胀成重量级锁(轻量级锁升级成重量级锁的第二种时机:有两个以上的线程在竞争同一个锁。注:A,B,C3线程>2个线程)
如果一开始是无锁状态,那么第一个线程获取索取锁的时候,判断是不是无锁状态,如果是无锁(001),就通过CAS将mark word里的部分地址记录为当前线程的ID,同时最后倒数第三的标志位置为1,即倒数三位的结果是(101),表示当前为轻量级锁。
下一个如果该线程再次获取该锁的时候,就直接判断mark word里记录的线程ID是不是我当前的线程ID,如果是的话,就成功获取到锁了,即不需再进行CAS操作,这就是相对轻量级锁来说,
偏向锁的优势(只需进行第一次的CAS,而无需每次都进行CAS,当然这个理想过程是没有其他线程来竞争该锁)。如果中途有其他线程来竞争该锁,发现已经是101状态,那么就会查看偏向锁记录的线程是否还存活,如果未存活,即偏向锁的撤消,将markword记录的锁状态从101(偏向锁)置未001(无锁),然后重新偏向当前竞争成功的线程,如果当前线程还是存活状态,那么就升级成轻量级锁。
无锁状态最好。
偏向锁相对于轻量级锁,只会进行一次cas操作。
轻量级锁相对于重量级锁,会有多次cas操作。
而重量级锁会有等待队列阻塞线程。
synchronize锁的作用范围
回答:
(1)synchronize作用于成员变量和非静态方法时,锁住的是对象的实例,即this对象。
(2)synchronize作用于静态方法时,锁住的是Class实例
(3)synchronize作用于一个代码块时,锁住的是所有代码块中配置的对象。
Java中线程的状态有哪些?线程间的通信方式有哪些?
回答:Java中线程生命周期分为新建(New)、运行(Runnable)、阻塞(Blocked)、无限期等待(Waiting)、限期等待(Time Waiting)和结束(Terminated)这6种状态。
线程状态之间的转换
- 当创建一个线程的时候,线程处在New状态,运行Thread的Start方法后,线程进入Runnable可运行状态。
- 这个时候,所有可运行状态的线程并不能马上运行,而是需要先进入就绪状态等待线程调度,如图中间的Ready状态。在获取到Cpu后才能进入运行状态,如图中的Running。运行状态可以随着不同条件转换成除New以外的其他状态。
- 先看左边,在运行态中的线程进入Synchronized同步块或者同步方法时,如果获取锁失败,则会进入到Blocked状态。当获取到锁后,会从Blocked状态恢复到就绪状态。
- 再看右边,运行中的线程还会进入等待状态,这两个等待一个是有超时时间的等待,例如调用Object.wait、Thread.join等。另外一个时无超时的等待,例如调用Thread.join或者Locksupport.park。
- 这两种等待都可以通过Notify或Unpark结束等待状态恢复到就绪状态。
- 最后是线程运行完成结束时,如图下方,线程状态变成Terminated
线程的阻塞方式
当线程因为某种原因放弃 CPU 使用权后,即让出了 CPU 时间片,暂时就会停止运行,知道线程进入可运行状态(Runnable
),才有机会再次获得 CPU 时间片转入 RUNNING
状态。一般来讲,阻塞的情况可以分为如下三种:
- 等待阻塞(Object.wait -> 等待队列)
RUNNING
状态的线程执行 Object.wait()
方法后,JVM 会将线程放入等待序列(waitting queue);
- 同步阻塞(lock -> 锁池)
RUNNING
状态的线程在获取对象的同步锁时,若该 同步锁被其他线程占用,则 JVM 将该线程放入锁池(lock pool)中;
- 其他阻塞(sleep/join)
RUNNING
状态的线程执行 Thread.sleep(long ms)
或 Thread.join()
方法,或发出 I/O 请求时,JVM 会将该线程置为阻塞状态。当 sleep()
状态超时,join()
等待线程终止或超时、或者 I/O 处理完毕时,线程重新转入可运行状态(RUNNABLE
);
终止线程的四种方式
- 正常运行结束
程序运行结束,线程自动结束。
- 使用退出标志退出线程
一般 run()
方法执行完毕后,线程就会正常结束,但是有的线程是伺服线程,需要长时间的运行,直到满足某些外部条件满足时,才能关闭,一般通过使用关键字 volatile
来使退出标志进行同步(volatile
修饰时,同一时刻只能有一个线程来修改退出标志的值);
public class MyThread extends Thread{
public volatile boolean flag = false;
@Override
public void run(){
while(!flag){
……
}
}
}
- Interrupt 方法结束线程
利用 interrput()
方法来终止线程有两种情况:
- 线程处于阻塞状态
若使用了 sleep
,同步锁的 wait
,socket 中的 receive、accept
等方法时,线程会处于阻塞状态。当调用线程的 interrupt()
方法时,将抛出 InterrputException
异常,阻塞中的线程哪个方法抛出该异常,就通过代码来进行捕获,然后 break
跳出循环状态,从而让我们有机会结束该线程的执行。并非调用了 interrput() 方法后线程就会结束,而是需要我们先捕获 InterruptException 异常后通过 break 来跳出循环,才能正常结束 run() 方法;
- 线程未处于阻塞状态
使用isInterrupted()
判断线程的中断标志来退出循环,当使用 interrupt()
时,中断标志会置为 true
,和使用自定义的退出标志来控制循环原理一致;
打断没有阻塞的线程,不会清除打断标记,打断处于阻塞的线程,会清除打断标记。
- stop 方法终止进程(线程不安全)
可以使用 Thread.stop()
来强行终止线程,但 **调用 stop() 后,创建子线程就会抛出 ThreadDeathError 的错误,且会释放子线程所持有的所有锁。一般任何进行加锁的代码块都是为了保护数据一致性,若在调用 Thread.stop() 方法后导致该线程所持有的的所有锁的突然释放(不受控制),则被保护数据就有可能呈现不一致性,其他线程在使用这些被破坏的数据时,就可能会导致一些奇怪的应用程序错误。**一般不建议使用。
Java中线程间通信方式有:
互斥量(Mutex):采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。比如 Java 中的 synchronized 关键词和各种 Lock 都是这种机制。
信号量(Semphares) :它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量
事件(Event) :Wait/Notify:通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操
sleep后进入什么状态,wait后进入什么状态?
回答:sleep后进入Time waiting超时等待状态,wait后进入等待waiting状态。
另外wait属于object的方法,每一个对象都拥有的方法,但是sleep仅仅是Thread对象拥有的方法。
sleep和wait的区别?
回答:
(1)sleep方法属于Thread类,wait方法属于Object类
(2)sleep方法暂停执行指定的时间,让出CPU给其他线程,但其监控状态依然保持在指定的时间过后又会自动恢复运行状态。
(3)在调用sleep方法的过程中,线程不会释放对象锁,而wait会释放对象锁。
wait为什么是数Object类下面的方法?
所谓的释放锁资源实际是通知对象内置的monitor对象进行释放,而只有所有对象都有内置的monitor对象才能实现任何对象的锁资源都可以释放。又因为所有类都继承自Object,所以wait()就成了Object方法,也就是通过wait()来通知对象内置的monitor对象释放,而且事实上因为这涉及对硬件底层的操作,所以wait()方法是native方法,底层是用C写的。
notify()和 notifyAll()有什么区别?
notifyAll()会唤醒所有的线程,notify()之后唤醒一个线程。notifyAll() 调用后,会将全部线程由等待池移到锁池,然后参与锁的竞争,竞争成功则继续执行,如果不成功则留在锁池等待锁被释放后再次参与竞争。而 notify()只会唤醒一个线程,具体唤醒哪一个线程由虚拟机控制。
start方法和run方法有什么区别?
(1)start方法用于启动线程,真正实现了多线程运行。在调用了线程的start方法后,线程会在后台执行,无须等待run方法体的代码执行完毕。
(2)通过调用start方法启动一个线程时,此线程处于就绪状态,并没有运行。
(3)run方法也叫做线程体,包含了要执行的线程的逻辑代码,在调用run 方法后,线程就进入运行状态,开始运行run方法中的代码,在run方法运行结束后,该线程终止,CPU在调度其他的线程。
如果单单运行run()方法体,是不会真正的启动一个线程的。
AQS了解吗?
回答:AQS是一个抽象队列同步器,通过维护一个**状态标志位state和一个先进先出的(FIFO)**的线程等待队列来实现一个多线程访问共享资源的同步框架,其中队列使用的数据结构是双向链表。
AQS的原理大概是这样的,给每个共享资源都设置一个共享锁,线程在需要访问共享资源时首先需要获取共享资源锁,如果获取到了共享资源锁,便可以在当前线程中使用该共享资源,如果没有获取到共享锁,该线程被放入到等待队列中,等待下一次资源调度,其中等待队列可以有多个。
其实一句话总结:AQS就是一个并发包的基础组件,用来实现各种锁,各种同步组件的。
它包含了state变量、加锁线程、等待队列等并发中的核心组件。
AQS定义了两种资源共享方式:独占式和共享式
独占式:只有一个线程能执行,具体的Java实现有ReentrantLock。
共享式:多个线程可同时执行,具体的Java实现有Semaphore和CountDownLatch。
总结:公平锁和非公平锁只有两处不同:
- 非公平锁在调用 lock 后,首先就会调用 CAS 进行一次抢锁,如果这个时候恰巧锁没有被占用,那么直接就获取到锁返回了。
- 非公平锁在 CAS 失败后,和公平锁一样都会进入到
tryAcquire
方法,在tryAcquire
方法中,如果发现锁这个时候被释放了(state == 0),非公平锁会直接 CAS 抢锁,但是公平锁会判断等待队列是否有线程处于等待状态,如果有则不去抢锁,乖乖排到后面。公平锁和非公平锁就这两点区别,如果这两次 CAS 都不成功,那么后面非公平锁和公平锁是一样的,都要进入到阻塞队列等待唤醒。
相对来说,非公平锁会有更好的性能,因为它的吞吐量比较大。当然,非公平锁让获取锁的时间变得更加不确定,可能会导致在阻塞队列中的线程长期处于饥饿状态。
AQS只是一个框架(模板模式),只定义了一个接口,具体资源的获取、释放都交由自定义同步器去实现。不同的自定义同步器争取用共享资源的方式也不同,自定义同步器在实现时只需实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护,如获取资源失败入队、唤醒出队等,AQS已经在顶层实现好,不需要具体的同步器在做处理。
Reentrantlock就是基于Aqs实现的,Reentrantlock内部有公平锁和非公平锁两种实现,差别就在于新来的线程会不会比已经在同步队列中的等待线程更早获得锁。
和Reentrantlock实现方式类似,Semaphore也是基于aqs,差别在于Reentrantlock是独占锁,Semaphore是共享锁(它用来控制访问一个共享资源的线程的个数)。
Java中的并发关键字
Java中常见的并发关键字有CountDownLatch、CylicBarrier、Semaphore和volatile。
ReentrantLock
可重入式:获取锁时会判断当前线程是否占据锁,然后增加同步状态值,释放锁时减小同步状态值,同步状态值为0时才彻底释放
非公平锁(默认):先CAS获取锁,获取失败再判断锁是否被释放,如果被释放就再次CAS获取锁,否则加入双向队列中。加入队列后会自旋判断前驱节点是不是首节点,然后获取同步状态
公平锁:如果当前是无锁状态,会先判断自己是否是头结点引用,如果是CAS获取锁,否则加入双向队列中;加入队列后会自旋判断前驱节点是不是首节点,然后获取同步状态
支持多个等待队列
可打断
Semaphore(信号量)
初始化同步状态值,获取到资源时状态值减1,状态值为0时需要等待,用来控制访问某一种资源的线程的数量。
countDownLatch
是通过一个计数器来实现的,计数器为自己设定的值,每当一个线程执行完毕后,计数器的值就-1,当计数器的值为0时,表示所有线程都执行完毕,等待的线程恢复运行;
CyclicBarrier(同步屏障)
在CyclicBarrier类的内部有一个计数器,每个线程在到达屏障点的时候都会调用await方法将自己阻塞,此时计数器会减1,当计数器减为0的时候所有因调用await方法而被阻塞的线程将被唤醒。
介绍一下CAS
最好在介绍CAS的时候,先讲一下悲观锁和乐观锁的概念。
解决线程同步与互斥的主要方式是Cas、Synchronized、和Lock。
CAS指Compare and swap比较和替换是设计并发算法时用到的一种技术,CAS指令有三个操作数,分别是内存位置(在Java中可以简单的理解为变量的内存地址,用V表示),旧的预期值(用A表示)和准备设置的新值(用B表示)。CAS指令在执行的时候,当且仅当V符合A时,处理器才会用B更新V的值,否则它就不会执行更 新。
Cas是属于乐观锁的一种实现,是一种轻量级锁,Juc中很多工具类的实现就是基于Cas。
Cas操作是线程在读取数据时不进行加锁,在准备写回数据时,比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。这是一种乐观策略,认为并发操作并不总会发生。
比较并写回的操作是通过操作系统原语实现的,保证执行过程中不会被中断,深入一点理解就说是通过java中的unsafe的API进行实现的。
CAS带来的问题是什么?如何解决的?
回答:ABA问题、循环时间长开销很大、只能保证一个共享变量的原子操作。
Aba问题不一定会影响结果,但还是需要防范。
一般加版本号进行解决(具体操作:乐观锁每次在执行数据的修改操作时都会带上一个版本号,在预期的版本号和数据的版本号一致时就可以执行修改操作,并对版本号执行加1操作,否则执行失败。)
juc中的解决办法可以增加额外的标志位或者时间戳。Juc工具包中提供了这样的类。
什么是乐观锁,什么是悲观锁?
回答:悲观锁和乐观锁并不是某个具体的“锁”而是一种并发编程的基本概念。乐观锁和悲观锁最早出现在数据库的设计当中,后来逐渐被 Java 的并发包所引入。
悲观锁:认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观地认为,不加锁的并发操作一定会出问题。
乐观锁:正好和悲观锁相反,它获取数据的时候,并不担心数据被修改,每次获取数据的时候也不会加锁,只是在更新数据的时候,通过判断现有的数据是否和原数据一致来判断数据是否被其他线程操作,如果没被其他线程修改则进行数据更新,如果被其他线程修改则不进行数据更新。
Java中创建线程的方式有哪些?
回答:Java中创建线程的方式有4种,分别是
(1)写一个类继承子Thread类,重写run方法
(2)写一个类重写Runable接口,重写run方法
(3)写一个类重写Callable接口,重写call方法
(4)使用线程池
线程池的好处?说几个Java中常见的线程池?说一下其中的参数和运行流程?
回答:使用线程池可以降低资源消耗(反复创建线程是一件很消耗资源的事,利用已创建的线程降低线程创建和销毁造成的消耗)、提供处理速度(当任务到达时,可以直接使用已有线程,不比等到线程创建完成才去执行。)、线程资源可管理性和通过控制系统的最大并发数,以保证系统高效且安全的运行。
Executors 实现了以下四种类型的 ThreadPoolExecutor:
第1种是:固定大小线程池,特点是线程数固定,使用无界队列,适用于任务数量不均匀的场景、对内存压力不敏感,但系统负载比较敏感的场景;
第2种是:Cached线程池,特点是不限制线程数,适用于要求低延迟的短期任务场景;
第3种是:单线程线程池,也就是一个线程的固定线程池,适用于需要异步执行但需要保证任务顺序的场景;
第4种是:Scheduled线程池,适用于定期执行任务场景,支持按固定频率定期执行和按固定延时定期执行两种方式;
第5种是:工作窃取线程池,使用的ForkJoinPool,是固定并行度的多任务队列,适合任务执行时长不均匀的场景。
线程池有7大核心参数,分别是:
**第1个参数:**设置核心线程数。默认情况下核心线程会一直存活。
**第2个参数:**设置最大线程数。决定线程池最多可以创建的多少线程。
**第3个参数和第4个参数:**用来设置线程空闲时间,和空闲时间的单位,当线程闲置超过空闲时间就会被销毁。可以通过AllowCoreThreadTimeOut方法来允许核心线程被回收。
第5个参数:设置缓冲队列,图中左下方的三个队列是设置线程池时常使用的缓冲队列。其中Array Blocking Queue是一个有界队列,就是指队列有最大容量限制。Linked Blocking Queue是无界队列,就是队列不限制容量。最后一个是Synchronous Queue,是一个同步队列,内部没有缓冲区。
**第6个参数:**设置线程池工厂方法,线程工厂用来创建新线程,可以用来对线程的一些属性进行定制,例如线程的Group、线程名、优先级等。一般使用默认工厂类即可。
**第7个参数:**设置线程池满时的拒绝策略。如右下角所示有四种策略,abort策略在线程池满后,提交新任务时会抛出Rejected Execution Exception,这个也是默认的拒绝策略。
Discard策略会在提交失败时对任务直接进行丢弃。CallerRuns策略会在提交失败时,由提交任务的线程直接执行提交的任务。Discard Oldest策略会丢弃最早提交的任务。
前面的5种线程池都是使用怎样的参数来创建的呢?
固定大小线程池创建时核心和最大线程数都设置成指定的线程数,这样线程池中就只会使用固定大小的线程数。队列使用无界队列Linked Blocking Queue。
Single线程池就是线程数设置为1的固定线程池。
Cached线程池的核心线程数设置为0,最大线程数是Integer.Max_Value,主要是通过把缓冲队列设置成SynchronousQueue,这样只要没有空闲线程就会新建。
scheduled线程池与前几种不同的是使用了Delayed Work Queue,这是一种按延迟时间获取任务的优先级队列。
- CPU密集型:线程个数为CPU核数
- IO密集型:线程个数为CPU核数的两倍
我们向线程提交任务时可以使用Execute和Submit,区别就是Submit可以返回一个Future对象,通过Future对象可以了解任务执行情况,可以取消任务的执行,还可获取执行结果或执行异常。Submit最终也是通过Execute执行的。
线程池中的执行流程:
(1)当线程数小于核心线程数的时候,使用核心线程数。
(2)如果核心线程数小于线程数,就将多余的线程放入任务队列(阻塞队列)中
(3)当任务队列(阻塞队列)满的时候,就启动最大线程数.
(4)当最大线程数也达到后,就将启动拒绝策略。
拒绝策略有哪些?
回答:有四种拒绝策略
1.ThreadPoolExecutor.AbortPolicy
线程池的默认拒绝策略为AbortPolicy,即丢弃任务并抛出RejectedExecutionException异常(即后面提交的请求不会放入队列也不会直接消费并抛出异常);
2.ThreadPoolExecutor.DiscardPolicy
丢弃任务,但是不抛出异常。如果线程队列已满,则后续提交的任务都会被丢弃,且是静默丢弃(也不会抛出任何异常,任务直接就丢弃了)。
3.ThreadPoolExecutor.DiscardOldestPolicy
丢弃队列最前面的任务,然后重新提交被拒绝的任务(丢弃掉了队列最前的任务,并不抛出异常,直接丢弃了)。
4.ThreadPoolExecutor.CallerRunsPolicy
由调用线程处理该任务(不会丢弃任务,最后所有的任务都执行了,并不会抛出异常)
线程池的参数如何确定呢?
回答:
一般需要确定核心线程数、最大线程数、任务队列和拒绝策略,这些需要根据实际的业务场景去设置,可以大致分为CPU密集型和IO密集型。
CPU密集型时,任务可以少配置线程数,大概和机器的cpu核数相当,这样可以使得每个线程都在执行任务。
IO密集型时,大部分线程都阻塞,故需要多配置线程数,2*cpu核数。
Java中常见的阻塞队列有哪些?
ArrayBlockingQueue:是一个我们常用的典型的有界队列,其内部的实现是基于数组来实现的。
LinkedBlockingQueue :从它的名字我们可以知道,它是一个由链表实现的队列,这个队列的长度Integer.MAX_VALUE ,这个值是非常大的,几乎无法达到,对此我们可以认为这个队列基本属于一个无界队列(也又认为是有界队列)。此队列按照先进先出的顺序进行排序。
SynchronousQueue: 是一个不存储任何元素的阻塞队列,每一个put操作必须等待take操作,否则不能添加元素。同时它也支持公平锁和非公平锁。
PriorityBlockingQueue:是一个支持优先级排序的无界阻塞队列,可以通过自定义实现 compareTo() 方法来指定元素的排序规则,或者通过构造器参数 Comparator 来指定排序规则。但是需要注意插入队列的对象必须是可比较大小的,也就是 Comparable 的,否则会抛出 ClassCastException 异常。
DelayQueue: 是一个实现PriorityBlockingQueue的延迟获取的无界队列。具有“延迟”的功能。
ThreaLocal知道吗?
回答:Java中每一个线程都有自己的专属本地变量, JDK 中提供的ThreadLocal
类,ThreadLocal类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。
1.ThreadLocal是Java中所提供的线程本地存储机制,可以利用该机制将数据存在某个线程内部,该线程可以在任意时刻、任意方法中获取缓存的数据
2.ThreadLocal底层是通过ThreadLocalmap来实现的,每个Thread对象(注意不是ThreadLocal对象)中都存在一个ThreadLocalMap,Map的key为ThreadLocal对象,Map的value为需要缓存的值。
3.ThreadLocal经典的应用场景就是连接管理(一个线程持有一个链接,该连接对象可以在不同给的方法之间进行线程传递,线程之间不共享同一个连接)
用它可能会带来什么问题?
回答:如果在线程池中使用ThreadLocal会造成内存泄漏,因为当ThreadLocal对象使用完之后,应该要把设置的key,value,也就是Entry对象进行回收,但线程池中的线程不会回收,而线程对象是通过强引用指向ThreadLocalmap,ThreadLocalmap也是通过强引用指向Entry对象,线程不被回收,Entry对象也就不会被回收,从而出现内存泄漏,
解决办法是:在使用了ThreadLocal对象之后,手动调用ThreadLocal的remove方法,手动清除Entry对象。
ThreadLocalMap
中使用的 key 为ThreadLocal
的弱引用,而 value 是强引用。所以,如果ThreadLocal
没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来,ThreadLocalMap
中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。
什么是强软弱虚引用?
回答:
(1)强引用是使用最普遍的引用。只要某个对象有强引用与之关联,JVM必定不会回收这个对象,即使在内存不足的情况下,JVM宁愿抛出OutOfMemory错误也不会回收这种对象
(2)软引用是用来描述一些有用但并不是必需的对象,在Java中用java.lang.ref.SoftReference类来表示。只有在内存不足的时候JVM才会回收该对象。
(3)只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。
(4)虚引用也称为幻影引用,一个对象是都有虚引用的存在都不会对生存时间都构成影响,也无法通过虚引用来获取对一个对象的真实引用。唯一的用处:能在对象被GC时收到系统通知,JAVA中用PhantomReference来实现虚引用
虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之 关联的引用队列中。
JUC工具类
原子整数类
第一行的类都是基本数据类型的原子类:包括Atomicboolean、Atomiclong、Atomicinteger类。
AtomicLong通过Unsafe类实现,基于Cas。Unsafe类是底层工具类,Juc中很多类的底层都使用到了Unsafe包中的功能。Unsafe类提供了类似C的指针操作,提供Cas等功能。Unsafe类中的所有方法都是Native修饰的;
另外Longadder等四个类是Jdk1.8中提供的更高效的操作类。LongAdder基于Cell实现,使用分段锁思想,是一种空间换时间的策略,更适合高并发场景;
LongAccumulator提供了比LongAdder更强大的功能,能够指定对数据的操作规则,例如可以把对数据的相加操作改成相乘操作。
第二行中的类提供了对对象的原子读写功能,后两个类Atomic Stamped Reference和Atomic Markable Reference是用来解决我们前面提到的Abs问题,分别基于时间戳和标记位来解决。
锁
这一页表格中,第一行的类主要是锁相关的类,例如我们前面介绍过的Reentrant重入锁。与Reentrant Lock的独占锁不同,Semaphore是共享锁,允许多个线程共享资源,适用于限制使用共享资源线程数量的场景,例如100个车辆要使用20个停车位,那么最多允许20个车占用停车位。
Stamped Lock是1.8改进的读写锁,是使用一种Clh的乐观锁,能够有效防止写饥饿。所谓写饥饿就是在多线程读写时,读线程访问非常频繁,导致总是有读线程占用资源,写线程很难加上写锁。
第二行中主要是异步执行相关的类,这里可以重点了解jdk1.8中提供的CompletableFuture,可以支持流式调用,可以方便的进行多Future的组合使用,例如可以同时执行两个异步任务,然后对执行结果进行合并处理。还可以很方便的设置完成时间。
另外一个是1.7中提供的ForkJoinPool,采用分治思想,将大任务分解成多个小任务处理,然后在合并处理结果。ForkJoinPool的特点是使用工作窃取算法,可以有效平衡多任务时间长短不一的场景。
其他并发容器和工具
表格中第一行是常用的阻塞队列,刚才讲解线程池时已经简单介绍过了,这里在补充一点,Linked Blocking Deque是双端队列,也就是可以分别从队头和队尾操作入队、出队。而Array Blocking Queue单端队列,只能从队尾入队,队头出队。
第二行是控制多线程协作时使用的类。其中Count Down Latch实现计数器功能,可以用来控制等待多个线程执行任务后进行汇总。
Cyclic Barrier可以让一组线程等待至某个状态之后,再全部同时执行,一般在测试时使用,可以让多线程更好的并发执行。
Semaphore前面已经介绍过,用来控制对共享资源的访问并发度。
最后一行是比较常用的两个集合类,可以了解一下Copy On Write ArrayList,Cow通过在写入数据时进行Copy修改,然后在更新引用的方式,来消除并行读写中的锁使用,比较适合读多写少,数据量比较小,但是并发非常高的场景。
补充CopyOnWriteArrayList
读操作可以尽可能的快,而写即使慢一些也没关系,在很多时候,我们的读操作很频繁,但是写操作并不是非常的频繁,对于这种操作,我们希望读操作尽可能的快速,而写操作不是必须非常快,很典型的一个场景就是黑名单,总是需要读取,但是写的机会很少。
读写锁的规则
读写锁的思想是:读读共享、其他都互斥(写写互斥、读写互斥、写读互斥),原因是由于读操作不会修改原有的数据,因此并发读并不会有安全问题;而写操作是危险的,所以当写操作发生时,不允许有读操作加入,也不允许第二个写线程加入。
对读写锁规则的升级
CopyOnWriteArrayList 的思想比读写锁的思想又更进一步。为了将读取的性能发挥到极致,CopyOnWriteArrayList 读取是完全不用加锁的,更厉害的是,写入也不会阻塞读取操作,也就是说你可以在写入的同时进行读取,只有写入和写入之间需要进行同步,也就是不允许多个写入同时发生,但是在写入发生时允许读取同时发生。这样一来,读操作的性能就会大幅度提升。
CopyOnWrite的含义
从 CopyOnWriteArrayList 的名字就能看出它是满足 CopyOnWrite 的 ArrayList,CopyOnWrite 的意思是说,当容器需要被修改的时候,不直接修改当前容器,而是先将当前容器进行 Copy,复制出一个新的容器,然后修改新的容器,完成修改之后,再将原容器的引用指向新的容器。这样就完成了整个修改过程。
这样做的好处是,CopyOnWriteArrayList 利用了“不变性”原理,因为容器每次修改都是创建新副本,所以对于旧容器来说,其实是不可变的,也是线程安全的,无需进一步的同步操作。我们可以对 CopyOnWrite 容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素,也不会有修改。
CopyOnWriteArrayList 的所有修改操作(add,set等)都是通过创建底层数组的新副本来实现的,所以 CopyOnWrite 容器也是一种读写分离的思想体现,读和写使用不同的容器。
在迭代期间可以并发的修改集合中的元素,但是普通的集合在迭代期间并发修改的话会抛出异常。这是因为迭代器使用的依然是旧的数组中的数据,但是数组中的数据可能已经过时了,要想读取最新的数据,必须重新创建一个迭代器获取数据。
CopyOnWriteArrayList 的迭代器一旦被建立之后,如果往之前的 CopyOnWriteArrayList 对象中去新增元素,在迭代器中既不会显示出元素的变更情况,同时也不会报错,这一点和 ArrayList 是有很大区别的。
缺点
这些缺点不仅是针对 CopyOnWriteArrayList,其实同样也适用于其他的 CopyOnWrite 容器:
- 内存占用问题
因为 CopyOnWrite 的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,这一点会占用额外的内存空间。
在元素较多或者复杂的情况下,复制的开销很大
复制过程不仅会占用双倍内存,还需要消耗 CPU 等资源,会降低整体性能。
- 数据一致性问题
由于 CopyOnWrite 容器的修改是先修改副本,所以这次修改对于其他线程来说,并不是实时能看到的,只有在修改完之后才能体现出来。如果你希望写入的的数据马上能被其他线程看到,CopyOnWrite 容器是不适用的。
线程死锁
抢占互斥资源
抢占可消费资源
线程推进顺序不得当
死锁的四个必要条件:
- 互斥条件:该资源任意一个时刻只由一个线程占用。
- 请求与保持条件:一个进程因请求资源而阻塞时,不会释放已获得的资源。
- 不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。
- 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
如何避免:
- 破坏互斥条件:无法破坏,因为我们用锁本来就是想让他们互斥的。
- 破坏请求与保持条件:一次性申请所有的资源。
- 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
- 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件
如何解除:
1.利用抢占:挂起某些进程,并抢占它的资源,将他分配给其他进程。但应防止某些进程被长时间挂起而处于饥饿状态;
2.利用回滚:让某些进程回退到足以解除死锁的地步,进程回退时自愿释放资源。要求系统保持进程的历史信息,设置还原点;
3.利用杀死进程:强制杀死某些进程直到死锁解除为止,可以按照优先级进行.
如何排查:
jstack
Volatile关键字
什么是JMM
介绍一下volatile关键字
可见性:对volatile声明的变量进行写操作时,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存,然后由于缓存一致性协议,其他处理器的会通过嗅探检测到自己缓存对应的内存地址被修改,就会将当前自己的缓存设置成失效
有序性:volatile读操作会在后面插入一个loadload和loadStore屏障,防止下面的普通读写操作与volatile读操作指令重排序;volatile写操作会在前面插入一个storestore屏障,防止前面普通写操作与volatile写操作重排序,会在后面插入一个storeload屏障,防止后面的volatile读写操作
原子性、可见性、有序性
内存屏障
Memory Barrier(Memory Fence)
可见性
- 写屏障(
sfence
)保证在该屏障之前的,对共享变量的改动,都同步到主存当中 - 而读屏障(
lfence
)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
- 写屏障(
有序性
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
不能保证原子性
- 对
volatile
变量的写指令后会加入写屏障- 对
volatile
变量的读指令前会加入读屏障
多线程面试重点总结
**第1点:**是要理解线程的同步与互斥的原理,包括临界资源、临界区的概念,知道重量级锁、轻量级锁、自旋锁、偏向锁、重入锁、读写锁的概念。 作者:Java架构小海
**第2点:**要掌握线程安全相关机制,例如 Cas、Synchronized、Lock(AQS)三种同步方式的实现原理、要明白Threadlocal是每个线程独享的局部变量,了解Threadlocal使用弱引用的ThreadLocalMap保存不同的Threadlocal变量。
**第3点:**要了解Juc中的工具类的使用场景与主要的几种工具类的实现原理,例如Reentrantlock,Concurrenthashmap、Longadder等实现方式
**第4点:**要熟悉线程池的原理、使用场景、常用配置,例如大量短期任务的场景适合使用Cached线程池;系统资源比较紧张时,可以选择固定线程池。
另外注意慎用无界队列,可能会有Oom的风险。
**第5点:**要深刻理解线程的同步与异步、阻塞与非阻塞,同步和异步的区别是任务是否是同一个线程执行,阻塞与非阻塞的区别是异步执行任务时,线程是不是会阻塞等待结果,还是会继续执行后续逻辑。
会用Jstack分析线程的运行状态,查找锁对象持有状况等