问题记录:
- 详细描述ThreadPoolExecutor的各个参数的含义,介绍一个任务提交到线程池后的执行流程。
- 请简要说明Servlet中的生命周期
- 开启两个线程A、B,打印1到10,线程A打印奇数(1、3、5、7、9),线程B打印偶数(2、4、6、8、10)。
- 请编写代码实现单例模式 ,类名为Singleton
- 写一个Map转换成JavaBean的工具类方法,实现如下mapToObject方法(使用Java反射,不允许使用第三方类库)
public static
}
数据库操作是我们经常使用的一个技能, 请你完成一个简单的用户密码验证过程 ,给定的条件如下:
数据库中存在个用户表:users ,表结构如下:
CREATE TABLE users
(
uid
bigint(20) NOT NULL COMMENT ‘用户ID’,
user_name
varchar(32) NOT NULL COMMENT ‘用户账号’,
password
varchar(64) NOT NULL COMMENT ‘用户混淆密码’,
PRIMARY KEY (uid
),
UNIQUE KEY u_user_name
(user_name
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=’用户表’
完善以下方法
public boolean verifyPassword(String username,String password) {
Connection con = getConnection () ;// getConnection() 方法是个已有的方法可以获取到数据库连接 ,
// here is your code
}
可以不用写Import 语句,只需要补充关键步骤即可
- 介绍HashMap的数据结构、扩容机制,HashMap与Hashtable的区别,是否是线程安全的,并介绍ConcurrentHashMap的实现机制。
- 介绍数据库连接池的实现方式。如何从连接池中获取连接、将连接放回连接池?使用连接池的优势是什么?列举一下自己用过的连接池。
- 什么是死锁?JAVA程序中什么情况下回出现死锁?如何避免出现死锁?
- 分布式锁有几种实现方式,并介绍每种方式的优缺点。
- 什么是TCP粘包拆包?为什么会出现粘包拆包?如何在应用层面解决此问题?
- 请大致描述一下BIO,AIO和NIO的区别?
- 在JAVA语法中加载类的的方式有哪些?
- 建立三个线程A、B、C,A线程打印10次字母A,B线程打印10次字母B,C线程打印10次字母C,但是要求三个线程同时运行,并且实现交替打印,即按照ABCABCABC的顺序打印。
- 请列举5个spring框架中的注解,并说明注解的用法以及使用场景
反射的步骤如下
- 获取想要操作的类的Class对象,该Class对象是反射的核心,通过它可以调用类的任意方法。
- 调用Class对象所对应的类中定义的方法,这是反射的使用阶段。
- 使用反射API来获取并调用类的属性和方法等信息。
1 |
|
JVM的运行机制
JVM的内容
包括一套字节码指令集、一组程序寄存器、一个虚拟机栈、一个虚拟机堆、一个方法区和一个垃圾回收器。
程序的运行过程
(1)Java源文件被编译器编译成字节码文件。
(2)JVM将字节码文件编译成相应操作系统的机器码。
(3)机器码调用相应操作系统的本地方法库执行相应的方法。
多线程
JVM中的线程与操作系统中的线程是相互对应的,在JVM线程的本地存储、缓冲区分配、同步对象、栈、程序计数器等准备工作都完成时,JVM会调用操作系统的接口创建一个与之对应的原生线程
JVM的后台线程
- 虚拟机线程
- 周期性任务线程
- GC线程
- 编译器线程
- 信号分发线程
JVM的内存区域
JVM的内存区域分为线程私有区域(程序计数器、虚拟机栈、本地方法区)、线程共享区域(堆、方法区)和直接内存
线程共享区域随虚拟机的启动而创建,随虚拟机的关闭而销毁。
Java进程可以通过堆外内存技术避免在Java堆和Native堆中来回复制数据带来的资源占用和性能消耗,因此堆外内存在高并发应用场景下被广泛使用(Netty、Flink、HBase、Hadoop都有用到堆外内存)。
程序计数器(是线程私有的)是一块很小的内存空间,用于存储当前运行的线程所执行的字节码的行号指示器
虚拟机栈(是线程私有的)是描述Java方法的执行过程的内存模型,它在当前栈帧(Stack Frame)中存储了局部变量表、操作数栈、动态链接、方法出口等信息。
栈帧用来记录方法的执行过程,在方法被执行时虚拟机会为其创建一个与之对应的栈帧,方法的执行和返回对应栈帧在虚拟机栈中的入栈和出栈。
在线程内部,每个方法的执行和返回都对应一个栈帧的入栈和出栈,每个运行中的线程当前只有一个栈帧处于活动状态。
本地方法区(是线程私有的)本地方法栈为Native方法服务。
堆:现代JVM采用分代收集算法,因此Java堆从GC(Garbage Collection,垃圾回收)的角度还可以细分为:新生代、老年代和永久代。
方法区也被称为永久代,用于存储常量、静态变量、类信息、即时编译器编译后的机器码、运行时常量池等数据。
永久代的内存回收主要针对常量池的回收和类的卸载,因此可回收的对象很少。
常量被存储在运行时常量池(Runtime ConstantPool)中,是方法区的一部分。静态变量也属于方法区的一部分。
JVM的运行时内存
JVM的运行时内存也叫作JVM堆,从GC的角度可以将JVM堆分为新生代、老年代和永久代。
- 其中新生代默认占1/3堆空间;
- 老年代默认占2/3堆空间;
- 永久代占非常少的堆空间。
新生代又分为Eden区、ServivorFrom区和ServivorTo区,
- Eden区默认占8/10新生代空间,
- ServivorFrom区默认占1/10新生代空间
- ServivorTo区默认1/10新生代空间
新生代
(1)Eden区:Java新创建的对象首先会被存放在Eden区,如果新创建的对象属于大对象,则直接将其分配到老年代。大对象的定义和具体的JVM版本、堆大小和垃圾回收策略有关,一般为2KB~128KB,可通过XX:PretenureSizeThreshold设置其大小。在Eden区的内存空间不足时会触发MinorGC,对新生代进行一次垃圾回收。
(2)ServivorTo区:保留上一次MinorGC时的幸存者。
(3)ServivorFrom区:将上一次MinorGC时的幸存者作为这一次MinorGC的被扫描者。
新生代GC算法MinorGC
新生代的GC过程叫作MinorGC,采用复制算法实现:
(1)把在Eden区和ServivorFrom区中存活的对象复制到ServivorTo区。
如果某对象的年龄达到老年代的标准(对象晋升老年代的标准由XX:MaxTenuringThreshold设置,默认为15),则将其复制到老年代,同时把这些对象的年龄加1;如果ServivorTo区的内存空间不够,则也直接将其复制到老年代;如果对象属于大对象(大小为2KB~128KB的对象属于大对象,例如通过XX:PretenureSizeThreshold=2097152设置大对象为2MB,1024×1024×2Byte=2097152Byte=2MB),则也直接将其复制到老年代。
(2)清空Eden区和ServivorFrom区中的对象。
(3)将ServivorTo区和ServivorFrom区互换,原来的ServivorTo区成为下一次GC时的ServivorFrom区。
老年代
老年代主要存放有长生命周期的对象和大对象。老年代的GC过程叫作MajorGC
在老年代,对象比较稳定,MajorGC不会被频繁触发。在进行MajorGC前,JVM会进行一次MinorGC,在MinorGC过后仍然出现老年代空间不足或无法找到足够大的连续空间分配给新创建的大对象时,会触发MajorGC进行垃圾回收,释放JVM的内存空间。
老年代GC算法MajorGC
MajorGC采用标记清除算法,该算法首先会扫描所有对象并标记存活的对象,然后回收未被标记的对象,并释放内存空间。
老年代主要存放有长生命周期的对象和大对象。老年代的GC过程叫作MajorGC。在老年代,对象比较稳定,MajorGC不会被频繁触发。在进行MajorGC前,JVM会进行一次MinorGC,在MinorGC过后仍然出现老年代空间不足或无法找到足够大的连续空间分配给新创建的大对象时,会触发MajorGC进行垃圾回收,释放JVM的内存空间。
MajorGC采用标记清除算法,该算法首先会扫描所有对象并标记存活的对象,然后回收未被标记的对象,并释放内存空间。
因为要先扫描老年代的所有对象再回收,所以MajorGC的耗时较长。MajorGC的标记清除算法容易产生内存碎片。在老年代没有内存空间可分配时,会抛出Out Of Memory异常。
永久代
永久代指内存的永久保存区域,主要存放Class和Meta(元数据)的信息。Class在类加载时被放入永久代。GC不会在程序运行期间对永久代的内存进行清理
在Java 8中永久代已经被元数据区(也叫作元空间)取代。元数据区的作用和永久代类似,二者最大的区别在于:元数据区并没有使用虚拟机的内存,而是直接使用操作系统的本地内存。因此,元空间的大小不受JVM内存的限制,只和操作系统的内存有关。
垃圾回收与算法
Java采用引用计数法和可达性分析来确定对象是否应该被回收
引用计数法
引用计数法容易产生循环引用问题。循环引用指两个对象相互引用,导致它们的引用一直存在,而不能被回收
可达性分析
为了解决引用计数法的循环引用问题,Java还采用了可达性分析来判断对象是否可以被回收。
不可达对象要经过至少两次标记才能判定其是否可以被回收,如果在两次标记后该对象仍然是不可达的,则将被垃圾收集器回收。
Java中常用的垃圾回收算法
Java中常用的垃圾回收算法有标记清除(Mark-Sweep)、复制(Copying)、标记整理(Mark-Compact)和分代收集(Generational Collecting)这4种垃圾回收算法
标记清除算法
在标记阶段标记所有需要回收的对象,在清除阶段清除可回收的对象并释放其所占用的内存空间
由于标记清除算法在清理对象所占用的内存空间后并没有重新整理可用的内存空间,因此如果内存中可被回收的小对象居多,则会引起内存碎片化的问题,继而引起大对象无法获得连续可用空间的问题。
复制算法
复制算法是为了解决标记清除算法内存碎片化的问题而设计的。复制算法首先将内存划分为两块大小相等的内存区域,即区域1和区域2,新生成的对象都被存放在区域1中,在区域1内的对象存储满后会对区域1进行一次标记,并将标记后仍然存活的对象全部复制到区域2中,这时区域1将不存在任何存活的对象,直接清理整个区域1的内存即可
复制算法的内存清理效率高且易于实现,但由于同一时刻只有一个内存区域可用,即可用的内存空间被压缩到原来的一半,因此存在大量的内存浪费。同时,在系统中有大量长时间存活的对象时,这些对象将在内存区域1和内存区域2之间来回复制而影响系统的运行效率。因此,该算法只在对象为“朝生夕死”状态时运行效率较高。
标记整理算法
标记整理算法结合了标记清除算法和复制算法的优点,其标记阶段和标记清除算法的标记阶段相同,在标记完成后将存活的对象移到内存的另一端,然后清除该端的对象并释放内存
分代收集算法
针对不同的对象类型,JVM采用了不同的垃圾回收算法,该算法被称为分代收集算法。
新生代主要存放新生成的对象,其特点是对象数量多但是生命周期短,在每次进行垃圾回收时都有大量的对象被回收;老年代主要存放大对象和生命周期长的对象,因此可回收的对象相对较少。
大部分JVM在新生代都采用了复制算法
JVM将新生代进一步划分为一块较大的Eden区和两块较小的Servivor区,Servivor区又分为ServivorFrom区和ServivorTo区。JVM在运行过程中主要使用Eden区和ServivorFrom区,进行垃圾回收时会将在Eden区和ServivorFrom区中存活的对象复制到ServivorTo区,然后清理Eden区和ServivorFrom区的内存空间
老年代主要存放生命周期较长的对象和大对象,因而每次只有少量非存活的对象被回收,因而在老年代采用标记清除算法。
若Servivor区的对象经过一次GC后仍然存活,则其年龄加1。在默认情况下,对象在年龄达到15时,将被移到老年代。
Java中的四种引用类型
Java中的引用类型有4种,分别为强引用、软引用、弱引用和虚引用
强引用
在把一个对象赋给一个引用变量时,这个引用变量就是一个强引用。有强引用的对象一定为可达性状态,所以不会被垃圾回收机制回收。因此,强引用是造成Java内存泄漏(Memory Link)的主要原因。
软引用
软引用通过SoftReference类实现。如果一个对象只有软引用,则在系统内存空间不足时该对象将被回收。
弱引用
弱引用通过WeakReference类实现,如果一个对象只有弱引用,则在垃圾回收过程中一定会被回收。
虚引用
虚引用通过PhantomReference类实现,虚引用和引用队列联合使用,主要用于跟踪对象的垃圾回收状态。
分代收集算法与分区收集算法
分代收集算法
新生代 复制算法
新生代主要存储短生命周期的对象,因此在垃圾回收的标记阶段会标记大量已死亡的对象及少量存活的对象,因此只需选用复制算法将少量存活的对象复制到内存的另一端并清理原区域的内存即可。
老年代 标记清除算法
老年代主要存放长生命周期的对象和大对象,可回收的对象一般较少,因此JVM采用标记清除算法进行垃圾回收,直接释放死亡状态的对象所占用的内存空间即可。
分区收集算法
分区收集算法可以根据系统可接受的停顿时间,每次都快速回收若干个小区域的内存,以缩短垃圾回收时系统停顿的时间,最后以多次并行累加的方式逐步完成整个内存区域的垃圾回收。
垃圾收集器
针对新生代提供的垃圾收集器有Serial、ParNew、Parallel Scavenge,针对老年代提供的垃圾收集器有Serial Old、Parallel Old、CMS,还有针对不同区域的G1分区收集算法
垃圾收集器
Serial垃圾收集器:单线程,复制算法(新生代)
Serial 垃圾收集器计数复制算法实现,他是一个单线程收集器,在它正在进行垃圾收集时,必须暂停其他所有工作线程,直到垃圾收集结束。
Serial垃圾收集器是Java虚拟机运行在Client模式下的新生代的默认垃圾收集器。
ParNew垃圾收集器:多线程,复制算法(新生代)
ParNew垃圾收集器在垃圾收集过程中会暂停所有其他工作线程,是Java虚拟机运行在Server模式下的新生代的默认垃圾收集器。
ParNew垃圾收集器默认开启与CPU同等数量的线程进行垃圾回收,在Java应用启动时可通过-XX:ParallelGCThreads参数调节ParNew垃圾收集器的工作线程数。
Parallel Scavenge垃圾收集器:多线程,复制算法(新生代)
Parallel Scavenge收集器是为提高新生代垃圾收集效率而设计的垃圾收集器,基于多线程复制算法实现,在系统吞吐量上有很大的优化,可以更高效地利用CPU尽快完成垃圾回收任务
提供了三个参数用于调节、控制垃圾回收的停顿时间及吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数,控制吞吐量大小的-XX:GCTimeRatio参数和控制自适应调节策略开启与否的UseAdaptiveSizePolicy参数。
Serial Old 垃圾收集器:单线程,标记整理算法
Serial Old垃圾收集器是JVM运行在Client模式下的老年代的默认垃圾收集器。
新生代的Serial垃圾收集器和老年代的Serial Old垃圾收集器可搭配使用
Parallel Old垃圾收集器:多线程,标记整理算法
Parallel Old 垃圾收集器在设计张优先考虑系统吞吐量,其次考虑停顿时间等因素,如果系统对吞吐量的要求较高,则可以优先考虑新生代的Parallel Scavenge垃圾收集器和老年代的Parallel Old垃圾收集器的配合使用。
CMS垃圾收集器
CMS的主要目标是达到最短的垃圾回收停顿时间,基于线程的标记清除算法实现,以便在多线程并发环境下以最短的垃圾收集停顿时间提高系统的稳定性。
CMS工作步骤:
- 初始标记:只标记和GC Roots直接关联的对象,速度很快,需要暂停所有工作线程。
- 并发标记:和用户线程一起工作,执行GC Roots跟踪标记过程,不需要暂停工作线程
- 重新标记:在并发标记过程中用户线程继续运行,导致在垃圾回收过程中部分对象的状态发生变化,为了确保这部分对象的状态正确性,需要对其重新标记并暂停工作线程。
- 并发清除:和用户线程一起工作,执行清除GC Roots不可达对象的任务不需要暂停工作线程。
CMS垃圾收集器在和其他用户线程一起工作时(并发标记和并发清除)不需要暂停用户线程,有效缩短了垃圾回收时系统的停顿时间,同时由于CMS垃圾收集器和用户线程一起工作,因此其并行度和效率也有很大提升
G1垃圾收集器
G1(Gabage First)垃圾收集器为了避免全区域垃圾收集引起的系统停顿,将堆内存划分为大小固定的几个独立区域,独立使用这些区域的内存资源并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,在垃圾回收过程中根据系统允许的最长垃圾收集时间,优先回收垃圾最多的区域。
相对于CMS收集器,G1垃圾收集器两个突出的改进:
- 基于标记整理算法,不产生内存碎片。
- 可以精确的控制停顿时间,在不牺牲吞吐量的前提下实现短停顿垃圾回收。
网络编程模型
阻塞I/O模型
阻塞I/O的工作流程为:在用户线程发出I/O请求之后,内核会检查数据是否就绪,此时用户线程一直组设等待内存数据 就绪;在内存数据就绪后,内核将数组复制到用夯实线程中,并返回I/O执行结果到用户线程,此时用户线程将解除阻塞状态并开始处理数据。
非阻塞I/O模型
非阻塞I/O模型指用户线程在发器一个I/O操作后,无需阻塞便可以马上得到内核返回的一个结果。
在非阻塞I/O模型中,用户线程需要不断询问内核数据是否就绪,在内存数据还未就绪时,用户线程可以处理其他任务,在内核数据就绪后可立即获取数据并进行相应的操作。
多路复用I/O模型
时多线程并发编程用的比较多的模型,Java NIO就是基于多路复用I/O模型实现的。
在多路复用I/O模型中回右一个被称为Selector的线程不断轮询多个Socket的状态,只有在Socket的有读写事件时,才会通知用户线程进行I/O读写操作。
多路复用I/O模型在连接数众多且消息体不大的情况下有很大的优势。
非阻塞I/O模型在每个用户线程中都进行Socket状态检查,而在多路复用I/O模型中是在系统内核中进行Socket状态检查的,这也是多路复用I/O模型比非阻塞I/O模型效率高的原因。
对于多路复用I/O模型来说,在事件响应体(消息体)很大时,Selector线程就会成为性能瓶颈,导致后续的事件迟迟得不到处理,影响下一轮的事件轮询。
信号驱动I/O模型
在信号驱动I/O模型中,在用户线程发起一个I/O请求操作时,系统会为该请求对应的Socket注册一个信号函数,然后用户线程可以继续执行其他业务逻辑;在内核数据就绪时,系统会发送一个信号到用户线程,用户线程在接受到该信号后,会在信号函数中调用对应的I/O读写操作完成时机的I/O请求操作。
异步I/O模型
在异步I/O模型中,用户线程会发起一个asynchronous read 操作到内核,内核在接收到Synchronous read请求后会立即返回一个状态,来说明请求是否成功发起,在此过程中用户线程不会发生任何阻塞。接着,内核会等待数据准备完成并将数据复制到用户线程,通知用户线程Asynchronous读操作已完成。在异步I/O模型中,用户线程不需要关心整个I/O操作时如何进行的,只需发起一个请求,在接收到内核返回的成功或失败信号时说明I/O操作已经完成,直接使用数据即可。
一步I/Oc操作需要操作系统的底层支持,在Java 7 中提供了Asynchronous I/O操作。
Java I/O
在整个Java.io包中最重要的5个类和一个接口。5个类指的是File、OutPutStream、InputStream、Writer、Reader,一个接口指的是Serializable。
Java NIO
Java NIO的实现主要涉及三大核心内容:Selector(选择器)、Channel(通道)和Buffer(缓冲区)。
传统I/O基于数据流进行I/O读写操作;而Java NIO基于channel 和BUffer 进行I/O读写操作,并且数据总是被从Channel读取到Buffer中,或者从Buffer写入Channel中
Java NIO和传统I/O的最大区别如下:
- I/O是面向流的,NIO是面向缓冲区的。
- 传统I/O的流操作时阻塞模式的,NIO的流操作是非阻塞模式的。
channel
Channel和I/O中的Stream(流)类似,只不过Stream是单向的(例如InputStream、OutputStream),而Channel是双向的,既可以用来进行读操作,也可以用来进行写操作。
NIO中Channel的主要实现有:FileChannel、DatagramChannel、SocketChannel、ServerSocketChannel,分别对应文件的I/O、UDP、TCP I/O、Socket Client和Socker Server操作。
Buffer
Buffer实际上是一个容器,其内部通过一个连续的字节数组存储I/O上的数据。
常用的Buffer实现类有:ByteBuffer、IntBuffer、CharBuffer、LongBuffer、DoubleBuffer、FloatBuffer、ShortBuffer
Selector
Selector用于检测多个注册的Channel上是否有I/O事件发生,并对检测到的I/O事件进行相应的相应和处理。
JVM的类加载机制
JVM的类加载阶段
加载->验证->准备->解析->初始化
1、加载
指JVM读取Class文件,并且根据Class文件描述创建java.lang.Class对象的过程。
类加载过程主要包含将Class文件读取到运行时区域的方法区内,在堆中创建java.lang.Class对象,并封装类在方法区的数据结构的过程。
2、验证
主要用于确保Class文件符合当前虚拟机的要求,保障虚拟机自身的安全,只有通过验证的Class文件才能被JVM加载。
3、准备
主要工作是在方法区中为类变量分配内存空间并设置类中变量的初始值。
静态变量在准备阶段的初始值是0,赋值的动作是在对象初始化时完成的。
JVM在编译阶段后会为final类型的变量生成其对应的ConstantValue属性,虚拟机在准备阶段会根据ConstanValue属性给变量赋值。
4、解析
JVM会将常量池中的符号引用替换为直接引用
5、初始化
主要通过执行类构造器的
发生以下几种情况时,JVM不会执行类的初始化流程:
- 常量在编译时会将其常量值存入使用该常量的类的常量池中,该过程不需要调用常量所在的类,因此不会出发该常量类的初始化。
- 在子类引用父类的静态字段时,不会触发子类的初始化,只会触发父类的初始化。
- 定义对象数组,不会触发该类的初始化。
- 在使用类名获取Class对象时不会触发类的初始化。
- 在使用Ckass.ForName加载指定的类时,可以通过initialize参数设置是否需要对类进行初始化。
- 在使用ClassLoader默认的loadClass方法加载类时不会触发该类的初始化
类加载器
JVM提供了3种类加载器,分别是启动类加载器、扩展类加载器和应用程序类加载器
1、启动类加载器:负责加载Java_HOME/lib目录中的类库,或通过-Xbootclasspath参数指定路径中被虚拟机认可的类库。
2、扩展类加载器:负责加载Java_HOME/lib/ext目录中的类库,或通过java.ext.dirs系统变量加载指定路径中的类库。
3、应用程序类加载器:负责加载用户路径(classpath)上的类库、
双亲委派机制(重要)
JVM通过双亲委派机制对类进行加载。双亲委派机制指一个类在收到类加载请求后不会尝试自己加载这个类,而是把该类加载请求向上委派给其父类去完成,其父类在接收到该类加载请求后又会将其委派给自己的父类,以此类推,这样所有的类加载请求都被向上委派到启动类加载器中。
若父类加载器在接收到类加载请求后发现自己也无法加载该类(通常原因时该类的Class文件在父类的类加载路径中不存在),则父类会将该信息反馈给子类并向下委派子类加载器加载该类,直到该类被成功加载,若找不到该类,则JVM会抛出ClassNotFound异常。
- 将自定义加载器挂载到应用程序类加载器。
- 应用程序类加载器将类加载请求委托给扩展类加载器。
- 扩展类加载器将类加载请求委托给七大类加载器。
- 启动类加载器在加载路径下查找并加载Class文件,如果未找到目标Class文件,则交由扩展类加载器加-载。
- 扩展类加载器在加载路径下查找并加载Class文件,如果未找到目标Class文件,则交由自定义加载器加载。
- 应用程序类加载器在加载路径下查找并加载Class文件,如果未找到目标Class文件,则交由自定义加载器加载。
- 在自定义加载器下查找并加载用户指定目录下的Class文件,如果在自定义加载路径下未找到目标Class文件,则抛出ClassNotFound异常。
双亲委派机制的核心时保障类的唯一性和安全性。
OSGI(Open Service Gateway Initiative)是 Java动态化模块系统的一系列规范,旨在为实现Java程序员的模块化编程提供基础条件。
集合
ArrayList:不适合随机插入和删除的操作,更适合随机查找和遍历的操作。
Vector:基于数组实现,增删慢,查询快,线程安全
Vector的数据结构和ArrayList一样,都是基于数组实现的,不同的是Vector支持线程同步,即同一时刻只允许一个线程对Vector进行写操作(新增、删除、修改),以保证多线程环境下数据的一致性,但需要频繁地对Vector实例进行加锁和释放锁操作,因此,Vector的读写效率在整体上比ArrayList低。
LinkedList:基于双向链表实现,增删快,查询慢,线程不安全
LinkedList还提供了在List接口中未定义的方法,用于操作链表头部和尾部的元素,因此有时可以被当作堆栈、队列或双向队列使用。
- ArrayBlockingQueue:基于数组数据结构实现的有界阻塞队列。
- LinkedBlockingQueue:基于链表数据结构实现的有界阻塞队列。
- PriorityBlockingQueue:支持优先级排序的无界阻塞队列。
- DelayQueue:支持延迟操作的无界阻塞队列。
- SynchronousQueue:用于线程同步的阻塞队列。
- LinkedTransferQueue:基于链表数据结构实现的无界阻塞队列。
- LinkedBlockingDeque:基于链表数据结构实现的双向阻塞队列。
对象的相等性在本质上是对象的HashCode值相同,Java依据对象的内存地址计算出对象的HashCode值。如果想要比较两个对象是否相等,则必须同时覆盖对象的hashCode方法和equals方法,并且hashCode方法和equals方法的返回值必须相同。
HashSet:HashTable实现,无序
HashSet存放的是散列值,它是按照元素的散列值来存取元素的。元素的散列值是通过元素的hashCode方法计算得到的,HashSet首先判断两个元素的散列值是否相等,如果散列值相等,则接着通过equals方法比较,如果equls方法返回的结果也为true, HashSet就将其视为同一个元素;如果equals方法返回的结果为false, HashSet就不将其视为同一个元素。
TreeSet:二叉树实现
TreeSet基于二叉树的原理对新添加的对象按照指定的顺序排序(升序、降序),每添加一个对象都会进行排序,并将对象插入二叉树指定的位置。
LinkHashSet:HashTable实现数据存储,双向链表记录顺序
HashMap:数组+链表+红黑树存储数据(Java8,之前是数组+链表),线程不安全
HashMap的key和value允许为null
HashMap是非线程安全的,即在同一时刻有多个线程同时写HashMap时将可能导致数据的不一致。
如果需要满足线程安全的条件,则可以用Collections的synchornizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。
HashMap的常用参数:
- capacity:当前数组的容量,默认为16,可以扩容,扩容后数组的大小为当前的两倍,因此该值始终为$2^n$
- loadFactor:负载因子,默认为0.75
- threshold: 扩容的阈值,其值等于caoacity*loadFactor
- 链表元素超过8个以后(并且桶的数量大于64个)HashMap会将链表结构转化为红黑树以提高查询效率(红黑树中的元素数量小于6个时转换为链表)
ConcurrentHashMap:分段锁实现,线程安全。
ConcurrnetHashMap采用分段锁的思想实现并发操作,因此是线程安全的。ConcurrentHashMap由多个Segment组成(Segment的数量也是锁的并发度),每个Segment均继承自ReentantLock并单独枷锁,所以每次进行枷锁操作时锁住的都是一个Segment,这样只要保证每个Segment都是线程安全的,也就实现了全局的线程安全。
ConcurrentHashMap中有个concurrencyLevel参数表示并行级别,默认是16,也就是说ConcurrnetHashMap默认由16个Segments组成,在这种情况下做多同时支持16个线程并发执行写操作,只要他们的操作分布在不同的Segment上即可。并行级别concurrnecyLevel可以在初始化时设置,一旦初始化就不可更改。
HashTable
HashTable是遗留类,很多映射的常用功能都与HashMap类似,不同的是它继承自Dicitionary类,并且使线程安全的,每一时刻只能由一个线程写HashTable,并发性不如ConcurrentHashMap
TreeMap
TreeMap基于二叉树数据结构存储数据,同时实现了SortedMap接口以保障元素的顺序存取,默认按照键值的升序排序,也可以自定义排序比较器。
在使用TreeMap时其key必须实现Comparable接口或采用自定义的比较器,否则会抛出java.lang.ClassCastException异常。
LinkedHashMap:基于hashTable数据结构,使用链表保存插入顺序
在通过Iterator遍历LinkedhashMap时,会按照元素的插入顺序访问元素。
异常
java中Throwable是所有错误或异常的父类,Throwable又可分为Error和Exception。
常见的Error有:AWTError、ThreadDeath。
Exception又可分为RuntimeException和CheckedException
Error
指Java程序运行错误,如果程序在启动时出现Error,则启动失败;如果程序在运行过程中出现Error,则系统将退出进程。
出现Error通常是因为系统的内部错误或资源耗尽,Error不能被在运行过程中被动态处理。如果程序出现Error,则系统能做的工作也只能有记录错误的成因和安全终止。
Exception
Exception指Java程序运行异常,即运行中的程序发生了人们不期望发生的事件,可以被Java异常处理机制处理。Exception也是程序开发中异常处理的核心,可分为RuntimeException(运行时异常)和CheckedException(检查异常)
- RuntimeException:指在Java虚拟机正常运行期间抛出的异常,RuntimeException可以被捕获并处理,如果出现RuntimeException,那么一定是程序发生错误导致的。我们通常需要抛出该异常或者捕获并处理该异常。常见的RuntimeException有NullPointerException、ClassCastException、ArrayIndexOutOfBundsException等。
- CheckedException:指在编译阶段Java编译器会检查CheckedException异常并强制程序捕获和处理此类异常,即要求程序在可能出现异常的地方通过try catch语句块捕获并处理异常。常见的CheckedException有由于I/O错误导致的IOException、SQLException、ClassNotFoundException等。该类异常一般由于打开错误的文件、SQL语法错误、类不存在等引起。
异常处理方式:抛出异常、使用try catch 捕获并处理异常
(1)抛出异常:遇到异常时不进行具体处理,而是将异常抛给调用者,由调用者根据情况处理。有可能是直接捕获并处理,也有可能是继续向上层抛出异常。抛出异常有三种形式:throws、throw、系统自动抛出异常。
(2)使用try catch捕获并处理异常:使用trycatch捕获异常能够有针对性地处理每种可能出现的异常,并在捕获到异常后根据不同的情况做不同的处理。其使用过程比较简单:用try catch语句块将可能出现异常的代码包起来即可。
throw 和 throws的区别
throw和throws的区别如下。
- 位置不同:throws作用在方法上,后面跟着的是异常的类;而throw作用在方法内,后面跟着的是异常的对象。
- 功能不同:throws用来声明方法在运行过程中可能出现的异常,以便调用者根据不同的异常类型预先定义不同的处理方式;throw用来抛出封装了异常信息的对象,程序在执行到throw时后续的代码将不再执行,而是跳转到调用者,并将异常信息抛给调用者。也就是说,throw后面的语句块将无法被执行(finally语句块除外)。
反射
概念
反射机制指在程序运行过程中,对任意一个类都能获取其所有属性和方法,并且对任意一个对象都能调用其任意一个方法。这种动态获取类和对象的信息,以及动态调用对象的方法的功能被称为Java语言的反射机制。
从反射的角度来说,Java属于半动态语言。
Java反射的API
Java的反射API主要用于在运行过程中动态生成类、接口或对象等信息,其常用API如下。
- Class类:用于获取类的属性、方法等信息。
- Field类:表示类的成员变量,用于获取和设置类中的属性值。
- Method类:表示类的方法,用于获取方法的描述信息或者执行某个方法。
- Constructor类:表示类的构造方法。
反射的步骤
(1)获取想要操作的类的Class对象,该Class对象是反射的核心,通过它可以调用类的任意方法。
(2)调用Class对象所对应的类中定义的方法,这是反射的使用阶段。
(3)使用反射API来获取并调用类的属性和方法等信息。
内部类
Java中的内部类根据定义方式的不同可分为:
静态内部类、成员内部类、局部内部类和匿名内部类
静态内部类
定义在类内部的静态类被称为静态内部类
静态内部类可以访问外部类的静态变量和方法;在静态内部类中可以定义静态变量、方法、构造函数等;静态内部类通过“外部类.静态内部类”的方式来调用
和外部类关系密切且不依赖外部类实例的类,可以使用静态内部类实现。
成员内部类
定义在类内部的非静态类叫作成员内部类,成员内部类不能定义静态方法和变量(final修饰的除外),因为成员内部类是非静态的,而在Java的非静态代码块中不能定义静态方法和变量。
局部内部类
定义在方法中的类叫作局部内部类
匿名内部类
匿名内部类指通过继承一个父类或者实现一个接口的方式直接定义并使用的类。匿名内部类没有class关键字,这是因为匿名内部类直接使用new生成一个对象的引用
泛型
泛型的本质是参数化类型,泛型提供了编译时类型的安全检测机制,该机制允许程序在编译时检测非法的类型。
而使用泛型的好处是在编译期就能够检查类型是否安全,同时所有强制性类型转换都是自动和隐式进行的,提高了代码的安全性和重用性。
泛型标记和泛型限定:E、T、K、V、N、?
在使用泛型的时候,若希望将类的继承关系加入泛型应用中,就需要对泛型做限定,具体的泛型限定有对泛型上线的限定和对泛型下线的限定。
对泛型上限的限定:<? extendsT>
在Java中使用通配符“? ”和“extends”关键字指定泛型的上限,具体用法为<? extends T>,它表示该通配符所代表的类型是T类的子类或者接口T的子接口。
对泛型下限的限定:<? super T>
在Java中使用通配符“? ”和“super”关键字指定泛型的下限,具体用法为<? super T>,它表示该通配符所代表的类型是T类型的父类或者父接口。
泛型类、泛型接口、泛型方法
类型擦除
在编码阶段采用泛型时加上的类型参数,会被编译器在编译时去掉,这个过程就被称为类型擦除。因此,泛型主要用于编译阶段。在编译后生成的Java字节代码文件中不包含泛型中的类型信息
Java类型的擦除过程为:首先,查找用来替换类型参数的具体类(该具体类一般为Object),如果指定了类型参数的上界,则以该上界作为替换时的具体类;然后,把代码中的类型参数都替换为具体的类。
序列化
在使用Java序列化技术保存对象及其状态信息时,对象及其状态信息会被保存在一组字节数组中,在需要时再将这些字节数组反序列化为对象。注意,对象序列化保存的是对象的状态,即它的成员变量,因此类中的静态变量不会被序列化。
Java序列化的注意事项
- 类要实现序列化功能,只需实现java.io.Serializable接口即可。
- 序列化和反序列化必须保持序列化的ID一致,一般使用private static final longserialVersionUID定义序列化ID。
- 序列化并不保存静态变量。
- 使用Transient关键字可以阻止该变量被序列化,在被反序列化后,transient变量的值被设为对应类型的初始值
transient修饰的属性和static修饰的静态属性不会被序列化。
可以基于JDK原生的ObjectOutputStream和ObjectInputStream类实现对象进行序列化及反序列化,并调用其writeObject和readObject方法实现自定义序列化策略。
Java线程创建的方式
常见的Java线程的4种创建方式分别为:
- 继承Thread类
- 实现Runnable接口
- 通过ExecutorService实现有返回值的线程、基于线程池
- Callable
实现有返回值的线程、基于线程池
继承Thread类
Thread类实现了Runnable接口并定义了操作线程的一些方法,我们可以通过继承Thread类的方式创建一个线程。
start方法是一个native方法,通过在操作系统上启动一个新线程,并最终执行run方法来启动一个线程。
实现Runnable接口
基于Java编程语言的规范,如果子类已经继承(extends)了一个类,就无法再直接继承Thread类,此时可以通过实现Runnable接口创建线程。
事实上,在传入一个实现了Runnable的线程实例target给Thread后,Thread的run方法在执行时就会调用target.run方法并执行该线程具体的实现逻辑。
通过ExecutorService和Callable实现有返回值的线程
有时,我们需要在主线程中开启多个线程并发执行一个任务,然后收集各个线程执行返回的结果并将最终结果汇总起来,这时就要用到Callable接口。
实现方式:创建一个类并实现Callable接口,在call方法中实现具体的运算逻辑并返回计算结果。具体的调用过程为:创建一个线程池、一个用于接收返回结果的Future List及Callable线程实例,使用线程池提交任务并将线程执行之后的结果保存在Future中,在线程执行结束后遍历FutureList中的Future对象,在该对象上调用get方法就可以获取Callable线程任务返回的数据并汇总结果
基于线程池
线程是非常宝贵的计算资源,在每次需要时创建并在运行结束后销毁是非常浪费资源的。我们可以使用缓存策略并使用线程池来创建线程
线程池的工作原理
JVM先根据用户的参数创建一定数量的可运行的线程任务,并将其放入队列中,在线程创建后启动这些任务,如果线程数量超过了最大线程数量(用户设置的线程池大小),则超出数量的线程排队等候,在有任务执行完毕后,线程池调度器会发现有可用的线程,进而再次从队列中取出任务并执行。
线程池的主要作用是线程复用、线程资源管理、控制操作系统的最大并发数,以保证系统高效(通过线程资源复用实现)且安全(通过控制最大线程并发数实现)地运行。
线程复用
在start方法中不断循环调用传递进来的Runnable对象,程序就会不断执行run方法中的代码,可以将在循环方法中不断获取的Runnable对象存放在Queue中,当前线程在获取下一个Runnable对象之前可以是阻塞的,这样既能有效控制正在执行的线程个数,也能保证系统中正在等待执行的其他线程有序执行。这样就简单实现了一个线程池,达到了线程复用的效果。
线程池的核心组件和核心类
- 线程池管理器:用于创建并管理线程池。
- 工作线程:线程池中执行具体任务的线程
- 任务接口:用于定义工作线程的调度和执行策略,只有线程实现了该接口,线程中的任务才能够被线程池调度。
- 任务队列:存放待处理的任务,新的任务将会不断被加入队列中,执行完成的任务将被从队列中移除。
Java中的线程池是通过Executor框架实现的,在该框架中用到了Executor、Executors、ExecutorService、ThreadPoolExecutor、Callable、Future、FutureTask这几个核心类
Java线程池的工作流程
Java线程池的工作流程为:线程池刚被创建时,只是向系统申请一个用于执行线程队列和管理线程池的线程资源。在调用execute()添加一个任务时,线程池会按照以下流程执行任务。
- 如果正在运行的线程数量少于corePoolSize(用户定义的核心线程数),线程池就会立刻创建线程并执行该线程任务。
- 如果正在运行的线程数量大于等于corePoolSize,该任务就将被放入阻塞队列中。
- 在阻塞队列已满且正在运行的线程数量少于maximumPoolSize时,线程池会创建非核心线程立刻执行该线程任务。
- 在阻塞队列已满且正在运行的线程数量大于等于maximumPoolSize时,线程池将拒绝执行该线程任务并抛出RejectExecutionException异常。
- 在线程任务执行完毕后,该任务将被从线程池队列中移除,线程池将从队列中取下一个线程任务继续执行。
- 在线程处于空闲状态的时间超过keepAliveTime时间时,正在运行的线程数量超过corePoolSize,该线程将会被认定为空闲线程并停止。因此在线程池中所有线程任务都执行完毕后,线程池会收缩到corePoolSize大小。
线程池的拒绝策略
为了保证操作系统的安全,线程池将通过拒绝策略处理新添加的线程任务。JDK内置的拒绝策略有AbortPolicy、CallerRunsPolicy、DiscardOldestPolicy、DiscardPolicy这4种,默认的拒绝策略在ThreadPoolExecutor中作为内部类提供。
- AbortPolicyAbortPolicy直接抛出异常,阻止线程正常运行
- CallerRunsPolicy如果被丢弃的线程任务未关闭,则执行该线程任务。注意,CallerRunsPolicy拒绝策略不会真的丢弃任务。
- DiscardOldestPolicy的拒绝策略为:移除线程队列中最早的一个线程任务,并尝试提交当前任务。
- DiscardPolicy的拒绝策略为:丢弃当前的线程任务而不做任何处理。如果系统允许在资源不足的情况下丢弃部分任务,则这将是保障系统安全、稳定的一种很好的方案。
- 自定义拒绝策略:以上4种拒绝策略均实现了RejectedExecutionHandler接口,若无法满足实际需要,则用户可以自己扩展RejectedExecutionHandler接口来实现拒绝策略,并捕获异常来实现自定义拒绝策略
五种常用的线程池
newCachedThreadPool
newCachedThreadPool用于创建一个缓存线程池。之所以叫缓存线程池,是因为它在创建新线程时如果有可重用的线程,则重用它们,否则重新创建一个新的线程并将其添加到线程池中。
对于执行时间很短的任务而言,newCachedThreadPool线程池能很大程度地重用线程进而提高系统的性能
newFixedThreadPool
newFixedThreadPool用于创建一个固定线程数量的线程池,并将线程资源存放在队列中循环使用。在newFixedThreadPool线程池中,若处于活动状态的线程数量大于等于核心线程池的数量,则新提交的任务将在阻塞队列中排队,直到有可用的线程资源
newScheduledThreadPool
newScheduledThreadPool创建了一个可定时调度的线程池,可设置在给定的延迟时间后执行或者定期执行某个线程任务:
newSingleThreadExecutor
newSingleThreadExecutor线程池会保证永远有且只有一个可用的线程,在该线程停止或发生异常时,newSingleThreadExecutor线程池会启动一个新的线程来代替该线程继续执行任务:
newWorkStealingPool
newWorkStealingPool创建持有足够线程的线程池来达到快速运算的目的,在内部通过使用多个队列来减少各个线程调度产生的竞争。
线程的生命周期
线程的生命周期分为新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)和死亡(Dead)这5种状态。
- 调用new方法新建一个线程,这时线程处于新建状态。
- 调用start方法启动一个线程,这时线程处于就绪状态。
- 处于就绪状态的线程等待线程获取CPU资源,在等待其获取CPU资源后线程会执行run方法进入运行状态。
- 正在运行的线程在调用了yield方法或失去处理器资源时,会再次进入就绪状态。
- 正在执行的线程在执行了sleep方法、I/O阻塞、等待同步锁、等待通知、调用suspend方法等操作后,会挂起并进入阻塞状态,进入Blocked池。
- 阻塞状态的线程由于出现sleep时间已到、I/O方法返回、获得同步锁、收到通知、调用resume方法等情况,会再次进入就绪状态,等待CPU时间片的轮询。该线程在获取CPU资源后,会再次进入运行状态。
- 处于运行状态的线程,在调用run方法或call方法正常执行完成、调用stop方法停止线程或者程序执行错误导致异常退出时,会进入死亡状态。
新建状态:New
在Java中使用new关键字创建一个线程,新创建的线程将处于新建状态。在创建线程时主要是为线程分配内存并初始化其成员变量的值。
就绪状态:Runnable
新建的线程对象在调用start方法之后将转为就绪状态。此时JVM完成了方法调用栈和程序计数器的创建,等待该线程的调度和运行。
运行状态:Running
就绪状态的线程在竞争到CPU的使用权并开始执行run方法的线程执行体时,会转为运行状态,处于运行状态的线程的主要任务就是执行run方法中的逻辑代码。
阻塞状态:Blocked
运行中的线程会主动或被动地放弃CPU的使用权并暂停运行,此时该线程将转为阻塞状态,直到再次进入可运行状态,才有机会再次竞争到CPU使用权并转为运行状态。阻塞的状态分为以下三种。
- 等待阻塞:在运行状态的线程调用o.wait方法时,JVM会把该线程放入等待队列(Waitting Queue)中,线程转为阻塞状态。
- 同步阻塞:在运行状态的线程尝试获取正在被其他线程占用的对象同步锁时,JVM会把该线程放入锁池(Lock Pool)中,此时线程转为阻塞状态。
- 其他阻塞:运行状态的线程在执行Thread.sleep(long ms)、Thread.join()或者发出I/O请求时,JVM会把该线程转为阻塞状态。直到sleep()状态超时、Thread.join()等待线程终止或超时,或者I/O处理完毕,线程才重新转为可运行状态。
线程死亡:Dead
线程在以下面三种方式结束后转为死亡状态。
- 线程正常结束:run方法或call方法执行完成。
- 线程异常退出:运行中的线程抛出一个Error或未捕获的Exception,线程异常退出。
- 手动结束:调用线程对象的stop方法手动结束运行中的线程(该方式会瞬间释放线程占用的同步对象锁,导致锁混乱和死锁,不推荐使用)。
线程的基本方法
线程相关的基本方法有wait、notify、notifyAll、sleep、join、yield等,这些方法控制线程的运行,并影响线程的状态变化。
线程等待:wait方法
调用wait方法的线程会进入WAITING状态,只有等到其他线程的通知或被中断后才会返回。需要注意的是,在调用wait方法后会释放对象的锁,因此wait方法一般被用于同步方法或同步代码块中。
线程睡眠:sleep方法
调用sleep方法会导致当前线程休眠。与wait方法不同的是,sleep方法不会释放当前占有的锁,会导致线程进入TIMED-WATING状态,而wait方法会导致当前线程进入WATING状态。
线程让步:yield方法
调用yield方法会使当前线程让出(释放)CPU执行时间片,与其他线程一起重新竞争CPU时间片。在一般情况下,优先级高的线程更有可能竞争到CPU时间片,但这不是绝对的,有的操作系统对线程的优先级并不敏感。
线程中断:interrupt方法
interrupt方法用于向线程发行一个终止通知信号,会影响该线程内部的一个中断标识位,这个线程本身并不会因为调用了interrupt方法而改变状态(阻塞、终止等)。状态的具体变化需要等待接收到中断标识的程序的最终处理结果来判定。对interrupt方法的理解需要注意以下4个核心点。
◎ 调用interrupt方法并不会中断一个正在运行的线程,也就是说处于Running状态的线程并不会因为被中断而终止,仅仅改变了内部维护的中断标识位而已。
◎ 若因为调用sleep方法而使线程处于TIMED-WATING状态,则这时调用interrupt方法会抛出InterruptedException,使线程提前结束TIMED-WATING状态。
◎ 许多声明抛出InterruptedException的方法如Thread.sleep(long mills),在抛出异常前都会清除中断标识位,所以在抛出异常后调用isInterrupted方法将会返回false。
◎ 中断状态是线程固有的一个标识位,可以通过此标识位安全终止线程。比如,在想终止一个线程时,可以先调用该线程的interrupt方法,然后在线程的run方法中根据该线程isInterrupted方法的返回状态值安全终止线程。
线程加入:join方法
join方法用于等待其他线程终止,如果在当前线程中调用一个线程的join方法,则当前线程转为阻塞状态,等到另一个线程结束,当前线程再由阻塞状态转为就绪状态,等待获取CPU的使用权。在很多情况下,主线程生成并启动了子线程,需要等到子线程返回结果并收集和处理再退出,这时就要用到join方法
线程唤醒:notify方法
Object类有个notify方法,用于唤醒在此对象监视器上等待的一个线程,如果所有线程都在此对象上等待,则会选择唤醒其中一个线程,选择是任意的。
后台守护线程:setDaemon方法
setDaemon方法用于定义一个守护线程,也叫作“服务线程”,该线程是后台线程,有一个特性,即为用户线程提供公共服务,在没有用户线程可服务时会自动离开。守护线程的优先级较低,用于为系统中的其他对象和线程提供服务。将一个用户线程设置为守护线程的方法是在线程对象创建之前用线程对象的setDaemon(true)来设置。
垃圾回收线程就是一个经典的守护线程
sleep方法与wait方法的区别
◎ sleep方法属于Thread类,wait方法则属于Object类。
◎ sleep方法暂停执行指定的时间,让出CPU给其他线程,但其监控状态依然保持,在指定的时间过后又会自动恢复运行状态。
◎ 在调用sleep方法的过程中,线程不会释放对象锁。
◎ 在调用wait方法时,线程会放弃对象锁,进入等待此对象的等待锁池,只有针对此对象调用notify方法后,该线程才能进入对象锁池准备获取对象锁,并进入运行状态。
start方法与run方法的区别
start方法与run方法的区别如下。
◎ start方法用于启动线程,真正实现了多线程运行。在调用了线程的start方法后,线程会在后台执行,无须等待run方法体的代码执行完毕,就可以继续执行下面的代码。
◎ 在通过调用Thread类的start方法启动一个线程时,此线程处于就绪状态,并没有运行。
◎ run方法也叫作线程体,包含了要执行的线程的逻辑代码,在调用run方法后,线程就进入运行状态,开始运行run方法中的代码。在run方法运行结束后,该线程终止,CPU再调度其他线程。
终止线程的四种方式
1.正常运行结束指线程体执行完成,线程自动结束。
使用退出标志退出线程
可以使用一个变量来控制循环,比如设置一个boolean类型的标志,并通过设置这个标志为true或false来控制while循环是否退出
使用Interrupt方法终止线程
(1)线程处于阻塞状态。例如,在使用了sleep、调用锁的wait或者调用socket的receiver、accept等方法时,会使线程处于阻塞状态。在调用线程的interrupt方法时,会抛出InterruptException异常。我们通过代码捕获该异常,然后通过break跳出状态检测循环,可以有机会结束这个线程的执行。
(2)线程未处于阻塞状态。此时,使用isInterrupted方法判断线程的中断标志来退出循环。在调用interrupt方法时,中断标志会被设置为true,并不能立刻退出线程,而是执行线程终止前的资源释放操作,等待资源释放完毕后退出该线程。
使用stop方法终止线程:不安全
在程序中可以直接调用Thread.stop方法强行终止线程,但这是很危险的,就像突然关闭计算机的电源,而不是正常关机一样,可能会产生不可预料的后果。
Java中的锁
Java中的锁主要用于保障多并发线程情况下数据的一致性。
锁从乐观和悲观的角度可分为乐观锁和悲观锁,从获取资源的公平性角度可分为公平锁和非公平锁,从是否共享资源的角度可分为共享锁和独占锁,从锁的状态的角度可分为偏向锁、轻量级锁和重量级锁,在JVM中还巧妙设计了自旋锁以更快地使用CPU资源。
乐观锁
乐观锁采用乐观的思想处理数据,在每次读取数据时都认为别人不会修改该数据,所以不会上锁,但在更新时会判断在此期间别人有没有更新该数据,通常采用在写时先读出当前版本号然后加锁的方法。具体过程为:比较当前版本号与上一次的版本号,如果版本号一致,则更新,如果版本号不一致,则重复进行读、比较、写操作。
Java中的乐观锁大部分是通过CAS(Compare And Swap,比较和交换)操作实现的,CAS是一种原子更新操作,在对数据操作之前首先会比较当前值跟传入的值是否一样,如果一样则更新,否则不执行更新操作,直接返回失败状态。
悲观锁
悲观锁采用悲观思想处理数据,在每次读取数据时都认为别人会修改数据,所以每次在读写数据时都会上锁,这样别人想读写这个数据时就会阻塞、等待直到拿到锁。
Java中的悲观锁大部分基于AQS(Abstract Queued Synchronized,抽象的队列同步器)架构实现。AQS定义了一套多线程访问共享资源的同步框架,许多同步类的实现都依赖于它,例如常用的Synchronized、ReentrantLock、Semaphore、CountDownLatch等。该框架下的锁会先尝试以CAS乐观锁去获取锁,如果获取不到,则会转为悲观锁(如RetreenLock)。
自旋锁
自旋锁认为:如果持有锁的线程能在很短的时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞、挂起状态,只需等一等(也叫作自旋),在等待持有锁的线程释放锁后即可立即获取锁,这样就避免了用户线程在内核状态的切换上导致的锁时间消耗。
线程在自旋时会占用CPU,在线程长时间自旋获取不到锁时,将会产CPU的浪费,甚至有时线程永远无法获取锁而导致CPU资源被永久占用,所以需要设定一个自旋等待的最大时间。在线程执行的时间超过自旋等待的最大时间后,线程会退出自旋模式并释放其持有的锁。
自旋锁的优缺点
自旋锁的优缺点如下。
◎ 优点:自旋锁可以减少CPU上下文的切换,对于占用锁的时间非常短或锁竞争不激烈的代码块来说性能大幅度提升,因为自旋的CPU耗时明显少于线程阻塞、挂起、再唤醒时两次CPU上下文切换所用的时间。
◎ 缺点:在持有锁的线程占用锁时间过长或锁的竞争过于激烈时,线程在自旋过程中会长时间获取不到锁资源,将引起CPU的浪费。所以在系统中有复杂锁依赖的情况下不适合采用自旋锁。
自旋锁的时间阈值
JDK的不同版本所采用的自旋周期不同,JDK 1.5为固定DE时间,JDK 1.6引入了适应性自旋锁。适应性自旋锁的自旋时间不再是固定值,而是由上一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的,可基本认为一个线程上下文切换的时间是就一个最佳时间。
synchronized
synchronized关键字用于为Java对象、方法、代码块提供线程安全的操作。synchronized属于独占式的悲观锁,同时属于可重入锁。在使用synchronized修饰对象时,同一时刻只能有一个线程对该对象进行访问;在synchronized修饰方法、代码块时,同一时刻只能有一个线程执行该方法体或代码块,其他线程只有等待当前线程执行完毕并释放锁资源后才能访问该对象或执行同步代码块。
Java中的每个对象都有个monitor对象,加锁就是在竞争monitor对象。对代码块加锁是通过在前后分别加上monitorenter和monitorexit指令实现的,对方法是否加锁是通过一个标记位来判断的。
synchronized的作用范围
◎ synchronized作用于成员变量和非静态方法时,锁住的是对象的实例,即this对象。
◎ synchronized作用于静态方法时,锁住的是Class实例,因为静态方法属于Class而不属于对象。
◎ synchronized作用于一个代码块时,锁住的是所有代码块中配置的对象。
synchronized的实现原理
在synchronized内部包括ContentionList、EntryList、WaitSet、OnDeck、Owner、! Owner这6个区域,每个区域的数据都代表锁的不同状态。
◎ ContentionList:锁竞争队列,所有请求锁的线程都被放在竞争队列中。
◎ EntryList:竞争候选列表,在Contention List中有资格成为候选者来竞争锁资源的线程被移动到了Entry List中。
◎ WaitSet:等待集合,调用wait方法后被阻塞的线程将被放在WaitSet中。
◎ OnDeck:竞争候选者,在同一时刻最多只有一个线程在竞争锁资源,该线程的状态被称为OnDeck。
◎ Owner:竞争到锁资源的线程被称为Owner状态线程。
◎ !Owner:在Owner线程释放锁后,会从Owner的状态变成!Owner。
synchronized在收到新的锁请求时首先自旋,如果通过自旋也没有获取锁资源,则将被放入锁竞争队列ContentionList中。
为了防止锁竞争时ContentionList尾部的元素被大量的并发线程进行CAS访问而影响性能,Owner线程会在释放锁资源时将ContentionList中的部分线程移动到EntryList中,并指定EntryList中的某个线程(一般是最先进入的线程)为OnDeck线程。Owner线程并没有直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,让OnDeck线程重新竞争锁。在Java中把该行为称为“竞争切换”,该行为牺牲了公平性,但提高了性能。
获取到锁资源的OnDeck线程会变为Owner线程,而未获取到锁资源的线程仍然停留在EntryList中。
Owner线程在被wait方法阻塞后,会被转移到WaitSet队列中,直到某个时刻被notify方法或者notifyAll方法唤醒,会再次进入EntryList中。ContentionList、EntryList、WaitSet中的线程均为阻塞状态,该阻塞是由操作系统来完成的(在Linux内核下是采用pthread_mutex_lock内核函数实现的)。
Owner线程在执行完毕后会释放锁的资源并变为!Owner状态
在synchronized中,在线程进入ContentionList之前,等待的线程会先尝试以自旋的方式获取锁,如果获取不到就进入ContentionList,该做法对于已经进入队列的线程是不公平的,因此synchronized是非公平锁。另外,自旋获取锁的线程也可以直接抢占OnDeck线程的锁资源。
synchronized是一个重量级操作,需要调用操作系统的相关接口,性能较低,给线程加锁的时间有可能超过获取锁后具体逻辑代码的操作时间。
JDK 1.6对synchronized做了很多优化,引入了适应自旋、锁消除、锁粗化、轻量级锁及偏向锁等以提高锁的效率。锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。这种升级过程叫作锁膨胀。在JDK 1.6中默认开启了偏向锁和轻量级锁,可以通过-XX:UseBiasedLocking禁用偏向锁。
ReentrantLock
ReentrantLock继承了Lock接口并实现了在接口中定义的方法,是一个可重入的独占锁。ReentrantLock通过自定义队列同步器(Abstract Queued Sychronized,AQS)来实现锁的获取与释放。
独占锁指该锁在同一时刻只能被一个线程获取,而获取锁的其他线程只能在同步队列中等待;可重入锁指该锁能够支持一个线程对同一个资源执行多次加锁操作。
ReentrantLock支持公平锁和非公平锁的实现。公平指线程竞争锁的机制是公平的,而非公平指不同的线程获取锁的机制是不公平的。
ReentrantLock不但提供了synchronized对锁的操作功能,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。
ReentrantLock的用法
定义一个ReentrantLock,在需要加锁的地方通过lock方法加锁,等资源使用完成后再通过unlock方法释放锁
ReentrantLock之所以被称为可重入锁,是因为ReentrantLock锁可以反复进入。即允许连续两次获得同一把锁,两次释放同一把锁。
注意,获取锁和释放锁的次数要相同,如果释放锁的次数多于获取锁的次数,Java就会抛出java.lang.IllegalMonitorStateException异常;如果释放锁的次数少于获取锁的次数,该线程就会一直持有该锁,其他线程将无法获取锁资源。
ReentrantLock如何避免死锁:响应中断、可轮询锁、定时锁
(1)响应中断:在synchronized中如果有一个线程尝试获取一把锁,则其结果是要么获取锁继续执行,要么保持等待。ReentrantLock还提供了可响应中断的可能,即在等待锁的过程中,线程可以根据需要取消对锁的请求。
(2)可轮询锁:通过boolean tryLock()获取锁。如果有可用锁,则获取该锁并返回true,如果无可用锁,则立即返回false。
(3)定时锁:通过boolean tryLock(long time, TimeUnit unit) throwsInterruptedException获取定时锁。如果在给定的时间内获取到了可用锁,且当前线程未被中断,则获取该锁并返回true。如果在给定的时间内获取不到可用锁,将禁用当前线程,并且在发生以下三种情况之前,该线程一直处于休眠状态。
◎ 当前线程获取到了可用锁并返回true。
◎ 当前线程在进入此方法时设置了该线程的中断状态,或者当前线程在获取锁时被中断,则将抛出InterruptedException,并清除当前线程的已中断状态。
◎ 当前线程获取锁的时间超过了指定的等待时间,则将返回false。如果设定的时间小于等于0,则该方法将完全不等待。
Lock接口的主要方法
◎ void lock():给对象加锁,如果锁未被其他线程使用,则当前线程将获取该锁;如果锁正在被其他线程持有,则将禁用当前线程,直到当前线程获取锁。
◎ boolean tryLock():试图给对象加锁,如果锁未被其他线程使用,则将获取该锁并返回true,否则返回false。tryLock()和lock()的区别在于tryLock()只是“试图”获取锁,如果没有可用锁,就会立即返回。lock()在锁不可用时会一直等待,直到获取到可用锁。
◎ tryLock(long timeout TimeUnit unit):创建定时锁,如果在给定的等待时间内有可用锁,则获取该锁。
◎ void unlock():释放当前线程所持有的锁。锁只能由持有者释放,如果当前线程并不持有该锁却执行该方法,则抛出异常。
◎ Condition newCondition():创建条件对象,获取等待通知组件。该组件和当前锁绑定,当前线程只有获取了锁才能调用该组件的await(),在调用后当前线程将释放锁。
◎ getHoldCount():查询当前线程保持此锁的次数,也就是此线程执行lock方法的次数。
◎ getQueueLength():返回等待获取此锁的线程估计数,比如启动5个线程,1个线程获得锁,此时返回4。
◎ getWaitQueueLength(Condition condition):返回在Condition条件下等待该锁的线程数量。比如有5个线程用同一个condition对象,并且这5个线程都执行了condition对象的await方法,那么执行此方法将返回5。
◎ hasWaiters(Condition condition):查询是否有线程正在等待与给定条件有关的锁,即对于指定的contidion对象,有多少线程执行了condition.await方法。
◎ hasQueuedThread(Thread thread):查询给定的线程是否等待获取该锁。
◎ hasQueuedThreads():查询是否有线程等待该锁。
◎ isFair():查询该锁是否为公平锁。
◎ isHeldByCurrentThread():查询当前线程是否持有该锁,线程执行lock方法的前后状态分别是false和true。
◎ isLock():判断此锁是否被线程占用。
◎ lockInterruptibly():如果当前线程未被中断,则获取该锁。
公平锁与非公平锁
ReentrantLock支持公平锁和非公平锁两种方式。公平锁指锁的分配和竞争机制是公平的,即遵循先到先得原则。非公平锁指JVM遵循随机、就近原则分配锁的机制。
ReentrantLock通过在构造函数ReentrantLock(boolean fair)中传递不同的参数来定义不同类型的锁,默认的实现是非公平锁。这是因为,非公平锁虽然放弃了锁的公平性,但是执行效率明显高于公平锁。如果系统没有特殊的要求,一般情况下建议使用非公平锁。
tryLock、lock和lockInterruptibly的区别
tryLock、lock和lockInterruptibly的区别如下。
◎ tryLock若有可用锁,则获取该锁并返回true,否则返回false,不会有延迟或等待;tryLock(long timeout, TimeUnit unit)可以增加时间限制,如果超过了指定的时间还没获得锁,则返回false。
◎ lock若有可用锁,则获取该锁并返回true,否则会一直等待直到获取可用锁。
◎ 在锁中断时lockInterruptibly会抛出异常,lock不会。
synchronized和ReentrantLock的比较
synchronized和ReentrantLock的共同点如下。
◎ 都用于控制多线程对共享对象的访问。
◎ 都是可重入锁。
◎ 都保证了可见性和互斥性。
synchronized和ReentrantLock的不同点如下。
◎ ReentrantLock显式获取和释放锁;synchronized隐式获取和释放锁。为了避免程序出现异常而无法正常释放锁,在使用ReentrantLock时必须在finally控制块中进行解锁操作。
◎ ReentrantLock可响应中断、可轮回,为处理锁提供了更多的灵活性。
◎ ReentrantLock是API级别的,synchronized是JVM级别的。
◎ ReentrantLock可以定义公平锁。
◎ ReentrantLock通过Condition可以绑定多个条件。
◎ 二者的底层实现不一样:synchronized是同步阻塞,采用的是悲观并发策略;Lock是同步非阻塞,采用的是乐观并发策略。
◎ Lock是一个接口,而synchronized是Java中的关键字,synchronized是由内置的语言实现的。
◎ 我们通过Lock可以知道有没有成功获取锁,通过synchronized却无法做到。
◎ Lock可以通过分别定义读写锁提高多个线程读操作的效率。
Semaphore
Semaphore是一种基于计数的信号量,在定义信号量对象时可以设定一个阈值,基于该阈值,多个线程竞争获取许可信号,线程竞争到许可信号后开始执行具体的业务逻辑,业务逻辑在执行完成后释放该许可信号。在许可信号的竞争队列超过阈值后,新加入的申请许可信号的线程将被阻塞,直到有其他许可信号被释放。
Semaphore对锁的申请和释放和ReentrantLock类似,通过acquire方法和release方法来获取和释放许可信号资源。Semaphone.acquire方法默认和ReentrantLock. lockInterruptibly方法的效果一样,为可响应中断锁,也就是说在等待许可信号资源的过程中可以被Thread.interrupt方法中断而取消对许可信号的申请。
此外,Semaphore也实现了可轮询的锁请求、定时锁的功能,以及公平锁与非公平锁的机制。对公平与非公平锁的定义在构造函数中设定。
Semaphore的锁释放操作也需要手动执行,因此,为了避免线程因执行异常而无法正常释放锁,释放锁的操作必须在finally代码块中完成。
Semaphore也可以用于实现一些对象池、资源池的构建,比如静态全局对象池、数据库连接池等。此外,我们也可以创建计数为1的Semaphore,将其作为一种互斥锁的机制(也叫二元信号量,表示两种互斥状态),同一时刻只能有一个线程获取该锁。
AtomicInteger
还可以通过AtomicReference
可重入锁
可重入锁也叫作递归锁,指在同一线程中,在外层函数获取到该锁之后,内层的递归函数仍然可以继续获取该锁。在Java环境下,ReentrantLock和synchronized都是可重入锁。
公平锁与非公平锁
◎ 公平锁(Fair Lock)指在分配锁前检查是否有线程在排队等待获取该锁,优先将锁分配给排队时间最长的线程。
◎ 非公平锁(Nonfair Lock)指在分配锁时不考虑线程排队等待的情况,直接尝试获取锁,在获取不到锁时再排到队尾等待。
读写锁:ReadWriteLock
在Java中通过Lock接口及对象可以方便地为对象加锁和释放锁,但是这种锁不区分读写,叫作普通锁。为了提高性能,Java提供了读写锁。读写锁分为读锁和写锁两种,多个读锁不互斥,读锁与写锁互斥。在读的地方使用读锁,在写的地方使用写锁,在没有写锁的情况下,读是无阻塞的。
在Java中,通过读写锁的接口java.util.concurrent.locks.ReadWriteLoc的实现类ReentrantReadWriteLock来完成对读写锁的定义和使用。
共享锁和独占锁
Java并发包提供的加锁模式分为独占锁和共享锁。
◎ 独占锁:也叫互斥锁,每次只允许一个线程持有该锁,ReentrantLock为独占锁的实现。
◎ 共享锁:允许多个线程同时获取该锁,并发访问共享资源。ReentrantReadWriteLock中的读锁为共享锁的实现。
独占锁是一种悲观的加锁策略,同一时刻只允许一个读线程读取锁资源,限制了读操作的并发性;
因为并发读线程并不会影响数据的一致性,因此共享锁采用了乐观的加锁策略,允许多个执行读操作的线程同时访问共享资源。
重量级锁和轻量级锁
重量级锁是基于操作系统的互斥量(Mutex Lock)而实现的锁,会导致进程在用户态与内核态之间切换,相对开销较大。
synchronized在内部基于监视器锁(Monitor)实现,监视器锁基于底层的操作系统的Mutex Lock实现,因此synchronized属于重量级锁。重量级锁需要在用户态和核心态之间做转换,所以synchronized的运行效率不高。
JDK在1.6版本以后,为了减少获取锁和释放锁所带来的性能消耗及提高性能,引入了轻量级锁和偏向锁。
轻量级锁是相对于重量级锁而言的。轻量级锁的核心设计是在没有多线程竞争的前提下,减少重量级锁的使用以提高系统性能。轻量级锁适用于线程交替执行同步代码块的情况(即互斥操作),如果同一时刻有多个线程访问同一个锁,则将会导致轻量级锁膨胀为重量级锁。
偏向锁
除了在多线程之间存在竞争获取锁的情况,还会经常出现同一个锁被同一个线程多次获取的情况。偏向锁用于在某个线程获取某个锁之后,消除这个线程锁重入的开销,看起来似乎是这个线程得到了该锁的偏向(偏袒)。
偏向锁的主要目的是在同一个线程多次获取某个锁的情况下尽量减少轻量级锁的执行路径,因为轻量级锁的获取及释放需要多次CAS(Compare and Swap)原子操作,而偏向锁只需要在切换ThreadID时执行一次CAS原子操作,因此可以提高锁的运行效率。
在出现多线程竞争锁的情况时,JVM会自动撤销偏向锁,因此偏向锁的撤销操作的耗时必须少于节省下来的CAS原子操作的耗时。
综上所述,轻量级锁用于提高线程交替执行同步块时的性能,偏向锁则在某个线程交替执行同步块时进一步提高性能。
锁的状态总共有4种:无锁、偏向锁、轻量级锁和重量级锁。随着锁竞争越来越激烈,锁可能从偏向锁升级到轻量级锁,再升级到重量级锁,但在Java中锁只单向升级,不会降级。
分段锁
分段锁并非一种实际的锁,而是一种思想,用于将数据分段并在每个分段上都单独加锁,把锁进一步细粒度化,以提高并发效率。ConcurrentHashMap在内部就是使用分段锁实现的。
同步锁与死锁
在有多个线程同时被阻塞时,它们之间若相互等待对方释放锁资源,就会出现死锁。为了避免出现死锁,可以为锁操作添加超时时间,在线程持有锁超时后自动释放该锁。
如何进行锁优化
- 减少锁持有的时间
减少锁持有的时间指只在有线程安全要求的程序上加锁来尽量减少同步代码块对锁的持有时间。
- 减小锁粒度
减小锁粒度指将单个耗时较多的锁操作拆分为多个耗时较少的锁操作来增加锁的并行度,减少同一个锁上的竞争。在减少锁的竞争后,偏向锁、轻量级锁的使用率才会提高。减小锁粒度最典型的案例就是ConcurrentHashMap中的分段锁。
- 锁分离
锁分离指根据不同的应用场景将锁的功能进行分离,以应对不同的变化,最常见的锁分离思想就是读写锁(ReadWriteLock)
- 锁粗化
锁粗化指为了保障性能,会要求尽可能将锁的操作细化以减少线程持有锁的时间,但是如果锁分得太细,将会导致系统频繁获取锁和释放锁,反而影响性能的提升。在这种情况下,建议将关联性强的锁操作集中起来处理,以提高系统整体的效率。
- 锁消除
在开发中经常会出现在不需要使用锁的情况下误用了锁操作而引起性能下降,这多数是因为程序编码不规范引起的。这时,我们需要检查并消除这些不必要的锁来提高系统的性能。
线程上下文切换
任务的状态保存及再加载就叫作线程的上下文切换。
◎ 进程:指一个运行中的程序的实例。在一个进程内部可以有多个线程在同时运行,并与创建它的进程共享同一地址空间(一段内存区域)和其他资源。
◎ 上下文:指线程切换时CPU寄存器和程序计数器所保存的当前线程的信息。
◎ 寄存器:指CPU内部容量较小但速度很快的内存区域(与之对应的是CPU外部相对较慢的RAM主内存)。寄存器通过对常用值(通常是运算的中间值)的快速访问来加快计算机程序运行的速度。
◎ 程序计数器:是一个专用的寄存器,用于表明指令序列中CPU正在执行的位置,存储的值为正在执行的指令的位置或者下一个将被执行的指令的位置,这依赖于特定的系统。
上下文切换
上下文切换指的是内核(操作系统的核心)在CPU上对进程或者线程进行切换。上下文切换过程中的信息被保存在进程控制块(PCB-Process Control Block)中。PCB又被称作切换桢(SwitchFrame)。上下文切换的信息会一直被保存在CPU的内存中,直到被再次使用。上下文的切换流程如下。
(1)挂起一个进程,将这个进程在CPU中的状态(上下文信息)存储于内存的PCB中。
(2)在PCB中检索下一个进程的上下文并将其在CPU的寄存器中恢复。
(3)跳转到程序计数器所指向的位置(即跳转到进程被中断时的代码行)并恢复该进程。
引起线程上下文切换的原因
引起线程上下文切换的原因如下。
◎ 当前正在执行的任务完成,系统的CPU正常调度下一个任务。
◎ 当前正在执行的任务遇到I/O等阻塞操作,调度器挂起此任务,继续调度下一个任务。
◎ 多个任务并发抢占锁资源,当前任务没有抢到锁资源,被调度器挂起,继续调度下一个任务。
◎ 用户的代码挂起当前任务,比如线程执行sleep方法,让出CPU。
◎ 硬件中断。
阻塞队列
Java中的阻塞队列实现
Java中的阻塞队列有:ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue、DelayQueue、SynchronousQueue、LinkedTransferQueue、LinkedBlockingDeque。
ArrayBlockingQueue
ArrayBlockingQueue是基于数组实现的有界阻塞队列。ArrayBlockingQueue队列按照先进先出原则对元素进行排序,在默认情况下不保证元素操作的公平性。
LinkedBlockingQueue
LinkedBlockingQueue是基于链表实现的阻塞队列,同ArrayListBlockingQueue类似,此队列按照先进先出原则对元素进行排序;LinkedBlockingQueue对生产者端和消费者端分别采用了两个独立的锁来控制数据同步,我们可以将队列头部的锁理解为写锁,将队列尾部的锁理解为读锁,因此生产者和消费者可以基于各自独立的锁并行地操作队列中的数据,队列的并发性能较高。
PriorityBlockingQueue
PriorityBlockingQueue是一个支持优先级的无界队列。元素在默认情况下采用自然顺序升序排列。注意:如果两个元素的优先级相同,则不能保证该元素的存储和访问顺序。
DelayQueue
DelayQueue是一个支持延时获取元素的无界阻塞队列,在队列底层使用PriorityQueue实现。DelayQueue队列中的元素必须实现Delayed接口,该接口定义了在创建元素时该元素的延迟时间,在内部通过为每个元素的操作加锁来保障数据的一致性。
应用场景
◎ 缓存系统的设计:可以用DelayQueue保存缓存元素的有效期,使用一个线程循环查询DelayQueue,一旦能从DelayQueue中获取元素,则表示缓存的有效期到了。
◎ 定时任务调度:使用DelayQueue保存即将执行的任务和执行时间,一旦从DelayQueue中获取元素,就表示任务开始执行,Java中的TimerQueue就是使用DelayQueue实现的。
SynchronousQueue
SynchronousQueue是一个不存储元素的阻塞队列。SynchronousQueue中的每个put操作都必须等待一个take操作完成,否则不能继续添加元素。
我们可以将SynchronousQueue看作一个“快递员”,它负责把生产者线程的数据直接传递给消费者线程,非常适用于传递型场景,比如将在一个线程中使用的数据传递给另一个线程使用。SynchronousQueue的吞吐量高于LinkedBlockingQueue和ArrayBlockingQueue。
LinkedTransferQueue
LinkedTransferQueue是基于链表结构实现的无界阻塞TransferQueue队列。相对于其他阻塞队列,LinkedTransferQueue多了transfer、tryTransfer和tryTransfer(E e, long timeout, TimeUnit unit)方法。
◎ transfer方法:如果当前有消费者正在等待接收元素,transfer方法就会直接把生产者传入的元素投递给消费者并返回true。如果没有消费者在等待接收元素,transfer方法就会将元素存放在队列的尾部(tail)节点,直到该元素被消费后才返回。
◎ tryTransfer方法:首先尝试能否将生产者传入的元素直接传给消费者,如果没有消费者等待接收元素,则返回false。和transfer方法的区别是,无论消费者是否接收元素,tryTransfer方法都立即返回,而transfer方法必须等到元素被消费后才返回。
◎ tryTransfer(E e, long timeout, TimeUnit unit)方法:首先尝试把生产者传入的元素直接传给消费者,如果没有消费者,则等待指定的时间,在超时后如果元素还没有被消费,则返回false,否则返回true。
LinkedBlockingDeque
LinkedBlockingDeque是基于链表结构实现的双向阻塞队列,可以在队列的两端分别执行插入和移出元素操作。这样,在多线程同时操作队列时,可以减少一半的锁资源竞争,提高队列的操作效率。
LinkedBlockingDeque相比其他阻塞队列,多了addFirst、addLast、offerFirst、offerLast、peekFirst、peekLast等方法。以First结尾的方法表示在队列头部执行插入(add)、获取(peek)、移除(offer)操作;以Last结尾的方法表示在队列的尾部执行插入、获取、移除操作。
在初始化LinkedBlockingDeque时,可以设置队列的大小以防止内存溢出,双向阻塞队列也常被用于工作窃取模式。
Java并发关键字
CountDownLatch
CountDownLatch类位于java.util.concurrent包下,是一个同步工具类,允许一个或多个线程一直等待其他线程的操作执行完后再执行相关操作。
CountDownLatch基于线程计数器来实现并发访问控制,主要用于主线程等待其他子线程都执行完毕后执行相关操作。其使用过程为:在主线程中定义CountDownLatch,并将线程计数器的初始值设置为子线程的个数,多个子线程并发执行,每个子线程在执行完毕后都会调用countDown函数将计数器的值减1,直到线程计数器为0,表示所有的子线程任务都已执行完毕,此时在CountDownLatch上等待的主线程将被唤醒并继续执行。
CyclicBarrier
CyclicBarrier(循环屏障)是一个同步工具,可以实现让一组线程等待至某个状态之后再全部同时执行。在所有等待线程都被释放之后,CyclicBarrier可以被重用。CyclicBarrier的运行状态叫作Barrier状态,在调用await方法后,线程就处于Barrier状态。
CyclicBarrier中最重要的方法是await方法,它有两种实现。
◎ public int await():挂起当前线程直到所有线程都为Barrier状态再同时执行后续的任务。
◎ public int await(long timeout, TimeUnit unit):设置一个超时时间,在超时时间过后,如果还有线程未达到Barrier状态,则不再等待,让达到Barrier状态的线程继续执行后续的任务。
Semaphore
Semaphore指信号量,用于控制同时访问某些资源的线程个数,具体做法为通过调用acquire()获取一个许可,如果没有许可,则等待,在许可使用完毕后通过release()释放该许可,以便其他线程使用。
Semaphore常被用于多个线程需要共享有限资源的情况,比如办公室有两台打印机,但是有5个员工需要使用,一台打印机同时只能被一个员工使用,其他员工排队等候,且只有该打印机被使用完毕并释放后其他员工方可使用
在Semaphore类中有以下几个比较重要的方法。
◎ public void acquire():以阻塞的方式获取一个许可,在有可用许可时返回该许可,在没有可用许可时阻塞等待,直到获得许可。
◎ public void acquire(int permits):同时获取permits个许可。
◎ public void release():释放某个许可。
◎ public void release(int permits):释放permits个许可。
◎ public boolean tryAcquire():以非阻塞方式获取一个许可,在有可用许可时获取该许可并返回true,否则返回false,不会等待。
◎ public boolean tryAcquire(long timeout, TimeUnit unit):如果在指定的时间内获取到可用许可,则返回true,否则返回false。
◎ public boolean tryAcquire(int permits):如果成功获取permits个许可,则返回true,否则立即返回false。
◎ public boolean tryAcquire(int permits, long timeout, TimeUnit unit):如果在指定的时间内成功获取permits个许可,则返回true,否则返回false。
◎ availablePermits():查询可用的许可数量。
CountDownLatch、CyclicBarrier、Semaphore的区别如下。
◎ CountDownLatch和CyclicBarrier都用于实现多线程之间的相互等待,但二者的关注点不同。CountDownLatch主要用于主线程等待其他子线程任务均执行完毕后再执行接下来的业务逻辑单元,而CyclicBarrier主要用于一组线程互相等待大家都达到某个状态后,再同时执行接下来的业务逻辑单元。此外,CountDownLatch是不可以重用的,而CyclicBarrier是可以重用的。
◎ Semaphore和Java中的锁功能类似,主要用于控制资源的并发访问。
volatile关键字的作用
Java除了使用了synchronized保证变量的同步,还使用了稍弱的同步机制,即volatile变量。volatile也用于确保将变量的更新操作通知到其他线程。
volatile变量具备两种特性:一种是保证该变量对所有线程可见,在一个线程修改了变量的值后,新的值对于其他线程是可以立即获取的;一种是volatile禁止指令重排,即volatile变量不会被缓存在寄存器中或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
因为在访问volatile变量时不会执行加锁操作,也就不会执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。volatile主要适用于一个变量被多个线程共享,多个线程均可针对这个变量执行赋值或者读取的操作。
如果将变量声明为volatile, JVM就能保证每次读取变量时都直接从内存中读取,跳过CPU Cache这一步,有效解决了多线程数据同步的问题。
需要说明的是,volatile关键字可以严格保障变量的单次读、写操作的原子性,但并不能保证像i++这种操作的原子性,因为i++在本质上是读、写两次操作。volatile在某些场景下可以代替synchronized,但是volatile不能完全取代synchronized的位置,只有在一些特殊场景下才适合使用volatile。比如,必须同时满足下面两个条件才能保证并发环境的线程安全。
◎ 对变量的写操作不依赖于当前值(比如i++),或者说是单纯的变量赋值(boolean flag = true)。
◎ 该变量没有被包含在具有其他变量的不变式中,也就是说在不同的volatile变量之间不能互相依赖,只有在状态真正独立于程序内的其他内容时才能使用volatile。
多线程如何共享数据
在Java中进行多线程通信主要是通过共享内存实现的,共享内存主要有三个关注点:可见性、有序性、原子性。Java内存模型(JVM)解决了可见性和有序性的问题,而锁解决了原子性的问题。
将数据抽象成一个类,并将对这个数据的操作封装在类的方法中
在应用时需要注意的是,如果两个线程AddRunnable和DecRunnable需要保证数据操作的原子性和一致性,就必须在传参时使用同一个data对象入参。这样无论启动多少个线程执行对data数据的操作,都能保证数据的一致性。
将Runnable对象作为一个类的内部类,将共享数据作为这个类的成员变量
ConcurrentHashMap并发
减小锁粒度
减小锁粒度指通过缩小锁定对象的范围来减少锁冲突的可能性,最终提高系统的并发能力。减小锁粒度是一种削弱多线程锁竞争的有效方法,ConcurrentHashMap并发下的安全机制就是基于该方法实现的。
ConcurrentHashMap是线程安全的Map,对于HashMap而言,最重要的方法是get和set方法,如果为了线程安全对整个HashMap加锁,则可以得到线程安全的对象,但是加锁粒度太大,意味着同时只能有一个线程操作HashMap,在效率上就会大打折扣;而ConcurrentHashMap在内部使用多个Segment,在操作数据时会给每个Segment都加锁,这样就通过减小锁粒度提高了并发度。
ConcurrentHashMap的实现
ConcurrentHashMap在内部细分为若干个小的HashMap,叫作数据段(Segment)。在默认情况下,一个ConcurrentHashMap被细分为16个数据段,对每个数据段的数据都单独进行加锁操作。Segment的个数为锁的并发度。
ConcurrentHashMap是由Segment数组和HashEntry数组组成的。Segment继承了可重入锁(ReentrantLock),它在ConcurrentHashMap里扮演锁的角色。HashEntry则用于存储键值对数据。
在每一个ConcurrentHashMap里都包含一个Segment数组,Segment的结构和HashMap类似,是数组和链表结构。在每个Segment里都包含一个HashEntry数组,每个HashEntry都是一个链表结构的数据,每个Segment都守护一个HashEntry数组里的元素,在对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。
在操作ConcurrentHashMap时,如果需要在其中添加一个新的数据,则并不是将整个HashMap加锁,而是先根据HashCode查询该数据应该被存放在哪个段,然后对该段加锁并完成put操作。在多线程环境下,如果多个线程同时进行put操作,则只要加入的数据被存放在不同的段中,在线程间就可以做到并行的线程安全。
Java中的线程调度
抢占式调度
抢占式调度指每个线程都以抢占的方式获取CPU资源并快速执行,在执行完毕后立刻释放CPU资源,具体哪些线程能抢占到CPU资源由操作系统控制,在抢占式调度模式下,每个线程对CPU资源的申请地位是相等,从概率上讲每个线程都有机会获得同样的CPU执行时间片并发执行。抢占式调度适用于多线程并发执行的情况,在这种机制下一个线程的堵塞不会导致整个进程性能下降。
协同式调度
协同式调度指某一个线程在执行完后主动通知操作系统将CPU资源切换到另一个线程上执行。线程对CPU的持有时间由线程自身控制,线程切换更加透明,更适合多个线程交替执行某些任务的情况。
协同式调度有一个缺点:如果其中一个线程因为外部原因(可能是磁盘I/O阻塞、网络I/O阻塞、请求数据库等待)运行阻塞,那么可能导致整个系统阻塞甚至崩溃。