Java 基础
语言特性
优点
① 平台无关,摆脱硬件束缚,"一次编写,到处运行"。
② 安全的内存管理和访问机制,避免大部分内存泄漏和指针越界。
③ 热点代码检测和运行时编译优化,程序随运行时长获得更高性能。
④ 完善的应用程序接口,支持第三方类库。
平台无关⭐
JVM: 编译器生成与计算机体系结构无关的字节码,字节码文件不仅能在任何机器解释执行,还能动态转换成本地机器码,转换由 JVM 实现。JVM 是平台相关的,屏蔽了不同操作系统的差异。
语言规范: 基本数据类型大小有明确规定,如 int 永远 32 位,而 C/C++ 可能是 16 位、32 位,或编译器开发商指定的其他大小。数值类型有固定字节数,字符串用标准 Unicode 格式。
JDK 和 JRE
JDK: Java Development Kit,开发工具包。提供了编译运行 Java 程序的各种工具,包括编译器、JRE 及常用类库,是 JAVA 核心。
JRE: Java Runtime Environment,运行时环境,运行 Java 程序的必要环境,包括 JVM、核心类库、核心配置工具。
值调用和引用调用
按值调用指方法接收调用者提供的值,按引用调用指方法接收调用者提供的变量地址。
Java 总是按值调用,方法得到的是参数的副本,传递对象时实际上传递的是对象引用的副本。
方法不能修改基本数据类型的参数,例如传递了一个 int 值 ,改变 int 值不会影响实参。
方法可以改变对象参数的状态,但不能让对象参数引用新的对象。例如传递了一个 int 数组,改变数组内容会影响实参,而改变其引用并不会让实参引用新的数组对象。
浅拷贝和深拷贝
浅拷贝只复制当前对象的基本数据类型及引用变量,没有复制引用变量指向的实际对象。修改克隆对象可能影响原对象。
深拷贝完全拷贝基本数据类型和引用数据类型,修改克隆对象不会影响原对象。
反射
在运行状态中,对于任意一个类都能知道它的所有属性和方法,对于任意一个对象都能调用它的任意方法和属性,这种动态获取信息及调用对象方法的功能称为反射,缺点是破坏了封装性及泛型约束。
Class 类
在程序运行期间,Java 运行时系统为所有对象维护一个运行时类型标识,这个信息会跟踪每个对象所属的类,虚拟机利用运行时类型信息选择要执行的正确方法,保存这些信息的类就是 Class,这是一个泛型类。
获取 Class 对象:① 类名.class
。② 对象的 getClass
方法。③ Class.forName(类的全限定名)
。
注解⭐
注解是一种标记,使类或接口附加额外信息,帮助编译器和 JVM 完成一些特定功能,例如 @Override
标识一个方法是重写方法。
元注解是自定义注解的注解,例如:
@Target
:约束作用位置,值是 ElementType 枚举常量,包括 METHOD 方法、VARIABLE 变量、TYPE 类/接口、PARAMETER 方法参数、CONSTRUCTORS 构造方法和 LOACL_VARIABLE 局部变量等。
@Rentention
:约束生命周期,值是 RetentionPolicy 枚举常量,包括 SOURCE 源码、CLASS 字节码和 RUNTIME 运行时。
@Documented
:表明注解应该被 javadoc 记录。
泛型
泛型本质是参数化类型,解决不确定对象具体类型的问题。
泛型的好处:① 类型安全,不存在 ClassCastException。② 提升可读性,编码阶段就显式知道泛型集合、泛型方法等处理的数据类型。
泛型用于编译阶段,编译后的字节码文件不包含泛型类型信息,因为虚拟机没有泛型类型对象,所有对象都属于普通类。例如定义 List<Object>
或 List<String>
,在编译后都会变成 List
。
JDK8 新特性
lambda 表达式:允许把函数作为参数传递到方法,简化匿名内部类代码。
函数式接口:使用 @FunctionalInterface
标识,有且仅有一个抽象方法,可被隐式转换为 lambda 表达式。
方法引用:可以引用已有类或对象的方法和构造方法,进一步简化 lambda 表达式。
接口:接口可以定义 default
修饰的默认方法,降低了接口升级的复杂性,还可以定义静态方法。
注解:引入重复注解机制,相同注解在同地方可以声明多次。注解作用范围也进行了扩展,可作用于局部变量、泛型、方法异常等。
类型推测:加强了类型推测机制,使代码更加简洁。
Optional 类:处理空指针异常,提高代码可读性。
Stream 类:引入函数式编程风格,提供了很多功能,使代码更加简洁。方法包括 forEach
遍历、count
统计个数、filter
按条件过滤、limit
取前 n 个元素、skip
跳过前 n 个元素、map
映射加工、concat
合并 stream 流等。
日期:增强了日期和时间 API,新的 java.time 包主要包含了处理日期、时间、日期/时间、时区、时刻和时钟等操作。
JavaScript:提供了一个新的 JavaScript 引擎,允许在 JVM上运行特定 JavaScript 应用。
异常🌙
所有异常都是 Throwable 的子类,分为 Error 和 Exception。
Error 是 Java 运行时系统的内部错误和资源耗尽错误,例如 StackOverFlowError 和 OutOfMemoryError,这种异常程序无法处理。
Exception 分为受检异常和非受检异常,受检异常要显式处理,否则编译出错,非受检异常是运行时异常,继承 RuntimeException。
受检异常:① 无能为力型,如字段超长导致的 SQLException。② 力所能及型,如未授权异常 UnAuthorizedException,程序可跳转权限申请页面。常见受检异常还有 FileNotFoundException、ClassNotFoundException、IOException等。
非受检异常:① 可预测异常,例如 IndexOutOfBoundsException、NullPointerException、ClassCastException 等,这类异常应该提前处理。② 需捕捉异常,例如进行 RPC 调用时的远程服务超时,这类异常客户端必须显式处理。③ 可透出异常,指框架或系统产生的且会自行处理的异常,例如 Spring 的 NoSuchRequestHandingMethodException,Spring 会自动将异常自动映射到合适的状态码。
数据类型
基本数据类型
数据类型 | 内存大小 | 默认值 | 取值范围 |
---|---|---|---|
byte | 1 B | (byte)0 | -128 ~ 127 |
short | 2 B | (short)0 | -215 ~ 215-1 |
int | 4 B | 0 | -231 ~ 231-1 |
long | 8 B | 0L | -263 ~ 263-1 |
float | 4 B | 0.0F | ±3.4E+38(有效位数 6~7 位) |
double | 8 B | 0.0D | ±1.7E+308(有效位数 15 位) |
char | 英文 1B,中文 UTF-8 占 3B,GBK 占 2B。 | '\u0000' | '\u0000' ~ '\uFFFF' |
boolean | 单个变量 4B / 数组 1B | false | true、false |
JVM 没有 boolean 的字节码指令,单个 boolean 变量用 int 代替,boolean f = false
就是用 ICONST_0 即常数 0 赋值。boolean 数组会编码成 byte 数组。
自动装箱是将基本数据类型包装为一个包装类对象,例如向一个泛型为 Integer 的集合添加 int 元素;自动拆箱是将一个包装类对象转换为基本数据类型,例如将一个 Integer 对象赋值给一个 int 变量。比较两个包装类数值要用 equals
。
String⭐
String 类和其存储数据的 value 字节数组都是 final 修饰的。对 String 对象的任何修改实际都是创建新对象再引用,并没有修改原对象。
字符串拼接方式
① 直接用 +
,底层用 StringBuilder 实现。只适用小数量,如果在循环中使用 +
拼接,相当于不断创建新的 StringBuilder 对象再转换成 String 对象,效率极差。
② 使用 String 的 concat
方法,该方法使用 Arrays.copyOf
创建一个新的字符数组 buf 并将当前字符串 value 数组的值拷贝到 buf,之后调用 getChars
方法用 System.arraycopy
将拼接字符串的值也拷贝到 buf,最后用 buf 作为构造参数 new 一个新的 String 对象返回。效率稍高于直接使用 +
。
③ 使用 StringBuilder 或 StringBuffer,两者的 append
方法都继承自 AbstractStringBuilder,该方法首先使用 Arrays.copyOf
确定新的字符数组容量,再调用 getChars
方法用 System.arraycopy
将新的值追加到数组。StringBuilder 是 JDK5 引入的,效率高但线程不安全,StringBuffer 使用 synchronized 保证线程安全。
面向对象
面向对象
面向过程是过程化思维,代码松散,强调流程化,开发时软件维护困难,耦合严重;面向对象更适合解决大规模问题,强调高内聚、低耦合,先抽象模型定义共性行为,再解决问题。
封装是对象功能内聚的表现形式,在抽象基础上决定信息是否公开及公开等级。主要任务是对属性、数据、敏感行为实现隐藏,使对象关系变得简单,降低耦合。
继承用来扩展类,子类可继承父类的部分属性和行为,使模块具有复用性。
多态以封装和继承为基础,根据运行时对象实际类型使同一行为具有不同表现形式。多态指在编译层面无法确定最终调用的方法体,在运行期由 JVM 动态绑定,调用合适的重写方法。由于重载属于静态绑定,本质上重载结果是完全不同的方法,因此多态一般专指重写。
重载和重写
重载指方法名称相同,但参数列表不同,是行为水平方向不同实现。对编译器来说,方法名称和参数列表组成了一个唯一键,称为方法签名,JVM 通过方法签名决定调用哪种重载方法。不管继承关系多复杂,重载在编译时可以确定调用哪个方法,因此属于静态绑定。重载顺序:① 精确匹配。② 基本数据类型自动转换成更大表示范围。③ 自动拆箱与装箱。④ 子类向上转型。⑤ 可变参数。
重写指子类实现接口或继承父类时,保持方法签名完全相同,实现不同方法体,是行为垂直方向不同实现。元空间有一个方法表保存方法信息,如果子类重写父类的方法,方法表中的方法引用会指向子类。重写方法访问权限不能变小,返回类型和抛出的异常类型不能变大。
Object 类⭐
方法 | 说明 |
---|---|
equals | 检测对象是否相等,默认使用 == 比较,可以重写该方法自定义规则。规范:自反性、对称性、传递性、一致性、对于任何非空引用 x,x.equals(null) 返回 false。 |
hashCode | 每个对象都有默认散列码,值由对象存储地址得出。字符串散列码由内容导出,值可能相同。为了在集合中正确使用,一般需要同时重写 equals 和 hashCode,要求 equals 相同 hashCode 必须相同,hashCode 相同 equals 未必相同。 |
toString | 默认打印表示对象值的一个字符串。 |
clone | 默认声明为 protected,只能由本类对象调用,且是浅拷贝。一般重写 clone 方法需要实现 Cloneable 接口并声明为 public,如果没有实现 Cloneable 接口会抛出 CloneNotSupport 异常。 |
finalize | GC 判断垃圾时,如果对象没有与 GC Roots 相连会被第一次标记,之后判断对象是否有必要执行 finalize 方法,有必要则由一条低调度优先级的 Finalizer 线程执行。虚拟机会触发该方法但不保证结束,防止方法执行缓慢或发生死循环。只要对象在 finalize 方法中重新与引用链相连,就会在第二次标记时移出回收集合。由于运行代价高且具有不确定性,在 JDK9 标记为过时方法。 |
getClass | 返回对象所属类的 Class 对象。 |
wait | 阻塞持有该对象锁的线程。 |
notify | 唤醒持有该对象锁的线程,notify 随机唤醒一个线程,notifyAll 唤醒全部线程。 |
内部类
内部类可对同一包中其他类隐藏,内部类方法可以访问定义这个内部类的作用域中的数据,包括 private 数据。
内部类是一个编译器现象,与虚拟机无关。编译器会把内部类转换成常规的类文件,用 $ 分隔外部类名与内部类名,其中匿名内部类使用数字编号,虚拟机对此一无所知。
静态内部类: 属于外部类,只加载一次。作用域仅在包内,可通过 外部类名.内部类名
直接访问,只能访问外部类所有静态属性和方法。HashMap 的 Node 节点,ReentrantLock 中的 Sync 类都是静态内部类。
成员内部类: 属于外部类的每个对象,随对象一起加载。不可以定义静态成员和方法,可访问外部类的所有内容。
局部内部类: 定义在方法内,不能声明访问修饰符,只能定义实例成员变量和实例方法,作用范围仅在声明类的代码块中。
匿名内部类: 只用一次的没有名字的类,可以简化代码,创建的对象类型相当于 new 的类的子类类型。用于实现事件监听和其他回调。
访问权限控制符
访问权限控制符 | 本类 | 包内 | 包外子类 | 任何地方 |
---|---|---|---|---|
public | √ | √ | √ | √ |
protected | √ | √ | √ | × |
无 | √ | √ | × | × |
private | √ | × | × | × |
接口和抽象类
接口和抽象类对实体类进行更高层次的抽象,仅定义公共行为和特征。
语法维度 | 抽象类 | 接口 |
---|---|---|
成员变量 | 无特殊要求 | 默认 public static final 常量 |
构造方法 | 有构造方法,不能实例化 | 没有构造方法,不能实例化 |
方法 | 抽象类可以没有抽象方法 | 默认 public abstract,JDK8 支持默认/静态方法,JDK9 支持私有方法。 |
继承 | 单继承 | 多继承 |
抽象类是 is-a 关系,接口是 can-do 关系。与接口相比,抽象类通常是对同类事物相对具体的抽象。
抽象类是模板式设计,包含一组具体特征,例如汽车的底盘、控制电路等是抽象出来的共同特征,但内饰、显示屏、座椅可以根据不同级别配置存在不同实现。
接口是契约式设计,是开放的,定义了方法名、参数、返回值、抛出的异常类型,谁都可以实现它,但必须遵守约定。例如所有车辆都必须实现刹车这种强制规范。
接口是顶级类,抽象类在接口下面的第二层,对接口进行组合,然后实现部分接口。当纠结定义接口和抽象类时,推荐定义为接口,遵循接口隔离原则,按维度划分成多个接口,再利用抽象类去实现,方便扩展和重构。
集合
ArrayList⭐
ArrayList 是容量可变列表,使用数组实现,扩容时会创建更大的数组,把原有数组复制到新数组。支持对元素的随机访问,但插入与删除速度慢。ArrayList 实现了 RandomAcess 接口,如果类实现了该接口,使用索引遍历比迭代器更快。
elementData 是 ArrayList 的数据域,被 transient 修饰,序列化时调用 writeObject
写入流,反序列化时调用 readObject
重新赋值到新对象的 elementData。原因是 elementData 容量通常大于实际存储元素的数量,所以只需发送真正有值的元素。
size 是当前实际大小,小于等于 elementData 的大小。
modCount 记录了 ArrayList 结构性变化的次数,继承自 AbstractList。expectedModCount 是迭代器初始化时记录的 modCount 值,每次访问新元素时都会检查 modCount 是否等于 expectedModCount,不等将抛出异常。这种机制叫 fail-fast,所有集合类都有。
LinkedList⭐
LinkedList 本质是双向链表,与 ArrayList 相比增删速度更快,但随机访问慢。除继承 AbstractList 外还实现了 Deque 接口,该接口具有队列和栈的性质。成员变量被 transient 修饰,原理和 ArrayList 类似。
包含三个重要的成员:size、first 和 last。size 是双向链表中节点的个数,first 和 last 分别指向首尾节点。
优点:可以将零散的内存单元通过附加引用的方式关联起来,形成按链路顺序查找的线性结构,内存利用率高。
Set
Set 元素不重复且无序,常用实现有 HashSet、LinkedHashSet 和 TreeSet。
HashSet 通过 HashMap 实现,HashMap 的 Key 即 HashSet 存储的元素,所有 Key 都使用相同的 Value ,一个 Object 类型常量。使用 Key 保证元素唯一性,但不保证有序性。HashSet 判断元素是否相同时,对于包装类型直接按值比较,对于引用类型先比较 hashCode,不同则代表不是同一个对象,相同则比较 equals,都相同才是同一个对象。
LinkedHashSet 继承自 HashSet,通过 LinkedHashMap 实现,使用双向链表维护元素插入顺序。
TreeSet 通过 TreeMap 实现的,添加元素到集合时按照比较规则将其插入合适的位置,保证插入后的集合仍然有序。
TreeMap⭐
TreeMap 基于红黑树实现,增删改查的平均和最差时间复杂度均为 O(logn) ,最大特点是 Key 有序。Key 必须实现 Comparable 接口或 Comparator 接口,所以 Key 不允许为 null。
TreeMap 依靠 Comparable 或 Comparator 排序,如果实现了 Comparator 就会优先使用 compare
方法,否则使用 Comparable 的 compareTo
方法,两者都不满足会抛出异常。
TreeMap 通过 put
和 deleteEntry
实现增加和删除树节点。插入新节点的规则有三个:① 需要调整的新节点总是红色的。② 如果插入新节点的父节点是黑色的,不需要调整。③ 如果插入新节点的父节点是红色的,由于红黑树不能出现相邻红色,进入循环判断,通过重新着色或左右旋转来调整。
HashMap ⭐
JDK8 前底层使用数组加链表,JDK8 改为数组加链表/红黑树,节点从 Entry 变为 Node。主要成员变量包括 table 数组、元素数量 size、加载因子 loadFactor。
table 数组记录 HashMap 的数据,每个下标对应一条链表,所有哈希冲突的数据都会被存放到同一条链表,Node/Entry 节点包含四个成员变量:key、value、next 和 hash。
数据以键值对的形式存在,键对应的 hash 值用来计算数组下标,如果两个元素 key 的 hash 值一样,就会发生哈希冲突,被放到同一个链表上,为使查询效率尽可能高,键的 hash 值要尽可能分散。
默认初始化容量为 16,扩容容量必须是 2 的幂次方、最大容量为 1<< 30 、默认加载因子为 0.75。
JDK8 之前
hash:计算元素 key 的散列值
① 处理 String 类型时,调用 stringHash32
方法获取 hash 值。
② 处理其他类型数据时,提供一个随机值 hashSeed 作为计算初始量,执行异或和无符号右移使 hash 值更加离散。
indexFor:计算元素下标
将 hash 值和数组长度-1 进行与操作,保证结果不超过 table 范围。
get:获取元素的 value 值
key 为 null,调用 getForNullKey
方法:
- size=0 表示链表为空,返回 null。
- size!=0 说明存在链表,遍历 table[0] 链表,如果找到了 key=null 的节点则返回其 value,否则返回 null。
key 不为 null,调用 getEntry
方法:
- size=0 表示链表为空,返回 null 值。
- size!=0,首先计算 key 的 hash 值,然后遍历该链表的所有节点,如果节点的 key 和 hash 值都和要查找的元素相同则返回其 Entry 节点。 如果找到了对应的 Entry 节点,调用
getValue
方法获取其 value 并返回,否则返回 null。
put:添加元素
key 为 null,直接存入 table[0]。
key 不为 null,计算 key 的 hash 值,调用 indexFor
计算元素下标 i,遍历 table[i] 链表:
- key 已存在,更新 value 然后返回旧 value。
- key 不存在,将 modCount 加 1,调用
addEntry
方法增加一个节点并返回 null。
resize:扩容数组
当前容量达到了最大容量,将阈值设置为 Integer 最大值,之后扩容不再触发。
当前容量没达到最大容量,计算新的容量,将阈值设为 newCapacity x loadFactor
和 最大容量 + 1
的较小值。创建一个容量为 newCapacity 的 Entry 数组,调用 transfer
方法将旧数组的元素转移到新数组。
transfer:转移元素
遍历旧数组的所有元素,调用 rehash
方法判断是否需要哈希重构,如果需要就重新计算元素 key 的 hash 值。
调用 indexFor
方法计算元素存放的下标 i,利用头插法将旧数组的元素转移到新数组。
JDK8
hash:计算元素 key 的散列值
如果 key 为 null 返回 0,否则就将 key 的 hashCode
方法返回值高低16位异或,让尽可能多的位参与运算,让结果的 0 和 1 分布更加均匀,降低哈希冲突概率。
put:添加元素
调用 putVal
方法添加元素:
- 如果 table 为空或不存在元素就进行扩容,否则计算元素下标位置,不存在就调用
newNode
创建一个节点。 - 如果存在元素且是链表类型,如果首节点和待插入元素相同,直接更新节点 value。
- 如果首节点是 TreeNode 类型,调用
putTreeVal
方法增加一个树节点,每一次都比较插入节点和当前节点的大小,待插入节点小就往左子树查找,否则往右子树查找,找到空位后执行两个方法:balanceInsert
方法,插入节点并调整平衡、moveRootToFront
方法,由于调整平衡后根节点可能变化,需要重置根节点。 - 如果都不满足,遍历链表,根据 hash 和 key 判断是否重复,决定更新 value 还是新增节点。如果遍历到了链表末尾则添加节点,如果达到建树阈值 7,还需要调用
treeifyBin
把链表重构为红黑树。 - 存放元素后将 modCount 加 1,如果
++size > threshold
,调用resize
扩容。
get :获取元素的 value 值
调用 getNode
方法获取 Node 节点:
如果数组不为空且存在元素,先比较第一个节点和要查找元素,如果相同则直接返回。
如果第二个节点是 TreeNode 类型则调用
getTreeNode
方法进行查找。都不满足,遍历链表根据 hash 和 key 查找,如果没有找到就返回 null。
如果节点不是 null 就返回其 value,否则返回 null。
resize:扩容数组
重新规划长度和阈值,如果长度发生了变化,部分数据节点也要重新排列。
重新规划长度
① 如果当前容量 oldCap > 0
且达到最大容量,将阈值设为 Integer 最大值,终止扩容。
② 如果未达到最大容量,当 oldCap << 1
不超过最大容量就扩大为 2 倍。
③ 如果都不满足且当前扩容阈值 oldThr > 0
,使用当前扩容阈值作为新容量。
④ 否则将新容量置为默认初始容量 16,新扩容阈值置为 12。
重新排列数据节点
① 如果节点为 null 不进行处理。
② 如果节点不为 null 且没有 next 节点,通过节点的 hash 值和 新容量-1
进行与运算计算下标存入新的 table 数组。
③ 如果节点为 TreeNode 类型,调用 split
方法处理,如果节点数 hc 达到 6 会调用 untreeify
方法转回链表。
④ 如果是链表节点,需要将链表拆分为 hash 值超出旧容量的链表和未超出容量的链表。对于hash & oldCap == 0
的部分不需要做处理,否则需要放到新的下标位置上,新下标 = 旧下标 + 旧容量。
线程不安全
JDK7 存在死循环和数据丢失问题。
数据丢失:
并发赋值被覆盖: 在
createEntry
方法中,新添加的元素放在头部,使元素可以被更快访问,但如果两个线程同时执行到此处,会导致数据覆盖。新表被覆盖: 如果多线程同时
resize
,每个线程都会 new 一个数组,这是线程内的局部对象,线程间不可见。迁移完成后resize
的线程会赋值给 table 线程共享变量,可能会覆盖其他线程的操作,在新表中插入的对象都会被丢弃。
死循环: 扩容时 resize
调用 transfer
使用头插法迁移元素,虽然 newTable 是局部变量,但原先 table 中的 Entry 链表是共享的,问题根源是 Entry 的 next 指针并发修改,某线程还没有将 table 设为 newTable 时用完了 CPU 时间片。
JDK8 在 resize
方法中完成扩容,并改用尾插法,不会产生死循环,但并发下仍可能丢失数据。可用 ConcurrentHashMap 或 Collections.synchronizedMap
包装同步集合。
IO 流
BIO
BIO 是同步阻塞式 IO,JDK1.4 前的 IO 模型。服务器实现模式为一个连接请求对应一个线程,服务器需要为每一个客户端请求创建一个线程,如果这个连接不做任何事会造成不必要开销。可以通过线程池改善,称为伪异步 IO。适用连接数目少且服务器资源多的场景。
NIO
NIO 是 JDK1.4 引入的同步非阻塞 IO。服务器实现模式为多个连接请求对应一个线程,客户端连接请求会注册到一个多路复用器 Selector ,Selector 轮询到连接有 IO 请求时才启动一个线程处理。适用连接数目多且连接时间短的场景。
同步是指线程还是要不断接收客户端连接并处理数据,非阻塞是指如果一个管道没有数据,不需要等待,可以轮询下一个管道。
核心组件:
Selector: 多路复用器,轮询检查多个 Channel 的状态,判断注册事件是否发生,即判断 Channel 是否处于可读或可写状态。使用前需要将 Channel 注册到 Selector,注册后会得到一个 SelectionKey,通过 SelectionKey 获取 Channel 和 Selector 相关信息。
Channel: 双向通道,替换了 BIO 中的 Stream 流,不能直接访问数据,要通过 Buffer 来读写数据,也可以和其他 Channel 交互。
-
Buffer: 缓冲区,是一块可读写数据的内存。Buffer 三个重要属性:position 下次读写数据的位置,limit 本次读写的极限位置,capacity 最大容量。
-
flip
将写转为读,底层实现原理把 position 置 0,并把 limit 设为当前的 position 值。 -
clear
将读转为写模式(用于读完全部数据的情况,把 position 置 0,limit 设为 capacity)。 -
compact
将读转为写模式(用于存在未读数据的情况,让 position 指向未读数据的下一个)。 - 通道方向和 Buffer 方向相反,读数据相当于向 Buffer 写,写数据相当于从 Buffer 读。
使用步骤:向 Buffer 写数据,调用 flip 方法转为读模式,从 Buffer 中读数据,调用 clear 或 compact 方法清空缓冲区。
-
AIO
AIO 是 JDK7 引入的异步非阻塞 IO。服务器实现模式为一个有效请求对应一个线程,客户端的 IO 请求都是由操作系统先完成 IO 操作后再通知服务器应用来直接使用准备好的数据。适用连接数目多且连接时间长的场景。
异步是指服务端线程接收到客户端管道后就交给底层处理IO通信,自己可以做其他事情,非阻塞是指客户端有数据才会处理,处理好再通知服务器。
实现方式包括通过 Future 的 get
方法进行阻塞式调用以及实现 CompletionHandler 接口,重写请求成功的回调方法 completed
和请求失败回调方法 failed
。
java.io
主要分为字符流和字节流,字符流一般用于文本文件,字节流一般用于图像或其他文件。
字符流包括了字符输入流 Reader 和字符输出流 Writer,字节流包括了字节输入流 InputStream 和字节输出流 OutputStream。字符流和字节流都有对应的缓冲流,字节流也可以包装为字符流,缓冲流带有一个 8KB 的缓冲数组,可以提高流的读写效率。除了缓冲流外还有过滤流 FilterReader、字符数组流 CharArrayReader、字节数组流 ByteArrayInputStream、文件流 FileInputStream 等。
序列化
Java 对象在 JVM 退出时会全部销毁,如果需要将对象持久化就要通过序列化实现,将内存中的对象保存在二进制流中,需要时再将二进制流反序列化为对象。对象序列化保存的是对象的状态,因此属于类属性的静态变量不会被序列化。
常见的序列化有三种:
-
Java 原生序列化
实现
Serializabale
标记接口,兼容性最好,但不支持跨语言,性能一般。序列化和反序列化必须保持序列化 ID 的一致,一般使用
private static final long serialVersionUID
定义序列化 ID,如果不设置编译器会根据类的内部实现自动生成该值。 -
Hessian 序列化
支持动态类型、跨语言,对象序列化的二进制流可以被其它语言反序列化。特性:① 自描述序列化类型,不依赖外部描述文件。② 语言无关,支持脚本语言。③ 协议简单,比 Java 原生序列化高效。
-
JSON 序列化
JSON 序列化就是将数据对象转换为 JSON 字符串,在序列化过程中抛弃了类型信息,反序列化时只有提供类型信息才能准确进行。相比前两种方式可读性更好,方便调试。
序列化通常使用网络传输对象,容易遭受攻击,Jackson 和 fastjson 都出现过反序列化漏洞,因此不需要进行序列化的敏感属性应加上 transient 关键字。transient 的作用是把变量生命周期仅限于内存,不会写到磁盘,变量会被设为对应数据类型的零值。
JVM
内存区域划分 ⭐
程序计数器
程序计数器是一块较小的内存空间,可以看作当前线程执行字节码的行号指示器,是唯一没有内存溢出的区域。字节码解释器工作时通过改变计数器的值选取下一条执行指令。分支、循环、跳转、线程恢复等功能都需要依赖计数器。
如果线程正在执行 Java 方法,计数器记录正在执行的虚拟机字节码指令地址。如果是本地方法,计数器值为 Undefined。
Java 虚拟机栈
Java 虚拟机栈描述 Java 方法的内存模型。当有新线程创建时会分配一个栈空间,栈中元素用于支持虚拟机进行方法调用,每个方法在执行时都会创建一个栈帧存储方法的局部变量表、操作栈和方法出口等信息。每个方法从调用到执行完成,就是栈帧从入栈到出栈的过程。
线程请求的栈深度大于虚拟机允许的深度抛出 StackOverflowError;如果 JVM 栈允许动态扩展,栈扩展无法申请足够内存抛出 OutOfMemoryError(HotSpot 不可动态扩展)。
本地方法栈
本地方法栈与虚拟机栈作用相似,不同的是虚拟机栈为 Java 方法服务,本地方法栈为本地方法服务。虚拟机规范对本地方法栈中方法的语言与数据结构无强制规定,例如 HotSpot 将虚拟机栈和本地方法栈合二为一。
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError,例如一个递归方法不断调用自己。该异常有明确错误堆栈可供分析,容易定位问题。
如果 JVM 栈可以动态扩展,当扩展无法申请到足够内存时会抛出 OutOfMemoryError。HotSpot 不支持虚拟机栈扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现 OOM,否则在线程运行时是不会因为扩展而导致溢出的。
堆
堆是虚拟机管理的内存中最大的一块,被所有线程共享,在虚拟机启动时创建。堆用来存放对象实例,Java 里几乎所有对象都在堆分配内存。堆可以处于物理上不连续的内存空间,但对于数组这样的大对象,多数虚拟机出于简单高效的考虑会要求连续的内存空间。
堆既可以被实现成固定大小,也可以是可扩展的,通过 -Xms
和 -Xmx
设置堆的最小和最大容量,主流 JVM 都按照可扩展实现。
堆用于存储对象实例,只要不断创建对象并保证 GC Roots 可达避免垃圾回收,当总容量触及最大堆容量后就会产生 OOM,例如在 while 死循环中一直 new 创建实例。
处理方法:通过内存映像分析工具对 Dump 出的堆转储快照分析,确认导致 OOM 的对象是否必要,分清是内存泄漏还是内存溢出。
- 如果是内存泄漏,通过工具查看泄漏对象到 GC Roots 的引用链,找到泄露对象是通过怎样的引用路径、与哪些 GC Roots 关联才导致无法回收,一般可以准确定位到产生内存泄漏代码的具体位置。
- 如果不是内存泄漏,即内存中对象都必须存活,应当检查 JVM 堆参数与内存相比是否还有调整空间。再从代码检查是否存在某些对象生命周期过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。
方法区
方法区用于存储被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
JDK8 前使用永久代实现方法区,容易内存溢出,因为永久代有 -XX:MaxPermSize
上限,即使不设置也有默认大小。JDK7 把放在永久代的字符串常量池、静态变量等移出,JDK8 时永久代完全废弃,改用在本地内存中实现的元空间代替,把 JDK7 中永久代剩余内容(主要是类型信息)全部移到元空间。
虚拟机规范对方法区的约束宽松,除和堆一样不需要连续内存、可选择固定大小、可扩展外,还可以不实现垃圾回收。垃圾回收在方法区出现较少,主要针对常量池和类型卸载。
方法区主要存放类型信息,只要不断在运行时产生大量类,方法区就会溢出。例如使用反射或 CGLib 在运行时生成大量的类。
JDK8 使用元空间取代永久代,HotSpot 提供了一些参数作为元空间防御措施,例如 -XX:MetaspaceSize
指定元空间初始大小,达到该值会触发 GC 进行类型卸载,同时收集器会对该值进行调整,如果释放大量空间就适当降低该值,如果释放很少空间就适当提高。
运行时常量池
运行时常量池是方法区的一部分,Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译器生成的各种字面量与符号引用,这部分内容在类加载后存放到运行时常量池。一般除了保存 Class 文件中描述的符号引用外,还会把符号引用翻译的直接引用也存储在运行时常量池。
运行时常量池相对于 Class 文件常量池的一个重要特征是动态性,Java 不要求常量只有编译期才能产生,例如 String 的 intern
方法。
intern
方法是一个本地方法,作用是如果字符串常量池中已包含一个等于此 String 对象的字符串,则返回池中这个字符串的 String 对象的引用,否则将此 String 对象包含的字符串添加到常量池并返回此 String 对象的引用。
在 JDK6 及之前常量池分配在永久代,因此可以通过 -XX:PermSize
和 -XX:MaxPermSize
限制永久代大小,间接限制常量池。在 while 死循环中调用 intern
方法导致运行时常量池溢出。在 JDK7 后不会出现该问题,因为存放在永久代的字符串常量池已经被移至堆中。
内存溢出和内存泄漏
内存溢出 OutOfMemory,指程序在申请内存时,没有足够的内存空间供其使用。
内存泄露 Memory Leak,指程序在申请内存后,无法释放已申请的内存空间,内存泄漏最终将导致内存溢出。
创建对象
创建对象的过程⭐
① 当 JVM 遇到字节码 new 指令时,首先检查能否在常量池中定位到一个类的符号引用,并检查该类是否已被加载。
② 在类加载检查通过后虚拟机将为新生对象分配内存。
③ 内存分配完成后虚拟机将成员变量设为零值,保证对象的实例字段可以不赋初值就使用。
④ 设置对象头,包括哈希码、GC 信息、锁信息、对象所属类的类元信息等。
⑤ 执行 init 方法,初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。
分配内存
分配内存相当于把一块确定大小的内存块从 Java 堆划分出来。
指针碰撞: 假设 Java 堆内存规整,利用一个指针将内存分为两部分,分配内存就是把指针向空闲方向挪动一段与对象大小相等的距离。
空间列表: 如果 Java 堆内存不规整,虚拟机维护一个列表记录可用内存,分配时从列表中找到一块足够的空间划分给对象并更新列表。
选择哪种分配方式由堆是否规整决定,堆是否规整由垃圾收集器是否有空间压缩能力决定。使用 Serial、ParNew 等收集器时,系统采用指针碰撞;使用 CMS 时,采用空间列表。
修改指针位置是线程不安全的,存在正给对象分配内存,指针还没来得及修改,其它对象又使用指针分配内存的情况。解决方法:① CAS 加失败重试。② 把内存分配按线程划分在不同空间,叫做本地线程分配缓冲 TLAB,哪个线程要分配内存就在对应的 TLAB 分配。
内存布局
对象头
占 12B,包括对象标记和类型指针。对象标记存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁标志、偏向线程 ID 等,这部分占 8B,称为 Mark Word。Mark Word 被设计为动态数据结构,以便在极小的空间存储更多数据,根据对象状态复用存储空间。类型指针是对象指向它的类型元数据的指针,占 4B,JVM 通过该指针来确定对象是哪个类的实例。
实例数据
对象真正存储的有效信息,即本类对象的实例成员变量和所有可见的父类成员变量。存储顺序会受到虚拟机分配策略参数和字段在源码中定义顺序的影响。
对齐填充
仅起占位符作用。虚拟机的内存管理系统要求任何对象的大小必须是 8B 的倍数,如果没有对齐需要对齐填充补全。
垃圾回收
判断垃圾
引用计数
在对象中添加一个引用计数器,如果被引用计数器加 1,引用失效时计数器减 1,如果计数器为 0 则被标记为垃圾。简单高效,但在 Java 中很少使用,因为存在对象循环引用的问题,导致计数器无法清零。
可达性分析
通过一系列称为 GC Roots 的根对象作为起始节点集,从这些节点开始根据引用关系向下搜索,搜索过程走过的路径称为引用链,如果某个对象到 GC Roots 没有任何引用链相连则会被标记为垃圾。可作为 GC Roots 的对象包括:虚拟机栈和本地方法栈中引用的对象、类静态属性引用的对象、常量引用的对象。
引用类型
JDK1.2 后对引用进行了扩充,按强度分为四种。
强引用: 最常见的引用,例如使用 new 创建对象。只要对象有强引用指向且 GC Roots 可达,即使濒临内存耗尽也不会回收。
软引用: 弱于强引用,描述非必需对象。系统发生内存溢出前,会把软引用关联的对象加入回收范围。
弱引用: 弱于软引用,描述非必需对象。弱引用关联的对象只能生存到下次 YGC 前,GC 时无论内存是否足够都会回收。
虚引用: 最弱的引用,无法通过引用获取对象。唯一目的是在对象被回收时收到一个系统通知,必须与引用队列配合。
GC 算法
标记清除
分为标记和清除阶段,首先从每个 GC Roots 出发依次标记有引用关系的对象,最后清除没有标记的对象。如果堆包含大量对象且大部分需要回收,必须进行大量标记清除,效率低。
缺点:存在内存空间碎片化问题,分配大对象时容易触发 Full GC。
标记复制
为解决内存碎片,将可用内存按容量划分为大小相等的两块,每次只使用其中一块,主要用于新生代。
缺点:对象存活率高时要进行较多复制操作,效率低。如果不想浪费空间就需要有额外空间分配担保,老年代一般不使用此算法。
HotSpot 把新生代划分为一块较大的 Eden 和两块较小的 Survivor,每次分配内存只使用 Eden 和其中一块 Survivor。垃圾收集时将 Eden 和 Survivor 中仍然存活的对象一次性复制到另一块 Survivor 上,然后直接清理掉 Eden 和已用过的那块 Survivor。HotSpot 默认Eden 和 Survivor 的大小比例是 8:1,每次新生代中可用空间为整个新生代的 90%。
标记整理
老年代使用标记整理算法,标记过程与标记清除算法一样,但不直接清理可回收对象,而是让所有存活对象都向内存空间一端移动,然后清理掉边界以外的内存。
标记清除与标记整理的区别:前者是一种非移动式算法,后者是移动式的。如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活的区域,开销很大,而且移动必须暂停用户线程;如果不移动会导致空间碎片问题。
垃圾收集器
Serial
最基础的收集器,使用复制算法、单线程工作,进行垃圾收集时必须暂停其他线程。
Serial 是客户端模式的默认新生代收集器,对于处理器核心较少的环境,由于没有线程开销,可获得最高的单线程收集效率。
ParNew
Serial 的多线程版本,ParNew 是虚拟机在服务端模式的默认新生代收集器,一个重要原因是除了 Serial 外只有它能与 CMS 配合。从 JDK9 开始,ParNew 加 CMS 不再是官方推荐的解决方案,官方希望它被 G1 取代。
Parallel Scavenge
基于复制算法、多线程工作的新生代收集器。
它的目标是高吞吐量,吞吐量就是处理器用于运行用户代码的时间与处理器消耗总时间的比值。
Serial Old
Serial 的老年代版本,使用整理算法。
Serial Old 是客户端模式的默认老年代收集器,用于服务端有两种用途:① JDK5 前与 Parallel Scavenge 搭配。② 作为 CMS 失败预案。
Parellel Old
Parallel Scavenge 的老年代版本,支持多线程,基于整理算法。JDK6 提供,注重吞吐量可考虑 Parallel Scavenge 加 Parallel Old 组合。
CMS
以获取最短回收停顿时间为目标,基于清除算法,过程分为四个步骤:
初始标记:标记 GC Roots 能直接关联的对象,速度很快。
并发标记:从 GC Roots 的直接关联对象开始遍历整个对象图,耗时较长但不需要停顿用户线程。
重新标记:修正并发标记期间因用户程序运作而导致标记产生变动的记录。
并发清除:清理标记阶段判断的已死亡对象,不需要移动存活对象,该阶段也可与用户线程并发。
缺点:① 对处理器资源敏感,并发阶段虽然不会导致用户线程暂停,但会降低吞吐量。② 无法处理浮动垃圾,有可能出现并发失败而导致 Full GC。③ 基于清除算法,会产生空间碎片。
G1
开创了面向局部收集的设计思路和基于 Region 的内存布局,主要面向服务端,最初设计目标是替换 CMS。
G1 可面向堆任何部分来组成回收集进行回收,衡量标准不再是分代,而是哪块内存中垃圾的价值最大。价值即回收所获空间大小以及回收所需时间的经验值,G1 在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间优先处理回收价值最大的 Region。
G1 运作过程:
- 初始标记:标记 GC Roots 能直接关联到的对象,让下一阶段用户线程并发运行时能正确地在可用 Region 中分配新对象。需要 STW 但耗时很短,在 Minor GC 时同步完成。
- 并发标记:从 GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆的对象图。耗时长但可与用户线程并发,扫描完成后要重新处理 SATB 记录的在并发时有变动的对象。
- 最终标记:对用户线程做短暂暂停,处理并发阶段结束后仍遗留下来的少量 SATB 记录。
- 筛选回收:对各 Region 的回收价值排序,根据用户期望停顿时间制定回收计划。必须暂停用户线程,由多条收集线程并行完成。
ZGC
JDK11 中加入的具有实验性质的低延迟垃圾收集器,目标是尽可能在不影响吞吐量的前提下,实现在任意堆内存大小都可以把停顿时间限制在 10ms 以内的低延迟。
基于 Region 内存布局,不设分代,使用了读屏障、染色指针和内存多重映射等技术实现可并发的标记整理。ZGC 的 Region 具有动态性,是动态创建和销毁的,并且容量大小也是动态变化的。
内存分配与回收策略
对象优先在 Eden 区分配
大多数情况下对象在新生代 Eden 区分配,当 Eden 没有足够空间时将发起一次 Minor GC。
大对象直接进入老年代
大对象指需要大量连续内存空间的对象,例如很长的字符串或大数组,容易导致内存还有不少空间就提前触发 GC 以获得足够连续空间。
HotSpot 提供了 -XX:PretenureSizeThreshold
参数,大于该值的对象直接在老年代分配,避免在 Eden 和 Survivor 间来回复制。
长期存活对象进入老年代
虚拟机给每个对象定义了一个对象年龄计数器,存储在对象头。如果经历过第一次 Minor GC 仍然存活且能被 Survivor 容纳,该对象就会被移动到 Survivor 中并将年龄设置为 1。对象在 Survivor 中每熬过一次 Minor GC 年龄就加 1 ,当增加到一定程度(默认15)就会被晋升到老年代。对象晋升老年代的阈值可通过 -XX:MaxTenuringThreshold
设置。
动态对象年龄判定
为了适应不同内存状况,虚拟机不要求对象年龄达到阈值才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 的一半,年龄不小于该年龄的对象就可以直接进入老年代。
空间分配担保
MinorGC 前虚拟机必须检查老年代最大可用连续空间是否大于新生代对象总空间,如果满足则说明这次 Minor GC 确定安全。
如果不满足,虚拟机会查看 -XX:HandlePromotionFailure
参数是否允许担保失败,如果允许会继续检查老年代最大可用连续空间是否大于历次晋升老年代对象的平均大小,如果满足将冒险尝试一次 Minor GC,否则改成一次 FullGC。
冒险是因为新生代使用复制算法,只用一个 Survivor,大量对象在 Minor GC 后仍然存活时需要老年代接收 Survivor 无法容纳的对象。
故障处理工具
jps:虚拟机进程状况工具
列出正在运行的虚拟机进程,使用 Windows 的任务管理器或 UNIX 的 ps 命令也可以查询,但如果同时启动多个进程,必须依赖 jps。
jstat:虚拟机统计信息监视工具
监视虚拟机各种运行状态信息,显示本地或远程虚拟机进程中的类加载、内存、垃圾收集、即时编译器等运行时数据。
jinfo:Java 配置信息工具
查看虚拟机各项参数,使用 jps -v
可查看虚拟机启动时显式指定的参数,但如果想知道未显式指定的参数只能使用 jinfo -flag
。
jmap:Java 内存映像工具
用于生成堆转储快照,还可以查询 finalize 执行队列、Java 堆和方法区的详细信息,如空间使用率,当前使用的是哪种收集器等。
jhat:虚拟机堆转储快照分析工具
JDK 提供 jhat 与 jmap 搭配使用分析堆转储快照。jhat 内置微型的 HTTP/Web 服务器,堆转储快照的分析结果后可以在浏览器查看。
jstack:Java 堆栈跟踪工具
用于生成虚拟机当前时刻的线程快照,定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间挂起等。
类加载机制
Java 程序运行过程
首先通过 Javac 编译器将 .java
文件转为 JVM 可加载的 .class
字节码文件。编译过程分为: ① 词法解析,通过空格分割出单词、操作符、控制符等信息,形成 token 信息流,传递给语法解析器。② 语法解析,把 token 信息流按照 Java 语法规则组装成语法树。③ 语义分析,检查关键字使用是否合理、类型是否匹配、作用域是否正确等。④ 字节码生成,将前面各个步骤的信息转换为字节码。
之后通过即时编译器 JIT 把字节码文件编译成本地机器码。Java 程序最初都是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会认定其为热点代码,热点代码的检测主要有采样和计数器两种方式,为了提高热点代码的执行效率,虚拟机会把它们编译成本地机器码。
类加载
Class 文件中的信息需要加载到虚拟机后才能使用。JVM 把描述类的数据从 Class 文件加载到内存,并对数据进行校验、解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程称为虚拟机的类加载机制。
Java 类型的加载、连接和初始化都是在运行期间完成的,这增加了性能开销,但提供高扩展性,Java 动态扩展的特性就是依赖运行期动态加载和连接实现的。
类型从被加载到卸出,整个生命周期经历加载、验证、准备、解析、初始化、使用和卸载七个阶段,其中验证、解析和初始化三部分称为连接。加载、验证、准备、初始化的顺序是确定的,解析则不一定:可能在初始化后再开始,这是为了支持 Java 的动态绑定。
类初始化的情况
① 遇到 new
、getstatic
、putstatic
字节码指令时,还未初始化。例如 new 实例化对象、设置静态字段、调用静态方法。
② 对类反射调用时,还未初始化。
③ 初始化类时,父类还未初始化。(接口初始化时不要求父接口初始化,只有在真正使用父接口时才会初始化,如引用接口常量)
④ 虚拟机启动时,会先初始化包含 main 方法的主类。
⑤ 接口定义了默认方法,如果接口的实现类初始化,接口要在其之前初始化。
其余所有引用类型的方式都不会触发初始化,称为被动引用。被动引用举例:① 子类使用父类的静态字段时,只有父类被初始化。② 通过数组定义使用类。③ 常量在编译期会存入调用类的常量池,不会初始化定义常量的类。
类加载的过程⭐
加载
通过一个类的全限定类名获取对应的二进制字节流,将流所代表的静态存储结构转化为方法区的运行时数据区,然后在内存中生成对应该类的 Class 实例,作为方法区中这个类的数据访问入口。
验证
确保 Class 文件的字节流符合约束。如果虚拟机不检查输入的字节流,可能因为载入有错误或恶意企图的字节流而导致系统受攻击。验证主要包含:文件格式验证、元数据验证、字节码验证、符号引用验证。
验证通过后对程序运行期没有任何影响。如果代码已被反复使用和验证过,在生产环境就可以考虑关闭大部分验证缩短类加载时间。
准备
为类静态变量分配内存并设置零值,该阶段进行的内存分配仅包括类变量,不包括实例变量。如果变量被 final 修饰,编译时 Javac 会为变量生成 ConstantValue 属性,准备阶段虚拟机会将变量值设为代码值。
解析
将常量池内的符号引用替换为直接引用。
符号引用以一组符号描述引用目标,可以是任何形式的字面量,只要使用时能无歧义地定位目标即可,引用目标不一定已经加载到虚拟机内存;直接引用是可以直接指向目标的指针、相对偏移量或能间接定位到目标的句柄,引用目标必须已在虚拟机的内存中存在。
初始化
直到该阶段 JVM 才开始执行类中编写的代码。准备阶段时变量赋过零值,初始化阶段会根据程序员的编码去初始化类变量和其他资源。初始化阶段就是执行类构造方法中的 <client>
方法,该方法是 Javac 自动生成的。
类加载器
启动类加载器
在 JVM 启动时创建,负责加载最核心的类,例如 Object、System 等。无法被程序直接引用,如果需要把加载委派给启动类加载器,直接使用 null 代替即可,因为启动类加载器通常由操作系统实现,并不存在于 JVM 体系。
平台类加载器
从 JDK9 开始从扩展类加载器更换为平台类加载器,负载加载一些扩展的系统类,比如 XML、加密、压缩相关的功能类等。
应用类加载器
也称系统类加载器,负责加载用户类路径上的类库,可以直接在代码中使用。如果没有自定义类加载器,一般情况下应用类加载器就是默认的类加载器。自定义类加载器通过继承 ClassLoader 并重写 findClass
方法实现。
双亲委派模型
双亲委派模型要求除了顶层的启动类加载器外,其余类加载器都应该有自己的父加载器。
一个类加载器收到了类加载请求,不会自己去尝试加载,而将该请求委派给父加载器,每层的类加载器都是如此,因此所有加载请求最终都应该传送到启动类加载器,只有当父加载器反馈无法完成请求时,子加载器才会尝试。
类跟随它的加载器一起具备了有优先级的层次关系,确保某个类在各个类加载器环境中都是同一个,保证程序的稳定性。
判断两个类是否相等
任意一个类都必须由类加载器和这个类本身共同确立其在虚拟机中的唯一性。两个类只有由同一类加载器加载才有比较意义,否则即使两个类来源于同一个 Class 文件,被同一个 JVM 加载,只要类加载器不同,这两个类就必定不相等。
并发
JMM
JMM⭐
Java 线程的通信由 JMM 控制,JMM 定义了变量的访问规则,变量包括实例字段、静态字段,但不包括局部变量、方法参数这些线程私有的值。JMM 基本原则:只要不改变程序执行结果,编译器和处理器怎么优化都行,例如某个锁只会单线程访问就消除锁,某个 volatile 变量只会单线程访问就当作普通变量。
JMM 规定所有变量都存储在主内存,每条线程有自己的工作内存,工作内存中保存变量的主内存副本,线程对变量的所有操作都必须在工作内存进行,不能直接读写主内存。
关于主内存与工作内存的交互,即变量如何从主内存拷贝到工作内存、从工作内存同步回主内存,JMM 定义了 8 种原子操作:
操作 | 作用范围 | 作用 |
---|---|---|
lock | 主内存 | 把变量标识为锁定状态 |
unlock | 主内存 | 释放锁定状态的变量 |
read | 主内存 | 把变量值从主内存读到工作内存 |
load | 工作内存 | 把 read 值加载到工作内存 |
use | 工作内存 | 把 load 值传给执行引擎 |
assign | 工作内存 | 把 use 值赋给工作内存变量 |
store | 工作内存 | 把 assign 值传到主内存 |
write | 主内存 | 把 store 值写回主内存变量 |
happens-before
先行发生原则,指两项操作间的偏序关系。JMM 存在一些天然的 happens-before 关系,如果两个操作的关系不在此列且无法从中推导,虚拟机就可以对其重排序:
- 一个线程内写在前面的操作先行发生于后面的。
- unlock 先行发生于后面对同一个锁的 lock。
- 对 volatile 变量的写先行发生于后面的读。
- 线程的
start
方法先行发生于线程的每个动作。 - 线程中所有操作先行发生于对线程的终止检测。
- 对象的初始化先行发生于
finalize
方法。 - 如果 A 先行发生于 B,B 先行发生于 C,那么 A 先行发生于 C 。
as-if-serial 保证单线程程序的执行结果不变,happens-before 保证正确同步的多线程程序的执行结果不变。这两种语义的目的都是为了在不改变程序执行结果的前提下尽可能提高程序并行度。
重排序
为了提高性能,编译器和处理器通常会对指令进行重排序,重排序指从源代码到指令序列的重排序,分为三种:① 编译器重排序,编译器在不改变单线程程序语义的前提下可以重排语句的执行顺序。② 处理器重排序,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。③ 内存系统的重排序。
原子性、可见性、有序性
原子性
原子性指操作要么全部成功,要么全部失败。基本数据类型的访问都具备原子性,例外就是 long 和 double,虚拟机将没有被 volatile 修饰的 64 位数据操作划分为两次 32 位操作。
可见性
可见性指当一个线程修改了共享变量时,其他线程能够立即得知修改。
volatile:保证新值能立即同步到主内存以及每次使用前立即从主内存刷新。
synchronized:对一个变量执行 unlock 前必须先把此变量同步回主内存,即先执行 store 和 write。
final:final 字段在构造方法中一旦初始化完成,并且构造方法没有把 this 引用传递出去,其他线程就能看到 final 字段的值。
有序性
volatile 和 synchronized 保证有序性,volatile 本身就包含禁止指令重排序的语义,而 synchronized 保证一个变量在同一时刻只允许一条线程对其进行 lock 操作。
volatile⭐
volatile 变量特性:
-
保证变量对所有线程可见
当一条线程修改了变量值,新值对于其他线程来说立即可见。但 Java 运算并非原子操作,导致 volatile 变量运算在并发下仍不安全。
-
禁止指令重排序优化
使用 volatile 变量进行写操作,汇编指令带有 lock 前缀,相当于一个内存屏障,后面的指令不能重排到屏障之前。
lock 引发两件事:① 将当前处理器缓存行的数据写回系统内存。②使其他处理器的缓存无效。相当于对缓存变量做了一次 store 和 write 操作,让 volatile 变量的修改对其他处理器立即可见。
静态变量 i++ 的不安全问题
自增语句由 4 条字节码指令构成的,依次为 getstatic
、iconst_1
、iadd
、putstatic
,当 getstatic
把 i 取到操作栈顶时,volatile 保证了 i 值在此刻正确,但在执行 iconst_1
、iadd
时,其他线程可能已经改变了 i 值,操作栈顶的值就变成了脏数据,所以 putstatic
后就可能把较小的值同步回了主内存。
适用场景
运算结果不依赖变量的当前值;一写多读,只有单一的线程修改变量值。
内存语义
写 volatile 变量时,把该线程工作内存中的值刷新到主内存;读 volatile 变量时,把该线程工作内存值置为无效,从主内存读取。
在旧的内存模型中,不允许 volatile 变量间重排序,但允许 volatile 变量与普通变量重排序,可能导致内存不可见问题。JSR-133 严格限制编译器和处理器对 volatile 变量与普通变量的重排序,确保 volatile 的写-读和锁的释放-获取具有相同的内存语义。
final⭐
final 类不能被继承,所有成员方法都会被隐式地指定为 final 方法,final 方法不能被重写。
final 变量表示常量,只能被赋值一次,赋值后值不再改变。
- 修饰基本数据类型时,该值在初始化后不能改变。
- 修饰引用类型时,引用指向的对象在初始化后不能改变,但该对象的内容可以发生变化。
内存语义
编译器会在 final 域的写后,构造方法的 return 前插入一个 Store Store 屏障,确保对象引用为任意线程可见前其 final 域已初始化。
编译器在读 final 域操作的前面插入一个 Load Load 屏障,确保在读一个对象的 final 域前一定会先读包含这个 final 域的对象引用。
锁
synchronized⭐
每个 Java 对象都有一个关联的 monitor,JVM 会根据 monitor 的状态进行加解锁的判断,monitor 在被释放前不能被其他线程获取。
同步块使用 monitorenter
和 monitorexit
字节码指令获取和释放 monitor。这两个指令都需要一个引用类型的参数指明锁对象,对于普通方法,锁是当前实例对象;对于静态方法,锁是当前类的 Class 对象;对于方法块,锁是 synchronized 括号里的对象。
执行 monitorenter
指令时,首先尝试获取对象锁。如果这个对象没有被锁定,或当前线程已经持有锁,就把锁的计数器加 1,执行 monitorexit
指令时会将锁计数器减 1。一旦计数器为 0 锁随即就被释放。
假设有两个线程 A、B 竞争锁,当 A 竞争到锁时会将 monitor 中的 owner 设置为 A,把 B 阻塞并放到等待资源的 ContentionList 队列。ContentionList 中的部分线程会进入 EntryList,EntryList 中的线程会被指定为 OnDeck 竞争候选者,如果获得了锁资源将进入 Owner 状态,释放锁后进入 !Owner 状态。
synchronized 修饰的同步块是可重入的,并且持有锁的线程释放锁前会阻塞其他线程。持有锁是一个重量级的操作,Java 线程是映射到操作系统的内核线程上的,如果要阻塞或唤醒一条线程,需要用户态到核心态的转换。
不公平性
所有收到锁请求的线程首先自旋,如果通过自旋也没有获取锁将被放入 ContentionList,该做法对于已经进入队列的线程不公平。
为了防止 ContentionList 队列尾部的元素被大量线程 CAS 访问影响性能,Owner 线程会在释放锁时将队列的部分线程移动到 EntryList 并指定某个线程为 OnDeck 线程。
锁优化策略⭐
JDK 6 对 synchronized 做了很多优化,引入了自适应自旋、锁消除、锁粗化、偏向锁和轻量级锁等提高锁的效率,锁一共有 4 个状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁,状态会随竞争升级,但不能降级。
自适应自旋
许多锁定时间很短,为了这段时间去挂起和恢复线程并不划算。如果机器有多个处理器核心,可以让后面请求锁的线程稍等一会,但不放弃处理器的执行时间,看看锁是否很快会被释放,这就是自旋锁。
自旋锁在 JDK1.4 引入,默认关闭,在 JDK6 默认开启。如果自旋超过限定次数仍然没有成功,就应挂起线程,自旋默认限定次数是 10。
JDK6 对自旋锁进行了优化,自旋时间不再固定,而是由前一次的自旋时间及锁拥有者的状态决定。
如果在同一个锁上,自旋刚刚成功获得过锁且持有锁的线程正在运行,虚拟机会认为这次自旋也很可能成功,允许自旋持续更久。如果自旋很少成功,以后获取锁时将可能直接省略掉自旋,避免浪费处理器资源。
锁消除
锁消除指即时编译器对检测到不可能存在共享数据竞争的锁进行消除。如果堆上的所有数据都只被一个线程访问,就当作栈上的数据对待,认为它们是线程私有的而无须同步。
锁粗化
原则上需要将同步块的作用范围限制得尽量小,只在共享数据的实际作用域进行同步,使等待锁的线程尽快拿到锁。
但如果一系列的连续操作都对同一个对象反复加锁和解锁,即使没有线程竞争也会导致不必要的性能消耗。因此如果虚拟机探测到有一串零碎的操作都对同一个对象加锁,会把同步范围扩展到整个操作序列的外部。
偏向锁
偏向锁是为了在没有竞争的情况下减少锁开销,锁会偏向于第一个获得它的线程,如果在执行过程中锁一直没有被其他线程获取,则持有偏向锁的线程将不需要同步。
当锁对象第一次被线程获取时,虚拟机会将对象头中的偏向模式设为 1,同时使用 CAS 把获取到锁的线程 ID 记录在对象的 Mark Word 中。如果 CAS 成功,持有偏向锁的线程以后每次进入锁相关的同步块都不再进行任何同步操作。
一旦有其他线程尝试获取锁,偏向模式立即结束,根据锁对象是否处于锁定状态决定是否撤销偏向,后续同步按照轻量级锁的步骤执行。
轻量级锁
轻量级锁是为了在没有竞争的前提下减少重量级锁的性能消耗。
在代码即将进入同步块时,如果同步对象没有被锁定,虚拟机将在当前线程的栈帧中建立一个锁记录空间,存储锁对象目前 Mark Word 的拷贝。然后使用 CAS 尝试把对象的 Mark Word 更新为指向锁记录的指针,如果更新成功代表该线程拥有了锁,锁标志位将转变为 00,表示轻量级锁状态。
如果更新失败就意味着存在线程竞争。虚拟机检查对象的 Mark Word 是否指向当前线程的栈帧,如果是则说明当前线程已经拥有了锁,直接进入同步块继续执行,否则说明锁已被其他线程抢占。如果出现两条以上线程竞争锁,轻量级锁将膨胀为重量级锁,锁标志状态变为 10,此时Mark Word 存储的就是指向重量级锁的指针,后面等待锁的线程将阻塞。
解锁同样通过 CAS 进行,如果对象 Mark Word 仍然指向线程的锁记录,就用 CAS 把对象当前的 Mark Word 和线程复制的 Mark Word 替换回来。假如替换成功同步过程就完成了,否则说明有其他线程尝试过获取该锁,就要在释放锁的同时唤醒被挂起的线程。
偏向锁、轻量级锁和重量级锁的区别
偏向锁的优点是加解锁不需要额外消耗,和执行非同步方法比仅存在纳秒级差距,适用只有一个线程访问同步代码块的场景。
轻量级锁的优点是程序响应速度快,缺点是如果线程始终得不到锁会自旋消耗 CPU,适用追求响应时间、同步代码块执行快的场景。
重量级锁的优点是线程竞争不使用自旋不消耗CPU,缺点是线程会阻塞,响应时间慢,适应追求吞吐量、同步代码块执行慢的场景。
Lock
Lock 是 juc 包的顶层接口,摆脱了语言特性束缚,在类库层面实现同步,利用了 volatile 的可见性。
重入锁 ReentrantLock 是 Lock 最常见的实现,与 synchronized 一样可重入,不过它增加了一些功能:
- **等待可中断: **持有锁的线程长期不释放锁时,正在等待的线程可以选择放弃等待而处理其他事情。
- 公平锁: synchronized 是非公平的,ReentrantLock 默认是非公平的,可以通过构造方法指定公平锁。
-
锁绑定多个条件: 一个 ReentrantLock 可以同时绑定多个 Condition。synchronized 中锁对象的
wait
跟notify
只可以实现一个隐含条件,而 ReentrantLock 可以调用newCondition
创建多个条件。
一般优先考虑 synchronized:① synchronized 是语法层面的同步,足够简单。② Lock 必须手动在 finally 释放锁,而synchronized 可以由 JVM 来确保即使出现异常也能正常释放锁。③ 尽管 JDK5 时 ReentrantLock 的性能优于 synchronized,但 JDK6 锁优化后二者的性能基本持平。 JVM 更可能针对synchronized 优化,因为 JVM 可以在线程和对象的元数据中记录锁的相关信息。
ReentrantLock 的可重入实现
以非公平锁为例,通过 nonfairTryAcquire
方法获取锁,如果是持有锁的线程再次请求则增加同步状态值并返回 true。
释放同步状态时将减少同步状态值。如果锁被获取了 n 次,那么前 n-1 次 tryRelease
方法必须都返回 fasle,只有同步状态完全释放才能返回 true,并将锁占有线程设置为null。
公平锁使用 tryAcquire
方法,该方法与非公平锁的区别是:判断条件中多了对同步队列中当前节点是否有前驱节点的判断,如果该方法返回 true 表示有线程比当前线程更早请求锁,因此需要等待前驱线程。
读写锁
读写锁在同一时刻允许多个读线程访问,在写线程访问时,所有的读写线程均阻塞。
读写锁依赖 AQS 实现,在一个 int 变量上维护读线程和写线程的状态,将变量切分成了两个部分,高 16 位表示读,低 16 位表示写。
写锁是可重入排他锁,如果当前线程已经获得了写锁则增加写状态,如果当前线程在获取写锁时,读锁已经被获取或者该线程不是已经获得写锁的线程则等待。写锁的释放与 ReentrantLock 的释放类似,每次释放减少写状态,当写状态为 0 时表示写锁已被释放。
读锁是可重入共享锁,能够被多个线程同时获取。如果当前线程已经获取了读锁,则增加读状态。如果当前线程在获取读锁时,写锁已被其他线程获取则进入等待。读锁每次释放会减少读状态,减少的值是 1<<16
,读锁的释放是线程安全的。
锁降级指把持住当前写锁再获取读锁,随后释放先前拥有的写锁。
锁降级中读锁的获取是必要的,这是为了保证数据可见性,如果当前线程不获取读锁而直接释放写锁,假设另一个线程 A 获取写锁修改数据,当前线程无法感知线程 A 的数据更新。
AQS
AQS 队列同步器是用来构建锁或其他同步组件的基础框架,使用一个 volatile int 变量作为共享同步状态,如果线程获取状态失败,则进入同步队列等待;如果成功就执行临界区代码,释放状态时会通知同步队列中的等待线程。
同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态,对同步状态进行更改需要使用同步器提供的 3个方法 getState
、setState
和 compareAndSetState
,它们保证状态改变是安全的。
每当有新线程请求同步状态时都会进入一个等待队列,等待队列通过双向链表实现,线程被封装在链表的 Node 节点中,Node 的等待状态包括:CANCELLED(线程已取消)、SIGNAL(线程需要唤醒)、CONDITION (线程正在等待)、PROPAGATE(后继节点会传播唤醒操作,只作用于共享模式)。
两种模式
独占模式下锁只会被一个线程占用,其他线程必须等持有锁的线程释放锁后才能获取锁。
获取同步状态时,调用 acquire
方法的 tryAcquire
方法安全地获取同步状态,获取失败的线程会被构造同步节点并通过 addWaiter
方法加入到同步队列的尾部,在队列中自旋。之后调用 acquireQueued
方法使得节点以死循环的方式获取同步状态,如果获取不到则阻塞,被阻塞线程的唤醒依靠前驱节点的出队或中断。后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点,目的是维护同步队列的 FIFO 原则,节点之间在循环检查的过程中基本不通信,而是简单判断自己的前驱是否为头节点。
释放同步状态时,同步器调用 tryRelease
方法释放同步状态,然后调用 unparkSuccessor
方法唤醒头节点的后继节点,使后继节点重新尝试获取同步状态。
共享模式下多个线程可以获取同一个锁。
获取同步状态时,调用 acquireShared
方法的 tryAcquireShared
方法,返回值为 int 类型,值不小于 0 表示能获取同步状态。
释放同步状态时,调用 releaseShared
方法,释放后会唤醒后续处于等待状态的节点。它和独占式的区别在于 tryReleaseShared
方法必须确保同步状态安全释放,通过循环 CAS 保证。
线程
生命周期⭐
NEW:新建状态,线程被创建且未启动,还未调用 start
方法。
RUNNABLE:Java 将操作系统中的就绪和运行两种状态统称为 RUNNABLE,此时线程有可能在等待时间片,也有可能在执行。
BLOCKED:阻塞状态,可能由于锁被其他线程占用、调用了 sleep
或 join
方法、执行了 wait
方法等。
WAITING:等待状态,该状态线程不会被分配 CPU 时间片,需要其他线程通知或中断。可能由于调用了无参的 wait
和 join
方法。
TIME_WAITING:限期等待状态,可以在指定时间内自行返回。导可能由于调用了带参的 wait
和 join
方法。
TERMINATED:终止状态,表示当前线程已执行完毕或异常退出。
线程的创建方式⭐
① 继承 Thread 类并重写 run
方法。实现简单,但不符合里氏替换原则,不可以继承其他类。
② 实现 Runnable 接口并重写 run
方法。避免了单继承局限性,实现解耦。
③实现 Callable 接口并重写 call
方法。可以获取线程执行结果的返回值,并且可以抛出异常。
线程方法
sleep
方法导致当前线程进入休眠状态,与 wait
不同的是该方法不会释放锁资源,进入的是 TIME-WAITING 状态。
yiled
方法使当前线程让出 CPU 时间片给优先级相同或更高的线程,回到 RUNNABLE 状态,与其他线程重新竞争 CPU 时间片。
join
方法用于等待其他线程运行终止,如果当前线程调用了另一个线程的 join
方法,则当前线程进入阻塞状态,当另一个线程结束时当前线程才能从阻塞状态转为就绪态,等待获取CPU时间片。底层使用 wait
,也会释放锁。
守护线程
守护线程是一种支持型线程,可以通过 setDaemon(true)
将线程设置为守护线程,但必须在线程启动前设置。
守护线程用于完成支持性工作,但在 JVM 退出时守护线程中的 finally 块不一定执行,因为 JVM 中没有非守护线程时需要立即退出,因此不能靠在守护线程使用 finally 确保关闭资源。
线程通信的方式⭐
Java 采用共享内存模型,线程间的通信总是隐式进行,整个通信过程对程序员完全透明。
volatile 告知程序任何对变量的读需要从主内存中获取,写必须同步刷新回主内存,保证所有线程对变量访问的可见性。
synchronized 确保多个线程在同一时刻只能有一个处于方法或同步块中,保证线程对变量访问的原子性、可见性和有序性。
等待通知机制指一个线程 A 调用了对象的 wait
方法进入等待状态,另一线程 B 调用了对象的 notify/notifyAll
方法,线程 A 收到通知后结束阻塞并执行后序操作。对象上的 wait
和 notify/notifyAll
完成等待方和通知方的交互。
如果一个线程执行了某个线程的 join
方法,这个线程就会阻塞等待执行了 join
方法的线程终止,这里涉及等待/通知机制。join
底层通过 wait
实现,线程终止时会调用自身的 notifyAll
方法,通知所有等待在该线程对象上的线程。
管道 IO 流用于线程间数据传输,媒介为内存。PipedOutputStream 和 PipedWriter 是输出流,相当于生产者,PipedInputStream 和 PipedReader 是输入流,相当于消费者。管道流使用一个默认大小为 1KB 的循环缓冲数组。输入流从缓冲数组读数据,输出流往缓冲数组中写数据。当数组已满时,输出流所在线程阻塞;当数组首次为空时,输入流所在线程阻塞。
ThreadLocal 是线程共享变量,但它可以为每个线程创建单独的副本,副本值是线程私有的,互相之间不影响。
线程池好处
降低资源消耗,复用已创建的线程,降低开销、控制最大并发数。
隔离线程环境,可以配置独立线程池,将较慢的线程与较快的隔离开,避免相互影响。
实现任务线程队列缓冲策略和拒绝机制。
实现某些与时间相关的功能,如定时执行、周期执行等。
线程池处理任务的流程
① 核心线程池未满,创建一个新的线程执行任务,此时 workCount < corePoolSize。
② 如果核心线程池已满,工作队列未满,将线程存储在工作队列,此时 workCount >= corePoolSize。
③ 如果工作队列已满,线程数小于最大线程数就创建一个新线程处理任务,此时 workCount < maximumPoolSize,这一步也需要获取全局锁。
④ 如果超过大小线程数,按照拒绝策略来处理任务,此时 workCount > maximumPoolSize。
线程池会将线程封装成工作线程 Worker,Worker 在执行完任务后会循环获取工作队列中的任务来执行。
创建线程池
可以通过 Executors 的静态工厂方法创建线程池:
① newFixedThreadPool
,固定大小的线程池,核心线程数也是最大线程数,不存在空闲线程,keepAliveTime = 0。使用的工作队列是无界阻塞队列 LinkedBlockingQueue,适用于负载较重的服务器。
② newSingleThreadExecutor
,使用单线程,相当于单线程串行执行所有任务,适用于需要保证顺序执行任务的场景。
③ newCachedThreadPool
,最大线程数是 Integer 最大值,使用的工作队列是没有容量的 SynchronousQueue,如果主线程提交任务的速度高于线程处理的速度,线程池会不断创建新线程,极端情况下会耗尽 CPU 和内存资源。适用于执行很多短期异步任务的小程序或负载较轻的服务器。
④ newScheduledThreadPool
:最大线程数是Integer 最大值,存在 OOM 风险。支持定期及周期性任务执行,适用需要多个后台执行任务,同时限制线程数量的场景。相比 Timer 更安全,功能更强,与 newCachedThreadPool
的区别是不回收工作线程。
线程池的参数
① corePoolSize:常驻核心线程数,设置过大会浪费资源,过小会导致线程的频繁创建与销毁。
② maximumPoolSize:线程池能够容纳同时执行的线程最大数,必须大于 0。
③ keepAliveTime:线程空闲时间,线程空闲时间达到该值后会被销毁,直到线程数等于 corePoolSize 为止,避免浪费内存资源。
④ unit:keepAliveTime 的时间单位。
⑤ workQueue:工作队列,当线程请求数大于等于 corePoolSize 时线程会进入队列。
⑥ threadFactory:线程工厂,用来生产一组相同任务的线程。可以给线程命名,有利于分析错误。
⑦ handler:拒绝策略,默认使用 AbortPolicy 丢弃任务并抛出异常,CallerRunsPolicy 重新尝试提交任务,DiscardOldestPolicy 抛弃队列里等待最久的任务并把当前任务加入队列,DiscardPolicy 丢弃任务但不抛出异常。
关闭线程池
可以调用 shutdown
或 shutdownNow
方法关闭线程池,原理是遍历线程池中的工作线程,逐个调用 interrupt
方法中断线程。
区别是 shutdownNow
首先将线程池的状态设为 STOP,然后尝试停止正在执行或暂停任务的线程,并返回等待执行任务的列表。而 shutdown
只是将线程池的状态设为 SHUTDOWN,然后中断没有正在执行任务的线程。
通常调用 shutdown
来关闭线程池,如果任务不一定要执行完可调用 shutdownNow
。
线程池的选择策略
-
任务性质:CPU 密集型、IO 密集型和混合型。
性质不同的任务用不同规模的线程池处理,CPU 密集型任务应配置尽可能少的线程;IO 密集型任务应配置尽可能多的线程;混合型任务,如果可以拆分,将其拆分为一个 CPU 密集型任务和一个 IO 密集型任务,只要两个任务执行时间相差不大,那么分解后的吞吐量将高于串行执行的吞吐量,如果相差太大则没必要分解。
-
任务优先级/执行时间。
使用优先级队列让优先级高或执行时间短的任务先执行。
-
任务依赖性:是否依赖其他资源,如数据库连接。
依赖数据库连接池的任务,由于线程提交 SQL 后需要等待数据库返回的结果,等待的时间越长 CPU 空闲的时间就越长,因此线程数应该尽可能地设置大一些,提高 CPU 的利用率。
阻塞队列
阻塞队列支持阻塞插入和移除,当队列满时,阻塞生产线程直到队列不满。当队列为空时,消费线程会被阻塞直到队列非空。阻塞生产者主要通过 LockSupport 的 park
方法实现,不同操作系统中实现方式不同,在 Linux 下使用的是系统方法 pthread_cond_wait
实现。
Java 中的阻塞队列
ArrayBlockingQueue,由数组组成的有界阻塞队列,默认情况下不保证线程公平。
LinkedBlockingQueue,由链表组成的有界阻塞队列,队列的默认和最大长度为 Integer 最大值。
PriorityBlockingQueue,支持优先级的无界阻塞队列,默认情况下元素按升序排序。可自定义 compareTo
方法指定排序规则,或者初始化时指定 Comparator 排序,不能保证同优先级元素的顺序。
DelayQueue,支持延时获取元素的无界阻塞队列,使用优先级队列实现。创建元素时可以指定多久才能从队列中获取当前元素,只有延迟期满时才能从队列中获取元素,适用于缓存和定时调度。
SynchronousQueue,不存储元素的阻塞队列,每一个 put 必须等待一个 take。默认使用非公平策略,适用于传递性场景,吞吐量高。
LinkedBlockingDeque,链表组成的双向阻塞队列,可从队列的两端插入和移出元素,多线程同时入队时减少了竞争。
ThreadLocal
ThreadLoacl 是线程共享变量,主要用于线程内跨类、方法传递数据。ThreadLoacl 有一个静态内部类 ThreadLocalMap,其 Key 是 ThreadLocal 对象,值是 Entry 对象,Entry 中只有一个 Object 类的 vaule 值。ThreadLocal 是线程共享的,但 ThreadLocalMap 是每个线程私有的。ThreadLocal 主要有 set、get 和 remove 三个方法。
set 方法
首先获取当前线程,然后再获取当前线程对应的 ThreadLocalMap 类型的对象 map。如果 map 存在就直接设置值,key 是当前的 ThreadLocal 对象,value 是传入的参数。
如果 map 不存在就通过 createMap
方法为当前线程创建一个 ThreadLocalMap 对象再设置值。
get 方法
首先获取当前线程,然后再获取当前线程对应的 ThreadLocalMap 类型的对象 map。如果 map 存在就以当前 ThreadLocal 对象作为 key 获取 Entry 类型的对象 e,如果 e 存在就返回它的 value 属性。
如果 e 不存在或 map 不存在,就调用 setInitialValue
方法为当前线程创建一个 ThreadLocalMap 对象然后返回默认的初始值 null。
remove 方法
首先通过当前线程获取其对应的 ThreadLocalMap 类型的对象 m,如果 m 不为空,就解除 ThreadLocal 这个 key 及其对应的 value 值的联系。
存在的问题
线程复用会产生脏数据,由于线程池会重用 Thread 对象,因此与 Thread 绑定的 ThreadLocal 也会被重用。如果没有调用 remove 清理与线程相关的 ThreadLocal 信息,那么假如下一个线程没有调用 set 设置初始值就可能 get 到重用的线程信息。
ThreadLocal 还存在内存泄漏的问题,由于 ThreadLocal 是弱引用,但 Entry 的 value 是强引用,因此当 ThreadLocal 被垃圾回收后,value 依旧不会被释放。因此需要及时调用 remove 方法进行清理操作。
JUC
CAS
CAS 表示比较并交换,需要三个操作数,内存位置 V、旧的预期值 A 和准备设置的新值 B。CAS 执行时,当且仅当 V=A 时,处理器才会用 B 更新 V 的值。不管是否更新都会返回 V 的旧值,处理过程是原子操作,期间不会被其他线程打断。
JDK5 开始使用 CAS 操作,该操作由 Unsafe 类里的 compareAndSwapInt
等几个方法提供。HotSpot 在内部对这些方法做了特殊处理,即时编译的结果是一条平台相关的处理器 CAS 指令。Unsafe 类不是给用户程序调用的类,因此 JDK9 前只有 Java 类库可以使用 CAS,譬如 AtomicInteger 类中 compareAndSet
等方法都使用了 Unsafe 类的 CAS 操作实现。
CAS 存在一个漏洞:如果初次读取和准备赋值时都满足 V=A,依旧不能说明值没有被其他线程更改过,因为存在 V 先改为 B 又改回 A 的情况,这个漏洞称为 ABA 问题。juc 包提供了一个 AtomicStampedReference 类,通过控制变量值的版本来解决 ABA 问题。
原子类
JDK5 提供了 atomic 包,包中的原子操作类提供了简单高效、线程安全地更新变量的方式。到 JDK8 该包共有17个类,依据作用分为四种:原子更新基本类型、原子更新数组、原子更新引用、原子更新字段,atomic 包里的类基本都使用 Unsafe 实现,Unsafe 只提供三种 CAS 方法:compareAndSwapInt
、compareAndSwapLong
和 compareAndSwapObject
,例如原子更新 Boolean 是先转成整形再使用 compareAndSwapInt
。
-
AtomicInteger 原子更新整形、 AtomicLong 原子更新长整型、AtomicBoolean 原子更新布尔类型。
AtomicInteger 的
getAndIncrement
调用 Unsafe 类的getAndAddInt
方法以原子方式将当前的值加 1,该方法调用compareAndSwapInt
更新值。 AtomicIntegerArray 原子更新整形数组、 AtomicLongArray 原子更新长整型数组、 AtomicReferenceArray 原子更新引用数组。
AtomicReference 原子更新引用、AtomicMarkableReference 原子更新带有标记的引用,AtomicStampedReference 原子更新带有版本号的引用,关联一个整数值作为版本号,解决 ABA 问题。
AtomicIntegerFieldUpdater 原子更新整形字段、 AtomicLongFieldUpdater 原子更新长整形字段、AtomicReferenceFieldUpdater 原子更新引用类型字段。
CountDownLatch
CountDownLatch 是基于执行时间的同步类,允许一个或多个线程等待其他线程完成操作,构造方法接收一个 int 参数作为计数器。每次调用 countDown
方法时计数器减 1,await
方法会阻塞当前线程直到计数器变为 0。
CyclicBarrier
循环屏障是基于同步到达某个点的信号量触发机制,作用是让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障才会解除。构造方法中的参数表示拦截线程数量,每个线程调用 await
方法表示自己已到达屏障,然后被阻塞。支持在构造方法中传入一个 Runnable 任务,当线程到达屏障时会优先执行该任务。适用于多线程计算数据,最后合并计算结果的应用场景。
CountDownLacth 的计数器只能用一次,而 CyclicBarrier 的计数器可用 reset
方法重置,所以 CyclicBarrier 能处理更为复杂的业务。
Semaphore
信号量用来控制同时访问特定资源的线程数量,通过协调各个线程以保证合理使用公共资源。信号量可以用于流量控制,特别是公共资源有限的应用场景,比如数据库连接。
Semaphore 的构造方法参数接收一个 int 值,表示可用的许可数量即最大并发数。使用 acquire
方法获得一个许可证,使用 release
方法归还许可,还可以用 tryAcquire
尝试获得许可。
Exchanger
交换者是用于线程间协作的工具类,用于进行线程间的数据交换。它提供一个同步点,在这个同步点两个线程可以交换彼此的数据。
两个线程通过 exchange
方法交换数据,第一个线程执行 exchange
方法后会阻塞等待第二个线程执行该方法,当两个线程都到达同步点时这两个线程就可以交换数据,将本线程生产出的数据传递给对方,可用于校对工作等场景。
ConcurrentHashMap⭐
ConcurrentHashMap 用于解决 HashMap 的线程安全和 HashTable 的低效问题,HashTable 效率低是因为所有线程都必须竞争同一把锁,假如容器有多把锁,每把锁只锁部分数据,那么多线程访问不同数据段时就不会存在竞争,ConcurrentHashMap 使用锁分段,将数据分成 Segment 数据段,给每个数据段配一把锁,当一个线程占用锁访问某段数据时,其他数据段也能被其他线程访问。
get
实现简单高效,先经过一次再散列得到一个 hash 值,再用这个 hash 值定位到 Segment,最后通过散列算法定位到元素。get
的高效在于不用加锁,除非读到空值才会加锁重读。get
方法中将共享变量定义为 volatile,由于只需要读所以不用加锁。
put
必须加锁,首先定位到 Segment,然后进行插入操作,第一步判断是否需要对 Segment 里的 HashEntry 数组进行扩容,第二步定位添加元素的位置,然后将其放入数组。
size
方法用于统计数量,必须统计每个 Segment 的大小然后求和,在统计结果累加的过程中,之前累加过的 count 变化几率很小,因此先尝试两次不加锁统计结果,如果统计过程中容器大小发生了变化,再加锁统计。判断容器是否发生变化根据 modCount 确定。
JDK8
主要改造:① 取消分段锁机制,降低冲突概率。② 同一个哈希槽上的元素个数超过阈值后,链表改为红黑树结构。③ 使用优化方式统计元素数量,在涉及元素总数的更新和计算时都避免了锁,使用 CAS 代替。
get
同样不需要同步,put
时如果没有出现哈希冲突,就使用 CAS 添加元素,否则使用 synchronized 添加元素。
当某个槽内的元素个数达到 7 且 table 容量不小于 64 时,链表转为红黑树。
当某个槽内的元素减少到 6 时,由红黑树重新转为链表。在转化过程中,使用同步块锁住当前槽的首元素,防止其他线程对当前槽进行增删改操作,转化完成后利用 CAS 替换原有链表。由于 TreeNode 节点也存储了 next 引用,因此红黑树转为链表很简单,只需从 first 元素开始遍历所有节点,并把节点从 TreeNode 转为 Node 类型即可,当构造好新链表后同样用 CAS 替换红黑树。
CopyOnWriteArrayList
可以使用 CopyOnWriteArrayList 代替 ArrayList,它实现了读写分离。写操作复制一个新的集合,在新集合内添加或删除元素,修改完成后再将原集合的引用指向新集合。这样做的好处是可以高并发地进行读写操作而不需要加锁,因为当前集合不会添加任何元素。使用时注意尽量设置容量初始值,使用批量添加或删除,避免多次扩容,防止只增加一个元素却复制整个集合。
适合读多写少的场景,单个添加时效率极低。CopyOnWriteArrayList 是 fail-safe 的,并发包的集合都是这种机制,fail-safe 在安全的副本上遍历,集合修改与副本遍历没有任何关系,缺点是无法读取最新数据。这也是 CAP 理论中 C(一致性) 和 A(可用性) 的矛盾。
参考资料
[1] HashMap和Hashtable的区别
[2] # JAVA基础知识之IO——Java IO体系及常用类
[3] Java反射介绍
[4] Java高级特性——反射
[5] # JAVA多线程之线程间的通信方式
[6] HashMap解决冲突的四种方法
[7] # hashmap冲突的解决方法以及原理分析:
[8] # Java多线程看这一篇就足够了(吐血超详细总结)
[9] Java多线程超详解很详细
[10] CPU内存模型和Java内存模型以及Java内存区域
[11] volatile关键字的作用以及原理
[12] # JVM的内存管理机制 特别好
[13] java中抽象类和接口有什么区别
[14] 漫画:什么是ConcurrentHashMap?
[15] ConcurrentHashMap底层实现原理(JDK1.7 & 1.8)
[16] Concurrenthashmap的实现原理分析