本篇文章是用来记录我在阅读《码出高效:Java开发手册》这本书时所做的笔记,笔记内容为书中讲到的一些关键的知识点摘抄,包括我在学习和工作当中容易忽略的知识点和注意事项,摘抄下来主要是为了提炼自己还不太熟悉的知识点然后归纳在一块,便于再次学习,希望对大家也有帮助!有兴趣的同学可以阅读书本,学习更全面的书本内容。(本文章的章节是和书本上对应的,有些章节没有摘抄内容就直接跳过了)
第四章 走进JVM
4.1 字节码
字节码涉及到多个类型的指令,指令的作用就是程序在跑的时候命令CPU干活的,在后面会拿一段程序方法来分析底层指令的执行情况,在这里书本也是先列出来各种指令,在后面描述JVM的虚拟机栈的时候会详细讲解,大家在这要明白下面提到栈帧、局部变量表和操作栈都是和JVM虚拟机栈相关的内容。
1.加载或存储指令
在某个栈帧中,通过指令操作数据在虚拟机的局部变量表与操作栈之间来回传输,常见指令如下:
(1)将局部变量表的变量加载到操作栈中:如ILOAD(将int类型的局部变量压入操作栈)和ALOAD(将对象引用的局部变量压入栈)等。
(2)从操作栈顶将变量存储到局部变量表中:如ISTORE、ASTORE等
(3)将常量加载到操作栈顶,这是极为高频使用的指令。如ICONST、BIPUSH、SIPUSH、LDC等。
- ICONST 加载的是 -1 ~ 5 的数(ICONST 与BIPUSH 的加载界限)。
- BIPUSH ,即Byte Immediate PUSH ,加载 -128 ~ 127 之间的数。
- SIPUSH ,即Short Immediate PUSH ,加载 -32768 ~ 32767 之间的数。
- LDC ,即Load Constant ,在 -2147483648 ~ 2147483647 或者是字符串时,JVM采用LDC 指令压入枝中。
2.运算指令
对两个操作栈帧上的值进行运算,并把结果写入操作栈,如IADD、IMUL等
3.类型转换指令
显示转换两种不同的数值类型。如I2L、D2F等
4.对象创建与访问指令
根据对象的创建、初始化、方法调用相关指令,常见指令如下:
(1)创建指令:如NEW、NEWAPPAY等。
(2)访问属性指令:如GETF!ELD 、PUTFIELD 、GETSTATIC 等。
(3)核查实例类型指令:如INSTANCEOF、CHECKCAST 等。
5.操作栈管理指令
JVM提供了直接操控操作栈的指令,常见指令如下:
(1)出栈操作:如POP即一个元素,POP2即两个元素。
(2)复制栈顶元素并压入栈:DUP。
6.方法调用与返回指令
( I ) INVOKEYIRTUAL 指令:调用对象的实例方法。
( 2) INVOKESPECIAL 指令:调用实例初始化方法、私有方法、父类方法等。
( 3 ) INVOKESTATIC 指令: 调用类静态方法。
( 4 ) RETURN 指令: 返回VOID 类型。
7.同步指令
JVM使用方法结构中的ACC_SYNCHRONIZED标志同步方法,指令集中有MONITORENTER和MONITOREXIT支持synchronized语义。
4.2类加载过程
该节我就不详细记录了,大家可以看下深入Java虚拟机(1):Java类的生命周期和加载机制 。
4.3内存布局
JVM内存我也有相关的文章可以看,在这我抽些内容补充一下,先看内存结构图:
这里我就只补充虚拟机栈的内容,因为在深入Java虚拟机(2):JVM内存结构详解 这篇文章没有详细讲到。
JVM Stack(虚拟机栈)
栈(Stack)是一个先进后出的数据结构,就像子弹的弹夹,最后压入的子弹先发射,压在底部的子弹最后发射,撞针只能访问位于顶部的那一颗子弹。
相对于基于寄存器的运行环境来说,JVM是基于栈结构的运行环境。栈结构移植性更好,可控性更强。JVM中的虚拟机栈是描述Java方法执行的内存区域,他是线程私有的。栈中的元素用于支持虚拟机进行方法调用,每个方法从开始调用到执行完成,就是栈帧从入栈到出栈的过程。在活动线程中,只有位于栈顶的帧才是有效的,称为当前栈帧。正在执行的方法称为当前方法,栈帧是方法运行的基本结构。在执行引擎运行时,所有指令都是针对当前栈帧进行操作的。而StackOverflowError表示请求的栈溢出,导致内存耗尽,通常出现在递归方法中。JVM能够横扫千军,虚拟机栈就是它的心腹大将,当前方法的栈帧,都是正在战斗的战场,其中操作栈是参与战斗的士兵。操作栈的压栈与出栈如图4-11:
现在我们来分析下每个栈帧的内部结构,包括局部变量表、操作栈、动态链接、方法返回地址等。
(1)局部变量表
局部变量表是存放方法参数和局部变量的区域。相对于类属性变量的准备阶段和初始化阶段来说,局部变量表没有准备阶段,必须显示初始化。如果是非静态方法,则在index[0]的位置上存储的是方法所属对象的引用实例,随后存储的是参数和局部变量。前面提到的字节码STORE指令就是将操作栈中的计算完成的局部变量写回局部变量表的存储空间内。
(2)操作栈
操作栈是一个初始状态为空的桶式结构栈。在方法执行过程中,会有各种指令往栈中写入和提取信息。JVM的执行引擎是基于栈的执行引擎,其中的栈就是指的操作栈。下面用一段简单的代码说明操作栈与局部变量表的交互:
public int simpleMethod(){
int x = 13;
int y = 14;
int z = x + y;
return z;
}
详细的指令操作顺序如下:
好好体会代码最后转换成指令所执行的流程,咱们现在从指令的角度来看下常见的 i++ 和 ++i 的区别,注意其中iinc指令是直接对局部变量表中的数据进行+1操作 :
这里延伸一个信息,i++并非原子操作。即使通过volatile 关键字进行修饰, 多个线程同时写的话, 也会产生数据互相覆盖的问题。这个和我在Java多线程与并发(3):线程安全性详解 这篇文章讲解volatile时写了一个例子证明过。
(3)动态链接
每个栈帧中包含一个在常量池中对当前方法的引用,目的是支持方法调用过程的动态链接。
(4)方法返回地址
方法执行时有两种退出情况,第一, 正常退出,即正常执行到任何方法的返回字节码指令, 如阻RETURN 、IRETURN 、ARETURN 等;第二, 异常退出。无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧,退出可能有三种方式:
- 返回值压入上层调用栈帧。
- 异常信息抛给能够处理的栈帧。
- PC 计数器指向方法调用后的下一条指令。
第六章 数据结构与集合
6.2 集合框架图
Java集合框架结构如图6-1所示。框架图中主要分为两类:第一类是按照单个元素存储的Collection,在继承树中Set和List都实现了Collection接口;第二类是按照Key-Value存储的Map。下面简要描述出来的集合类,建议都去详细阅读源码
6.2.1 List集合
List 集合是线性数据结构的主要实现,集合元素通常存在明确的上一个和下一个元素,也存在明确的第一个元素和最后一个元素。List 集合的遍历结果是稳定的。该体系最常用的是ArrayList 和LinkedList 两个集合类。
(1)ArrayList
ArrayList 是容量可以改变的非线程安全集合。内部实现使用数组进行存储,集合扩容时会创建更大的数组空间,把原有数据复制到新数组中。ArrayList 支持对元素的快速随机访问,但是插入与删除时速度通常很慢,因为这个过程很有可能需要移动其他元素。
大家可以看下ArrayList源码,在add方法中,会先去看需不需要扩容,扩容之后还会涉及到旧数组拷贝到新数组当中,非常的耗时,所以大家在使用ArrayList的时候,最好先大概确认将要使用的大小值,初始化的时候就指定好实例后的初始容量,避免系统频繁进行扩容操作。
(2)LinkedList
LinkedList 的本质是双向链表。与ArrayList 相比, LinkedList 的插入和删除速度更快,但是随机访问速度则很慢。测试表明,对于10万条的数据,与ArrayList 相比,随机提取元素时存在数百倍的差距。除继承AbstractList 抽象类外, LinkedList 还实现了另一个接口Deque ,即double-ended queue。这个接口同时具有队列和栈的性质。LinkedList 包含3 个重要的成员size 、first、last。size 是双向链表中节点的个数。first和last 分别指向第一个和最后一个节点的引用。LinkedList 的优点在于可以将零散的内存单元通过附加引用的方式关联起来,形成按链路顺序查找的线性结构,内存利用率较高。
LinkedList双向链表的实现就是每个元素节点Node都保存了本节点前一个节点和下一个节点的引用,大家也可以看下源码。
6.2.2 Queue集合
Queue (队列)是一种先进先出的数据结构,队列是一种特殊的线性表,它只允许在表的端进行获取操作,在表的另端进行插入操作。当队列中没有元素时,称为空队列。自从BlockingQueue (阻塞队列)问世以来,队列的地位得到极大的提升,在各种高并发编程场景中,由于其本身FIFO 的特性和阻塞操作的特点,经常被作为Buffer (数据缓冲区)使用。其中使用最多的ArrayBlockingQueue和LinkedBlockingQueue两种,可以通过查看源码来理解怎么个阻塞,什么时候阻塞。
6.2.3 Map集合
Map 集合是以Key-Value 键值对作为存储元素实现的晗希结构, Key 按某种晗希函数计算后是唯一的, Value 则是可以重复的。Map 类提供三种Collection 视图,在集合框架图中, Map 指向Collection 的箭头仅表示两个类之间的依赖关系。可以使用
keySet()查看所有的Key,使用values()查看所有的Value ,使用entrySet()查看所有的键值对。最早用于存储键值对的Hashtable 因为性能瓶颈已经被淘汰,而如今广泛使用的HashMap , 线程是不安全的。ConcurrentHashMap 是线程安全的,在JDK8 中进行了锁的大幅度优化,体现出不错的性能。在多线程并发场景中,优先推荐使用ConcurrentHashMap ,而不是HashMap 。TreeMap 是Key 有序的Map 类集合。
HashMap的扩容的代价也是相当高的,所以在实例的时候最好也先设定合适的初始的容量大小,具体大家可以看源码。
6.2.4 Set集合
Set 是不允许出现重复元素的集合类型。Set 体系最常用的是HashSet 、TreeSet和LinkedHashSet 三个集合类。HashSet 从源码分析是使用HashMap 来实现的,只是Value 固定为一个静态对象,使用Key 保证集合元素的唯性,但它不保证集合元素的顺序。TreeSet 也是如此,从源码分析是使用TreeMap 来实现的,底层为树结构,在添加新元素到集合中时,按照某种比较规则将其插入合适的位置,保证插入后的集合仍然是有序的。LinkedHashSet 继承自Hash Set , 具有Hash Set 的优点,内部使用链表维护了元素插入顺序。
6.6 元素的比较
6.6.1 Comparable 和 Comparator
Java 中两个对象相比较的方法通常用在元素排序中, 常用的两个接口分别是Comparable 和Comparator ,前者是自己和自己比,可以看作是自营性质的比较器;后者是第三方比较器,可以看作是平台性质的比较器。从词根上分析, Comparable 以- able结尾, 表示它有自身具备某种能力的性质, 表明Comparable 对象本身是可以与同类型进行比较的, 它的比较方法是compareTo ,而Comparator 以- or 结尾3 表示自身是比较器的实践者, 它的比较方法是compare。
咱们上两个简单的例子来理解Comparable和Comparator:
/**
* Comparable的用法
* 声明一个类,该类是具备比较功能的类,实现Comparable接口
* 所以该类是具有比较功能的,但是它并不是专门的比较器
*/
public class ComparableClass implements Comparable<ComparableClass> {
// 声明两个成员变量
private int param1;
private long param2;
public ComparableClass(int param1, long param2){
this.param1 = param1;
this.param2 = param2;
}
/**
* 比较功能
*
* @param o
* @return
*/
@Override
public int compareTo(ComparableClass o) {
if(this.param1 != o.param1){
return this.param1 > o.param1 ? 1 : -1;
}
if(this.param2 != o.param2){
return this.param2 > o.param2 ? 1 : -1;
}
return 0;
}
}
/**
* Comparator的用法
*
* 声明ComparatorClass类,实现Comparator接口
* 该类相当于一个比较器,compara方法的入参是要传两个要比较的对象,实现比较大小
*/
public class ComparatorClass implements Comparator<TestClass> {
@Override
public int compare(TestClass o1, TestClass o2) {
if(o1.getParam1() != o2.getParam1()){
return o1.getParam1() > o2.getParam1() ? 1 : -1;
}
if(o1.getParam2() != o2.getParam2()){
return o1.getParam2() > o2.getParam2() ? 1 : -1;
}
return 0;
}
}
/**
* 用来比较的类
*/
public class TestClass {
// 声明两个成员变量
private int param1;
private long param2;
public int getParam1() {
return param1;
}
public void setParam1(int param1) {
this.param1 = param1;
}
public long getParam2() {
return param2;
}
public void setParam2(long param2) {
this.param2 = param2;
}
}
6.6.2 hashCode 和 equals
上面提到的Comparable和Comparator是指比较大小用的,那在java当中,判断两个对象是否相等则是用hashCode和equals。总所周知,根据生成的哈希将数据离散开来,可以使存取的元素更快。对象通过调用Object.hashCode生产哈希值;由于不可避免地会存在哈希值冲突的情况,因此hashCode相同时,要判断两个对象相等的话还需要再调用equals进行一次值的比较;但是,若hashCode不同,可以直接判断两个对象不同,不需要再去判断equals是否一样,这里有一层递进关系大家一定要明白。Object类定义中对hashCode和equals要求如下:
- 如果两个对象的equals的结果是相等的,则两个对象的hashCode的返回结果也必须是相同的。
- 任何时候覆写equals,都必须同时覆写hashCode。
在Map和Set类集合中,用到这两个方法时,首先判断hashCode的值,如果hash相等,则再判断equals的结果,HashMap的get判断代码大家可以看下:
第七章 并发与多线程
7.1 线程安全
线程安全这一小节主要会讲到线程的状态、线程安全问题需要考量的四个维度、并发包下主要几个类族。
(一)线程状态
线程是CPU 调度和分派的基本单位,为了更充分地利用CPU 资源,一般都会使用多线程进行处理。多线程的作用是提高任务的平均执行速度,但是会导致程序可理解性变差,编程难度加大。例如,楼下有一车砖头需要工人搬到21 楼,如果10 个人一起搬,速度定比l 个人搬要快,完成任务的总时间会极大减少。但是论单次的时间成本,由于楼梯交会等因素10 个人比1个人要慢。如果无限地增加入数,比如10000 人参与搬砖时,反而会因为楼道拥墙不堪变得更慢,所以合适的人数才会使工作效率最大化。同理,合适的线程数才能让CPU 资源被充分利用。如图7 - 1 所示,这是计算机的资源监视数据,红色箭头指向的PID 就是进程ID ,绿色箭头表示Java进程运行着30 个线程。
线程在生命周期内存在多种状态。如图7 -2 所示,有NEW (新建状态)、RUNNABLE (就绪状态)、RUNNING (运行状态)、BLOCKED (阻塞状态)、DEAD (终止状态)五种状态。
(1) New , 即新建状态,是线程被创建旦未启动的状态。创建线程的方式有三种:第一种是继承自Thread 类,第二种是实现Runnable 接口,第三种是实现Callable 接口。相比第一种,推荐第二种方式,因为继承自Thread 类往往不符合里氏代换原则,而实现Runnable 接口可以使编程更加灵活,对外暴露的细节比较少,让使用者专注于实现线程的run ()方法上。第三种Ca llable 接口的call() 声明如下,
/**
* Computes a result, or throws an exception if unable to do so.
*
* @return computed result
* @throws Exception if unable to compute a result
*/
V call() throws Exception;
由此可知, Callable 与Runnable 有两点不同:第一,可以通过call()获得返回值。前两种方式都有个共同的缺陷,即在任务执行完成后,无法直接获取执行结果, 需要借助共享变量等获取,而Callable 和Future 则很好地解决了这个问题; 第二, call()可以抛出异常。而Runnable 只有通过setDefaultUncaughtExceptionHandler() 的方式才能在主线程中捕捉到子线程异常。
( 2 ) RUNNABLE, 即就绪状态, 是调用start() 之后运行之前的状态。线程的start() 不能被多次调用,否则会抛出IllegalStateException 异常。
( 3 ) RUNNING , 即运行状态, 是run () 正在执行时线程的状态。线程可能会由于某些因素而退出RUNNING ,如时间、异常、锁、调度等。
( 4 ) BLOCKED ,即阻塞状态, 进入此状态, 有以下种情况。
- 同步阻塞:锁被其他线程占用。
- 主动阻塞:调用Thread 的某些方法,主动让出CPU 执行权,比如sleep() 、join() 等。
- 等待阻塞: 执行了wait()。
( 5 ) DEAD , 即终止状态,是run () 执行结束,或同异常退出后的状态, 此状态不可逆转。
(二)线程安全问题解决需要考量的四个维度
线程安全问题只是多线程环境下才出现,单线程串行执行不存在此问题。保证高并发场景下的线程安全,可以从四个维度考量:
- 数据单线程内可见。单线程总是安全的。通过限制数据仅在单线程内可见,可以避免数据被其他线程篡改。最典型的就是线程局部变量,它存储在独立虚拟机栈帧的局部变量中,与其他线程毫无瓜葛。ThreadLocal就是采用这种方式来实现线程安全的。
- 只读对象。只读对象总是安全的。它的特性是允许复制、拒绝写入。最典型的只读对象有String、Integer等。一个对象想要拒绝任何写入,必须满足以下条件:使用final关键字修饰类,避免被继承;使用private final关键字避免属性被中途修改;没有任何更新方法;返回值不能可变对象为引用。
- 线程安全类。某些线程安全类的内部有非常明确的线程安全机制。比如StringBuffer就是一个线程安全类,它采用synchronized关键字来修饰相关方法。
- 同步与锁机制。如果想要对某个对象进行并发更新操作,但是又不属于上述三类,需要开发工程师在代码中实现安全的同步机制。虽然这个机制支持的并发场景很有价值,但非常复杂并且容易出现问题。
(三)并发包主要以下几个类族:
- 线程同步类。这些类使线程间的协调更加容易,支持了更加丰富的线程协调场景,逐步淘汰了使用Object的wait()和notify()进行同步的方式。主要代表为CountDownLatch、Semaphore、CyclicBarrier等。
- 并发集合类。集合并发操作的要求是执行速度快,提取数据准。最著名的莫非ConcurrentHashMap莫属,它不断优化,由刚开始的锁分段到后来的CAS,不断地提升并发性能。其他还有ConcurrentSkipListMap、CopyOnWriteArrayList、BlockingQueue等。
- 线程管理类。虽然Thread和ThreadLocal在JDK1.0就已经引入,但是真正把Thread发扬光大的是线程池。根据实际场景的需要,提供了多种创建线程池的快捷方法,如使用Executors静态工厂或者使用ThreadPoolExecutor等。另外,通过ScheduledExecutorService来执行定时任务。
- 锁相关类。锁以Lock接口为核心,派生出在一些实际场景中进行互斥操作的锁相关类。最有名的是ReentrantLock。
7.2 什么是锁
从古代的门闩、铁锁到现代的密码锁、指纹锁、虹膜识别锁等,锁的便捷性和安全性在不断提升,对于私有财产或领地的保护也更加高效和健全。在计算机信息世界里,单机单线程时代没有锁的概念。自从出现了资源竞争,人们才意识到需要对部分场景的执行现场加锁,昭告天下,表明自己的“短暂”独有(其实对于任何有型或者无形的东西,拥有都不可能是永恒的)。计算机的锁也是从开始的悲观锁,发展到后来的乐观锁、偏向锁、分段锁等。
锁主要提供了两种特性:互斥性和不可见性。因为锁的存在,某些操作对外界来说是黑箱进行的,只有锁的持有者才知道对变量进行了什么修改。计算机锁分类很多,在Java中常用的锁实现的方式有两种。
(一)用并发包中的锁类
并发包的类族中,Lock是JUC包的顶层入口,它的实现逻辑并未用到synchronize,而是利用了volatile的可见性。先通过Lock来了解JUC包的一些基础类,如下图所示:
上图为Lock的继承类图,ReentrantLock对于Lock接口的实现主要依赖了Sync,而Sync继承了AbstractQueueSynchronizer(AQS),它是JUC包实现同步的基础工具,可以去看下ReentrantLock源码就一目了然了。在AQS中,定义了一个volatitle int state变量作为共享资源,如果线程获取资源失败,则进入同步FIFO队列中等待;如果成功获取资源就执行临界区代码。执行完释放资源时,会通知同步队列中的等待线程来获取资源后出队并执行。强烈建议去好好看看ReentrantLock和AQS的源码,在这里贴一个写的非常不错文章供大家学习:https://www.cnblogs.com/waterystone/p/4920797.html ,在这里就不详细讲解了。
(二)利用同步代码块
同步代码块一般使用Java的synchronized关键字来实现,有两种方式对方法进行加锁操作第,在方法签名处加synchronized 关键字;第二,使用synchronized (对象或类)进行同步。这里的原则是锁的范围尽可能小,锁的时间尽可能短,即能锁对象,就不要锁类,能锁代码块,就不要锁方法。
synchron i zed 锁特性由JVM 负责实现。在JDK 的不断优化迭代中, synchronized锁的性能得到极大提升,特别是偏向锁的实现,使得synchronized 已经不是昔日那个低性能且笨重的锁了。JVM底层是通过监视锁来实现synchronized 同步的。监视锁即monitor,是每个对象与生俱来的一个隐藏字段。使用synchronized 时,JVM会根据synchronized 的当前使用环境,找到对应对象的monitor ,再根据monitor 的状态进行加、解锁的判断。例如,线程在进入同步方法或代码块时,会获取该方法或代码块所属对象的monitor,进行加锁判断。如果成功加锁就成为该monitor 的唯一持有者。monitor 在被释放前,不能再被其他线程获取。下面通过字节码学习synchronized 锁是如何实现的:
方法元信息中会使用ACC_SYNCHRONIZED 标识该方法是一个同步方法。同步代码块中会使用monitorenter 及monitorexit 两个字节码指令获取和释放monitor。如果使用monitorenter进入时monitor为0,表示该线程可以持有monitor 后续代码,并将monitor加1,如果当前线程已经持有了monitor,那么monitor继续加l;如果monitor非0,其他线程就会进入阻塞状态。
JVM对synchronized 的优化主要在于对monitor 的加锁、解锁上。JDK6 后不断优化使得synchronized 提供三种锁的实现,包括偏向锁、轻量级锁、重量级锁, 还提供自动的升级和降级机制。JVM 就是利用CAS 在对象头上设置线程ID,表示这个对象偏向于当前线程, 这就是偏向锁。
偏向锁是为了在资源没有被多线程竞争的情况下尽量减少锁带来的性能开销。在锁对象的对象头中有一个ThreadId字段,当第一个线程访问锁时,如果该锁没有被其他线程访问过,即ThreadId字段为空,那么JVM 让其持有偏向锁,并将ThreadId字段的值设置为该线程的ID。当下一次获取锁时,会判断当前线程的ID 是否与锁对象的ThreadId一致。如果一致,那么该线程不会再重复获取锁,从而提高了程序的运行效率。如果出现锁的竞争情况,那么偏向锁会被撤销并升级为轻量级锁。如果资源的竞争非常激烈, 会升级为重量级锁。偏向锁可以降低无竞争开销,它不是互斥锁,不存在线程竞争的情况,省去再次同步判断的步骤,提升了性能。
7.3 线程同步
7.3.1 同步是什么
资源共享的两个原因是资源紧缺和共建需求。线程共享CPU是从资源紧缺的维度来考虑的,而多线程共享同一变量,通常是从共建需求的维度来考虑的。在多个线程对同一变量进行写操作时,如果操作没有原子性,就可能产生脏数据。所谓原子性是指不可分割的一系列操作指令,在执行完毕前不会被任何其他操作中断,要么全部执行,要么全部不执行。如果每个线程的修改都是原子操作,就不存在线程同步问题。有些看似非常简单的操作其实不具备原子性,典型的就是i++操作,它需要分为三步,即ILOAD → IINC → IS TORE。另方面,更加复杂的CAS ( Compare and Swap )操作却具有原子性。
线程同步现象在实际生活随处可见。比如乘客在火车站排队打车,每个人都是一个线程,管理员每次放10个人进来,为了保证安全,等全部离开后,再放下一批人进来。如果没有协调机制,场面一定是混乱不堪的,人们会窝蜂地上去抢车,存在严重的安全隐患。计算机的线程同步,就是线程之间接某种机制协调先后次序执行,当有个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作。实现线程同步的方式有很多,比如同步方法、锁、阻塞队列等。
7.3.2 volatile
volatile在这里就不详细讲了,本人文章Java多线程与并发(3):线程安全性详解 有详细介绍。
7.3.3 信号量同步
信号量同步是指在不同的线程之间,通过传递同步信号量来协调线程执行的先后次序。这里重点分析基于时间维度和信号维度的两个类:CountDownLatch、Semaphore。
我们现在来写一个CountDownLatch的例子,并且该例子会包含一个子线程抛异常了而父线程感知不到的问题:
public class CountDownLatchExample {
private final static int threadCount = 20;
private final static CountDownLatch countDownLatch = new CountDownLatch(threadCount);
public static void main(String[] args) throws Exception {
ExecutorService exec = Executors.newCachedThreadPool();
for(int i = 0; i < threadCount; i++) {
final int threadNum = i;
exec.execute(() -> {
test(threadNum);
});
}
System.out.println("=======================");
countDownLatch.await();
log.info("finish");
exec.shutdown();
}
private static void test(int threadNum) {
// 模拟某种异常情况下子线程抛异常(第1处)
if(Math.random() > 0.5) {
System.out.println("线程:" + threadNum + "触发异常");
throw new RuntimeException("线程:" + threadNum + "运行时异常");
}
log.info("{}", threadNum);
countDownLatch.countDown();
}
}
代码中第1处抛出异常,且该异常没有被主线程try-catch 到,最终该线程没有执行countDown方法,这样会导致主线程一直被await,无法继续向下执行,该问题难以定位,因为异常被吞得一干二净。扩展说明下,子线程异常可以通过线程方法setUncaughtExceptionHandler()捕获。
CountDownLatch是基于执行时间的同步类。在实际编码中,可能需要处理基于空闲信号的同步情况。比如海关安检的场景,任何国家公民在出国时,都要走海关的查验通道。假设某机场的海关通道共有3个窗口,一批需要出关的人排成长队,每个人都是一个线程。当3个窗口中的任意一个出现空闲时,工作人员指示队列中第一个人出队到该空闲窗口接受查验。对于上述场景,JDK中提供了个Semaphore的信号同步类,只有在调用Semaphore对象的acquire()成功后,才可以往下执行,完成后执行release()释放持有的信号量,下一个线程就可以马上获取这个空闲信号量进入执行。基于Semaphore的示例代码如下:
@Slf4j
public class SemaphoreExample5 {
private final static int threadCount = 10; // 10人需要过海关
public static void main(String[] args) throws Exception {
ExecutorService exec = Executors.newCachedThreadPool();
final Semaphore semaphore = new Semaphore(3); // 表示同时有3个海关检查口
for (int i = 0; i < threadCount; i++) {
final int threadNum = i;
exec.execute(() -> {
try {
semaphore.acquire(); // 获取一个许可
test(threadNum);
semaphore.release(); // 释放一个许可
} catch (Exception e) {
log.error("exception", e);
}
});
}
exec.shutdown();
}
private static void test(int threadNum) throws Exception {
log.info("No.{}乘客,正在检查中...", threadNum);
if(threadNum % 2 == 0){ // 假设该部分人不能过关
Thread.sleep(3000);
log.warn("No.{}乘客身份可疑,不允许过关!", threadNum);
}
Thread.sleep(3000);
}
}
还有其他同步方式,如CyclicBarrier是基于同步到达某个点的信号量触发机制。CyclicBarrier从命名上即可知道它是一个可以循环使用( Cyclic)的屏障式( Barrier)多线程协作方式。采用这种方式进行刚才的安检服务,就是3个人同时进去,只有3个人都完成安检,才会放下一批进来。这是一种非常低效的安检方式。但在某种场景下就是非常正确的方式,假设在机场排队打车时,现场工作人员统一指挥,每次放3辆车进来,坐满后开走,再放下一批车和人进来。通过CyclicBarrier的reset()来释放线程资源。
最后温馨提示,无论从性能还是安全性上考虑,我们尽量使用并发包中提供的信号同步类,避免使用对象的wait()和notify()方式来进行同步。
7.4 线程池
7.4.1 线程池的好处
线程的好处其实大家可以通过自己的了解然后大概描述出最重要的好处即可,没必要完全记背。下面是书中说到的内容:线程使应用能够更加充分合理地协调利用CPU 、内存、网络、1/0 等系统资源。线程的创建需要开辟虚拟机栈、本地方法栈、程序计数器等线程私有的内存空间。在线程销毁时需要回收这些系统资源。频繁地创建和销毁线程会浪费大量的系统资源,增加并发编程风险。另外,在服务器负载过大的时候,如何让新的线程等待或者友好地拒绝服务?这些都是线程自身无法解决的。所以需要通过线程池协调多个线程,并实现类似主次线程隔离、定时执行、周期执行等任务。线程池的作用包括:
- 利用线程池管理并复用线程、控制最大并发数等。
- 实现任务线程队列缓存策略和拒绝机制。
- 实现某些与时间相关的功能,如定时执行、周期执行等。
- 隔离线程环境。比如,交易服务和搜索服务在同一台服务器上,分别开启两个线程池,交易线程的资源消耗明显要大;因此,通过配置独立的线程池,将较慢的交易服务与搜索服务隔离开,避免各服务线程相互影响。
7.4.2 线程池源码详解
线程池源码详解在这里就不摘抄了,大家可以去看书本或者网上查阅资料,有很多相关的文章。
7.5 ThreadLocal
在这一节包含三小节内容,分别是引用类型、ThreadLocal价值和ThreadLocal副作用,我不会在详细去摘抄文章后两小节的内容,就主要摘抄引用类型小节,关于ThreadLocal的价值和副作用,可以看我另外一篇文章已经讲得非常详细啦:Java多线程与并发(4):安全发布对象和线程安全策略
7.5.1 引用类型
前面介绍了内存布局和垃圾回收,对象在堆上创建之后所持有的引用其实是一种变量类型,引用之间可以通过赋值构成一条引用链。从GC Roots开始遍历,判断引用是否可达。引用的可达性是判断能否被垃圾回收的基本条件。JVM会据此自动管理内存的分配与回收,不需要开发工程师干预。但在某些场景下,即使引用可达,也希望能够根据语义的强弱进行有选择的回收,以保证系统的正常运行。根据引用类型语义的强弱来决定垃圾回收的阶段,我们可以把引用分为强引用、软引用、弱引用和虚引用四类。后三类引用,本质上是可以让开发工程师通过代码方式来决定对象的垃圾回收时机。我们先简要了解一下这四类引用。
强引用,即Strong Reference,最为常见。如Object obect = new Obect();这样的变量声明和定义就会产生对该对象的强引用。只要对象有强引用指向,并且GC Roots可达,那么Java内存回收时,即使濒临内存耗尽,也不会回收该对象。
软引用,即Soft Reference,引用力度弱于"强引用",是用于在非必需对象的场景。在即将OOM之前,垃圾回收器会把这些软引用指向的对象加入回收范围,以获得更多的内存空间,让程序能够继续健康运行。主要用来缓存服务器中间计算结果及不需要实时保存的用户行为等。
弱引用,即Weak Reference,引用强度较前两者更弱,也是用来描述非必需对象的。如果弱引用指向的对象只存在弱引用这一条线路,则在下一次YGC 时会被回收。由于YGC 时间的不确定性,弱引用何时被回收也具有不确定性。弱引用主要用于指向某个易消失的对象,在强引用断开后,此引用不会劫持对象。调用WeakReference.get()可能返回null ,要注意空指针异常。
虚引用,即Phantom Reference,是极弱的一种引用关系,定义完成后,就无法通过该引用获取指向的对象。为一个对象设置虚引用的唯一目的就是希望能在这个对象被回收时收到一个系统通知。虚引用必须与引用队列联合使用,当垃圾回收时,如果发现存在虚引用,就会在回收对象内存前,把这个虚引用加入与之关联的引用队列中。
对象的引用类型如下图所示。
举个具体例子,在房产交易市场中,某个卖家有一套房子,成功出售给某个买家后引用置为null。这里有4个买家使用4种不同的引用关系指向这套房子。买家buyer1是强引用,如果把seller引用赋值给它,则永久有效,系统不会因为seller=null就触发对这套房子的回收,这是房屋交易市场最常见的交付方式。买家buyer2是软引用,只要不产生OOM,buyer2.get()就可以获取房子对象,就像房子是租来的一样。买家buyer3是弱引用,一旦过户后,seller置为null, buyer3的房子持有时间估计只有几秒钟,卖家只是给买家做了一张假的房产证,买家高兴了几秒钟后,发现房子已经不是自己的了。buyer4 是虚引用,定义完成后无法访问到房子对象,卖家只是虚构了房源, 是空手套白狼的诈骗术。
强引用是最常用的,而虚引用在业务中几乎很难用到。本节重点介绍一下软引用和弱引用。先来说明一下软引用的回收机制。首先设置JVM参数-Xms20m-Xmx20m,即只有20MB的堆内存空间。在下方的示例代码中不断地往集合里添加House对象, 而每个House 有2000 个Door 成员变量, 狭小的堆空间加上大对象的产生, 就是为了尽快触达内存耗尽的临界状态:
public class SoftReferenceHouse {
public static void main(String[] args) {
// List<House> hourses = new ArrayList<House>(); (第一处)
List<SoftReference> hourses = new ArrayList<SoftReference>();
// 剧情反转注释处
int i = 0;
while(true){
// hourses.add(new House()); (第二处)
// 剧情反转注释处
SoftReference<House> buyer2 = new SoftReference<House>(new House());
hourses.add(buyer2);
System.out.println("i=" + (++i));
}
}
}
class House{
private static final Integer DOOR_NUMBER = 2000;
private Door[] doors = new Door[DOOR_NUMBER];
class Door{}
}
new House()是匿名对象,产生之后即赋值给软引用。正常运行一段时间后,内存到达耗尽的临界状态,House$Door超过lOMB左右,内存占比达到80.4%,如图所示
软引用的特性在数秒之后产生价值,House对象数从干数量级迅速降到百数量级,内存容量迅速被释放出来,保证了程序的正常运行,如图所示
如果按照上面代码一直执行,是没有问题的,不会出现OOM,那如果剧情反转一下,把代码中的注释部分去除,把软引用的部分注释掉再运行则很快就会报出OOM异常。
现在我们来看下如果内存没有达到OOM,软引用持有的对象会不会在gc的时候被回收掉,用代码来验证一下:
public class SoftReferenceWhenIdle {
public static void main(String[] args) {
House house = new House();
SoftReference<House> buyer2 = new SoftReference<>(house);
house = null;
while(true){
// 下方两句代码是 建议JVM进行垃圾回收
System.gc();
System.runFinalization();
if(buyer2.get() == null){
System.out.println("house is null");
break;
}else{
System.out.println("house still here.");
}
}
}
}
注意System.gc()方法是建议垃圾收集器进行垃圾回收,具体何时执行任由JVM来判断。System.runFinalization()方式的作用是强制调用已经失去引用对象的finalize()。运行后可以看到一直输出house still here. 所以没有达到OOM时JVM进行了垃圾回收也不会回收调软引用指向的对象。
我们再来看下弱引用是否在每次gc的时候就会被回收掉,代码如下:
public class WeakReferenceWhenIdle {
public static void main(String[] args) {
House house = new House();
WeakReference<House> buyer2 = new WeakReference<>(house);
house = null;
while(true){
// 下方两句代码是 建议JVM进行垃圾回收
/*System.gc();
System.runFinalization();*/
if(buyer2.get() == null){
System.out.println("house is null");
break;
}else{
System.out.println("house still here.");
}
}
}
}
上面是注释掉了System.gc(),运行一段时间之后,只要发生了GC就会终止循环,可见弱引用被回收的时机节点就是在创建之后的下一次GC就会被回收。
到这里码出高效这本书的笔记摘抄就结束啦,书中的其他章节就没有笔记了,大家有兴趣可以去看书本的完整版!