Java
基础概念
Java特点:面向对象(封装,继承,多态);平台无关(基于JVM);可靠安全(异常处理,自动内存管理,多重安全防护);编译与解释并存->一次编译,到处运行;Java生态
Java SE 是 Java 的基础版本,Java EE 是 Java 的高级版本。Java SE 更适合开发桌面应用程序或简单的服务器应用程序,Java EE 更适合开发复杂的企业级应用程序或 Web 应用程序。
编译型:编译型语言会通过编译器将源代码一次性翻译成可被该平台执行的机器码。一般情况下,编译语言的执行速度比较快,开发效率比较低。常见的编译性语言有C、C++、Go、Rust等等。
解释型:释型语言会通过解释器一句一句的将代码解释(interpret)为机器代码后再执行。解释型语言开发效率比较快,执行速度比较慢。常见的解释性语言有 Python、JavaScript、PHP 等等。
Java为了实现“一次编译,处处运行”的特性,把编译的过程分成两部分,首先它会先由javac编译成通用的中间形式——字节码,这些字节码可以在任何安装了Java虚拟机的平台上运行,由解释器逐条将字节码解释为机器码来执行。这种方式使得Java程序具有了跨平台性,同一份Java代码可以在各种操作系统和硬件平台上运行,而不需要针对不同平台进行重新编译。
在 Java 中,JVM 可以理解的代码就叫做字节码(即扩展名为 .class 的文件),它不面向任何特定的处理器,只面向虚拟机。Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。
JVM引入 JIT(Just in Time Compilation) 编译器, 属于运行时编译。当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。
Java和C++:
- Java 不提供指针来直接访问内存,程序内存更加安全
- Java 的类是单继承的,C++ 支持多重继承;虽然 Java 的类不可以多继承,但是接口可以多继承。
- Java 有**自动内存管理垃圾回收机制(GC)**,不需要程序员手动释放无用内存。
- C ++同时支持方法重载和操作符重载,但是 Java 只支持方法重载(操作符重载增加了复杂性,这与 Java 最初的设计思想不符)。
JDK、JRE、JVM
JDK(Java Development Kit)是一个功能齐全的 Java 开发工具包,供开发者使用,用于创建和编译 Java 程序。它包含了 JRE(Java Runtime Environment),以及编译器 javac 和其他工具,如 javadoc(文档生成器)、jdb(调试器)、jconsole(监控工具)、javap(反编译工具)等。
JRE (Java运行时环境)是运行已编译 Java 程序所需的环境,主要包含以下两个部分:JVM : Java 虚拟机。Java 基础类库(Class Library):一组标准的类库,提供常用的功能和 API(如 I/O 操作、网络通信、数据结构等)。
JRE 只包含运行 Java 程序所需的环境和类库,而 JDK 不仅包含 JRE,还包括用于开发和调试 Java 程序的工具。
基本数据类型
基本类型和包装类型
- 用途:基本类型用于定义一些常量和局部变量。方法参数/对象属性等多用包装类型。并且,包装类型可用于泛型,而基本类型不可以。
- 存储方式:基本类型的局部变量存放在 Java 虚拟机栈中的局部变量表中,基本数据类型的成员变量(未被
static修饰 )存放在 Java 虚拟机的堆中。包装类型属于对象类型,存在于堆中。 - 占用空间:相比于包装类型(对象类型), 基本数据类型占用的空间往往非常小。
- 默认值:成员变量包装类型不赋值就是
null,而成员变量基本类型有默认值且不是null。 - 比较方式:对于基本数据类型来说,
==比较的是值。对于包装数据类型来说,==比较的是对象的内存地址。所有整型包装类对象之间值的比较,全部使用equals()方法。
包装类型缓存机制:包装类型的大部分都用到了缓存机制来提升性能。Byte,Short,Integer,Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 True or False。如果超出对应范围仍然会去创建新的对象,缓存的范围区间的大小只是在性能和资源之间的权衡。两种浮点数类型的包装类 Float,Double 并没有实现缓存机制。
自动拆装箱:
- 装箱:将基本类型用它们对应的引用类型包装起来;
- 拆箱:将包装类型转换为基本数据类型;
Integer i = 10; // 装箱 调用包装类的.valueOf方法 -- 等价于 Integer i = Integer.valueOf(10)
int n = i; // 拆箱 调用包装类的xxxValue方法 -- 等价于int n = i.intValue();
成员变量与局部变量
语法形式:从语法形式上看,成员变量是属于类的,而局部变量是在代码块或方法中定义的变量或是方法的参数;成员变量可以被
public,private,static等修饰符所修饰,而局部变量不能被访问控制修饰符及static所修饰;但是,成员变量和局部变量都能被final所修饰。存储方式:从变量在内存中的存储方式来看,如果成员变量是使用
static修饰的,那么这个成员变量是属于类的,如果没有使用static修饰,这个成员变量是属于实例的。而对象存在于堆内存,局部变量则存在于栈内存。生存时间:从变量在内存中的生存时间上看,成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动生成,随着方法的调用结束而消亡。
默认值:从变量是否有默认值来看,成员变量如果没有被赋初始值,则会自动以类型的默认值而赋值(一种情况例外:被
final修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值。
重载和重写
- 重载(overload)是在一个类(或父类与子类)里面,方法名字相同,而参数不同。返回类型可以相同也可以不同。
- 重写(override)发生在运行期,是指子类定义了一个与其父类中具有相同名称和参数列表的方法,并且子类方法的实现覆盖了父类方法的实现。
| 区别点 | 重载方法 | 重写方法 |
|---|---|---|
| 发生范围 | 同一个类 | 子类 |
| 参数列表 | 必须修改 | 一定不能修改 |
| 返回类型 | 可修改 | 子类方法返回值类型应比父类方法返回值类型更小或相等 |
| 异常 | 可修改 | 子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等; |
| 访问修饰符 | 可修改 | 一定不能做更严格的限制(可以降低限制) |
| 发生阶段 | 编译期 | 运行期 |
方法的重写要遵循“两同两小一大”:
- “两同”即方法名相同、形参列表相同;
- “两小”指的是子类方法返回值类型应比父类方法返回值类型更小或相等,子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等;
- “一大”指的是子类方法的访问权限应比父类方法的访问权限更大或相等。
关于 重写的返回值类型 这里需要额外多说明一下,上面的表述不太清晰准确:如果方法的返回类型是 void 和基本数据类型,则返回值重写时不可修改。但是如果方法的返回值是引用类型,重写时是可以返回该引用类型的子类的。
另:类的构造方法不能被重写(override),但可以被重载(overload)。因此,一个类中可以有多个构造方法,这些构造方法可以具有不同的参数列表,以提供不同的对象初始化方式。
面向对象
封装
封装是指把一个对象的状态信息(也就是属性)隐藏在对象内部,不允许外部对象直接访问对象的内部信息。但是可以提供一些可以被外界访问的方法来操作属性。
继承
不同类型的对象,相互之间经常有一定数量的共同点。继承是使用已存在的类的定义作为基础建立新类的技术,新类的定义可以增加新的数据或新的功能,也可以用父类的功能,但不能选择性地继承父类。通过使用继承,可以快速地创建新的类,可以提高代码的重用,程序的可维护性,节省大量创建新类的时间 ,提高我们的开发效率。
- 子类拥有父类对象所有的属性和方法(包括私有属性和私有方法),但是父类中的私有属性和方法子类是无法访问,只是拥有。
- 子类可以拥有自己属性和方法,即子类可以对父类进行扩展。
- 子类可以用自己的方式实现父类的方法。
多态
表示一个对象具有多种的状态,具体表现为父类的引用指向子类的实例。
- 对象类型和引用类型之间具有继承(类)/实现(接口)的关系;
- 引用类型变量发出的方法调用的到底是哪个类中的方法,必须在程序运行期间才能确定;
- 多态不能调用“只在子类存在但在父类不存在”的方法;
- 如果子类重写了父类的方法,真正执行的是子类重写的方法,如果子类没有重写父类的方法,执行的是父类的方法。
另:静态方法不支持多态。多态是面向对象编程中的一个核心概念,它允许子类通过重写父类的方法来提供特定的实现。然而,由于静态方法不依赖于对象实例,它们不适用于多态。静态方法的调用在编译时就已经确定,这种机制被称为静态绑定或早期绑定。
接口和抽象类
抽象类:包含抽象方法的类。通过abstract关键字来创建抽象类,以及定义抽象方法。抽象类的存在就是为了被继承,所以抽象类中的抽象方法不能被private、static、final修饰,否则无法被继承。抽象类虽然不能被实例化,但是它可以有构造方法,供子类创建对象时,初始化父类成员。
接口:接口是一种引用数据类型,可以看成是多个类的公共规范。定义接口需要借助interface关键字,定义方式与定义类的方式相似
共同点:
- 都不能被实例化。
- 都可以包含抽象方法。每个抽象方法前都隐藏着public abstract修饰。
- 都可以有默认实现的方法(Java 8 开始可以用
default关键字在接口中定义默认方法)。
区别:
- 接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。
- 一个类只能继承一个类,但是可以实现多个接口。
- 接口中的成员变量只能是
public static final类型的,不能被修改且必须有初始值,而抽象类的成员变量默认 default,可在子类中被重新定义,也可被重新赋值。 - 接口中不能有静态代码块(可以有静态成员方法)、实例代码块以及构造方法;而抽象类可以有构造方法,供子类创建对象时,初始化父类成员。
拷贝
浅拷贝:浅拷贝会在堆上创建一个新的对象(区别于引用拷贝的一点),不过,如果原对象内部的属性是引用类型的话,浅拷贝会直接复制内部对象的引用地址,也就是说拷贝对象和原对象共用同一个内部对象。
深拷贝:深拷贝会完全复制整个对象,包括这个对象所包含的内部对象。
String
线程安全性:String 中的对象是不可变的,也就可以理解为常量,线程安全。AbstractStringBuilder 是 StringBuilder 与 StringBuffer 的公共父类,定义了一些字符串的基本操作,如 expandCapacity、append、insert、indexOf 等公共方法。StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。
性能:每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。StringBuffer 每次都会对 StringBuffer 对象本身进行操作,而不是生成新的对象并改变对象引用。相同情况下使用 StringBuilder 相比使用 StringBuffer 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。
String不可变
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
private final char value[];
//...
}
被 final 关键字修饰的类不能被继承,修饰的方法不能被重写,修饰的变量是基本数据类型则值不能改变,修饰的变量是引用类型则不能再指向其他对象。因此,final 关键字修饰的数组保存字符串并不是 String 不可变的根本原因,因为这个数组保存的字符串是可变的(final 修饰引用类型变量的情况)。
String 真正不可变有下面几点原因:
- 保存字符串的数组被
final修饰且为私有的,并且**String类没有提供/暴露修改这个字符串的方法。** String类被final修饰导致其不能被继承,进而避免了子类破坏String不可变。
在 Java 9 之后,String、StringBuilder 与 StringBuffer 的实现改用 byte 数组存储字符串。
public final class String implements java.io.Serializable,Comparable<String>, CharSequence {
// @Stable 注解表示变量最多被修改一次,称为“稳定的”。
@Stable
private final byte[] value;
}
abstract class AbstractStringBuilder implements Appendable, CharSequence {
byte[] value;
}
新版的 String 其实支持两个编码方案:Latin-1 和 UTF-16。如果字符串中包含的汉字没有超过 Latin-1 可表示范围内的字符,那就会使用 Latin-1 作为编码方案。Latin-1 编码方案下,byte 占一个字节(8 位),char 占用 2 个字节(16),byte 相较 char 节省一半的内存空间。
字符串常量池
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,用于存储字符串常量,主要目的是为了避免字符串的重复创建。
// 在字符串常量池中创建字符串对象 ab
// 将字符串对象 ab 的引用赋值给 aa -- aa是在栈上存储的ab对象的引用
String aa = "ab";
// 直接返回字符串常量池中字符串对象 ab,赋值给引用 bb -- 此时不创建任何对象
String bb = "ab";
System.out.println(aa==bb); // true
除了使用双引号创建字符串会自动放入常量池外,还可以使用 String 类的 intern() 方法手动将字符串添加到常量池中。intern() 方法会先检查常量池中是否已经存在该字符串,如果存在则返回常量池中的引用;如果不存在,则将该字符串添加到常量池中,并返回其引用。intern() 方法的主要作用是确保字符串引用在常量池中的唯一性。
public class InternExample {
public static void main(String[] args) {
String str1 = new String("hello");
String str2 = str1.intern();
String str3 = "hello";
System.out.println(str2 == str3); // 输出 true,因为 str2 和 str3 都引用常量池中的 "hello"
}
}
位置变化:
在JDK1.7前,运行时常量池+字符串常量池是存放在方法区中,HotSpot VM对方法区的实现称为永久代。
- 方法区是各个线程共享的内存区域,是用于存储已经被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
- 很多人会把方法区称为
永久代,其实本质上是不等价的,只不过HotSpot虚拟机设计团队是选择把GC分代收集扩展到了方法区,使用永久代来代替实现方法区。其实,在方法区中的垃圾收集行为还是比较少的,这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,但是这个区域的回收总是不尽如人意的,如果该区域回收不完全就会出现内存泄露。
在JDK1.7中,字符串常量池从方法区移到堆中,运行时常量池保留在方法区中。
需要注意的是,永久代的大小是有限的,并且很难准确地确定一个应用程序需要多少永久代空间。如果我们在应用程序中使用了大量的类、方法、常量等静态数据,就有可能导致永久代空间不足。这种情况下,JVM 就会抛出 OutOfMemoryError 错误。
因此,从 Java 7 开始,为了解决永久代空间不足的问题,将字符串常量池从永久代中移动到堆中。这个改变也是为了更好地支持动态语言的运行时特性。
在JDK1.8中,HotSpot移除永久代,使用元空间代替(也就是元空间成为了对“方法区”概念的实现),此时字符串常量池依然保留在堆中,运行时常量池保留在方法区(元空间)中,JVM内存变成了直接内存。
常量折叠:常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中,这是 Javac 编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。
对于 String str3 = "str" + "ing"; 编译器会给你优化成 String str3 = "string"; 。
并不是所有的常量都会进行折叠,只有编译器在程序编译期就可以确定值的常量才可以:
- 基本数据类型(
byte、boolean、short、char、int、float、long、double)以及字符串常量。 final修饰的基本数据类型和字符串变量- 字符串通过 “+”拼接得到的字符串、基本数据类型之间算数运算(加减乘除)、基本数据类型的位运算(<<、>>、>>> )
引用的值在程序编译期是无法确定的,编译器无法对其进行优化。
对象引用和“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。
异常
Exception :程序本身可以处理的异常,可以通过 catch 来进行捕获。Exception 又可以分为 Checked Exception (受检查异常,必须处理) 和 Unchecked Exception (不受检查异常,可以不处理)。
Checked Exception 即 受检查异常 ,Java 代码在编译过程中,如果受检查异常没有被
catch或者throws关键字处理的话,就没办法通过编译。Unchecked Exception 即 不受检查异常 ,Java 代码在编译过程中 ,我们即使不处理不受检查异常也可以正常通过编译。
RuntimeException及其子类都统称为非受检查异常,常见的有NullPointerException(空指针错误)IllegalArgumentException(参数错误比如方法入参类型错误)NumberFormatException(字符串转换为数字格式错误,IllegalArgumentException的子类)ArrayIndexOutOfBoundsException(数组越界错误)ClassCastException(类型转换错误)ArithmeticException(算术错误)SecurityException(安全错误比如权限不够)UnsupportedOperationException(不支持的操作错误比如重复创建同一用户)
**Error**:Error 属于程序无法处理的错误 ,不建议通过catch捕获 。例如 Java 虚拟机运行错误(Virtual MachineError)、虚拟机内存不够错误(OutOfMemoryError)、类定义错误(NoClassDefFoundError)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。
泛型
泛型,即“参数化类型”。在方法定义时,将方法签名中的形参的数据类型也设置为参数(也可称之为类型参数),在调用该方法时再从外部传入一个具体的数据类型和变量。泛型的本质是为了将类型参数化, 也就是说在泛型使用过程中,数据类型被设置为一个参数,在使用时再从外部传入一个数据类型;而一旦传入了具体的数据类型后,传入变量(实参)的数据类型如果不匹配,编译器就会直接报错。这种参数化类型可以用在类、接口和方法中,分别被称为泛型类、泛型接口、泛型方法。
- 泛型提供了一种扩展能力,更符合面向对象开发的软件编程宗旨。
- 泛型提高了程序代码的可读性。在定义泛型阶段(类、接口、方法)或者对象实例化阶段,由于 < 类型参数 > 需要在代码中显式地编写,所以程序员能够快速猜测出代码所要操作的数据类型,提高了代码可读性。
泛型类
类型参数用于类的定义中,则该类被称为泛型类。通过泛型可以完成对一组类的操作对外开放相同的接口。最典型的就是各种容器类,如:List、Set、Map等。
public class Generic<T> {
// key 这个成员变量的数据类型为 T, T 的类型由外部传入
private T key;
// 泛型构造方法形参 key 的类型也为 T,T 的类型由外部传入
public Generic(T key) {
this.key = key;
}
// 泛型方法 getKey 的返回值类型为 T,T 的类型由外部指定
public T getKey(){
return key;
}
}
泛型类中的静态方法和静态变量不可以使用泛型类所声明的类型参数
- 泛型类中的类型参数的确定是在创建泛型类对象的时候(例如 ArrayList< Integer >)。
- 而静态变量和静态方法在类加载时已经初始化,直接使用类名调用;在泛型类的类型参数未确定时,静态成员有可能被调用,因此泛型类的类型参数是不能在静态成员中使用的。
- 静态泛型方法中可以使用自身的方法签名中新定义的类型参数(即泛型方法),而不能使用泛型类中定义的类型参数。
泛型接口
public interface Inter<T> {
public abstract void show(T t) ;
}
泛型接口中的类型参数,在该接口被继承或者被实现时确定。
泛型方法
当在一个方法签名中的返回值前面声明了一个 < T > 时,该方法就被声明为一个泛型方法。< T >表明该方法声明了一个类型参数 T,并且这个类型参数 T 只能在该方法中使用。当然,泛型方法中也可以使用泛型类中定义的泛型参数。
public class Test<U> {
// 该方法只是使用了泛型类定义的类型参数,不是泛型方法
public void testMethod(U u){
System.out.println(u);
}
// <T> 真正声明了下面的方法是一个泛型方法
public <T> T testMethod1(T t){
return t;
}
}
泛型类中定义的类型参数和泛型方法中定义的类型参数是相互独立的,它们一点关系都没有。也就是说,泛型方法始终以自己声明的类型参数为准。
为了避免混淆,如果在一个泛型类中存在泛型方法,那么两者的类型参数最好不要同名。
在静态成员中不能使用泛型类定义的类型参数,但我们可以将静态成员方法定义为一个泛型方法。
类型擦除
泛型的本质是将数据类型参数化,它通过擦除的方式来实现,即编译器会在编译期间擦除代码中的所有泛型语法并相应的做出一些类型转换动作。
换而言之,泛型信息只存在于代码编译阶段,在代码编译结束后,与泛型相关的信息会被擦除掉,专业术语叫做类型擦除。也就是说,成功编译过后的 class 文件中不包含任何泛型信息,泛型信息不会进入到运行时阶段。
public class Caculate<T> {
private T num;
}
// 将这个泛型类反编译, 结果如下
public class Caculate {
public Caculate() {}// 默认构造器,不用管
private Object num;// T 被替换为 Object 类型
}
- 可以发现编译器
擦除了 Caculate 类后面的泛型标识 < T >,并且将 num 的数据类型替换为 Object 类型,而替换了 T 的数据类型我们称之为原始数据类型。
那么是不是所有的类型参数被擦除后都以 Object 类进行替换呢?
- 答案是否定的,大部分情况下,类型参数 T 被擦除后都会以 Object 类进行替换;而有一种情况则不是,那就是使用到了 extends 和 super 语法的
有界类型参数(即泛型通配符)。
在现实编码中,确实有这样的需求,希望泛型能够处理某一类型范围内的类型参数,比如某个泛型类和它的子类,为此 Java 引入了泛型通配符这个概念。
- :被称作无限定的通配符。**代表了任何一种数据类型。**
- :被称作有上界的通配符。 **逻辑上表示类型参数的范围是 T 和 T 的子类。**
- :被称作有下界的通配符。 **逻辑上表示类型参数的范围是 T 和 T 的超类。**
Object 本身也算是一种数据类型,但却不能代表任何一种数据类型,所以 ArrayList< Object > 和 ArrayList<?>的含义是不同的,前者类型是 Object,也就是继承树的最高父类,而后者的类型完全是未知的;ArrayList<?> 是 ArrayList< Object > 逻辑上的父类。
ArrayList< Integer > 和 ArrayList< Number > 之间不存在继承关系。而引入上界通配符的概念后,我们便可以在逻辑上将 ArrayList<? extends Number> 看做是 ArrayList< Integer > 的父类,但实质上它们之间没有继承关系。
ArrayList<? super Integer> 在逻辑上表示为 Integer 类以及 Integer 类的所有父类,它可以代表 ArrayList< Integer>、ArrayList< Number >、 ArrayList< Object >中的某一个集合,但实质上它们之间没有继承关系。
public class Caculate<T extends Number> {
private T num;
}
public class Caculate {
public Caculate() {}// 默认构造器,不用管
private Number num;
}
- 使用到了 extends 语法的类型参数 T 被擦除后会替换为 Number 而不再是 Object。
- extends 和 super 是一个限定类型参数边界的语法,extends 限定 T 只能是 Number 或者是 Number 的子类。 也就是说,在创建 Caculate 类对象的时候,尖括号 <> 中只能传入 Number 类或者 Number 的子类的数据类型,所以在创建 Caculate 类对象时无论传入什么数据类型,Number 都是其父类,于是可以使用 Number 类作为 T 的原始数据类型,进行类型擦除并替换。
原理
泛型信息被擦除了,如何保证我们在集合中只添加指定的数据类型的对象呢?
- 其实在创建一个泛型类的对象时, Java 编译器是先检查代码中传入 < T > 的数据类型,并记录下来,然后再对代码进行编译,
编译的同时进行类型擦除;如果需要对被擦除了泛型信息的对象进行操作,编译器会自动将对象进行类型转换。
可以把泛型的类型安全检查机制和类型擦除想象成演唱会的验票机制:以 ArrayList< Integer> 泛型集合为例。
当我们在创建一个 ArrayList< Integer > 泛型集合的时候,ArrayList 可以看作是演唱会场馆,而< T >就是场馆的验票系统,Integer 是验票系统设置的门票类型;
当验票系统设置好为< Integer >后,只有持有 Integer 门票的人才可以通过验票系统,进入演唱会场馆(集合)中;若是未持有 Integer 门票的人想进场,则验票系统会发出警告(编译器报错)。
在通过验票系统时,门票会被收掉(类型擦除),但场馆后台(JVM)会记录下观众信息(泛型信息)。
进场后的观众变成了没有门票的普通人(原始数据类型)。但是,在需要查看观众的信息时(操作对象),场馆后台可以找到记录的观众信息(编译器会自动将对象进行类型转换)。
public class GenericType {
public static void main(String[] args) {
ArrayList<Integer> arrayInteger = new ArrayList<Integer>();// 设置验票系统
arrayInteger.add(111);// 观众进场,验票系统验票,门票会被收走(类型擦除)
Integer n = arrayInteger.get(0);// 获取观众信息,编译器会进行强制类型转换
System.out.println(n);
}
}
擦除 ArrayList< Integer > 的泛型信息后,get() 方法的返回值将返回 Object 类型,但编译器会自动插入 Integer 的强制类型转换。也就是说,编译器把 get() 方法调用翻译为两条字节码指令:
- 对原始方法 get() 的调用,返回的是 Object 类型;
- 将返回的 Object 类型强制转换为 Integer 类型;
项目中哪里用到了泛型
- 自定义接口通用返回结果
CommonResult<T>通过参数T可根据具体的返回类型动态指定结果的数据类型 - 定义
Excel处理类ExcelUtil<T>用于动态指定Excel导出的数据类型 - 构建集合工具类(参考
Collections中的sort,binarySearch方法)。
反射
反射 (Reflection) 是 Java 的特征之一,它允许运行中的 Java 程序获取自身的信息,并且可以操作类或对象的内部属性。通过反射你可以获取任意一个类的所有属性和方法,你还可以调用这些反射可以让我们的代码更加灵活、为各种框架提供开箱即用的功能提供了便利。
不过,反射让我们在运行时有了分析操作类的能力的同时,也增加了安全问题,比如可以无视泛型参数的安全检查(泛型参数的安全检查发生在编译时)。
使用的前提条件:必须先得到代表的字节码的Class,Class类用于表示.class文件(字节码),一切反射的操作都是从Class对象开始
反射就是把java类中的各种成分映射成一个个的Java对象:在 Java 中,当程序启动时,类加载器会将 .class 文件加载到内存中,并创建对应的 Class 对象。每个类在 JVM 中都有且仅有一个对应的 Class 对象,它包含了该类的所有信息,如类的名称、父类、接口、字段、方法等。
例如:一个类有:成员变量、方法、构造方法、包等等信息,利用反射技术可以对一个类进行解剖,把各个组成部分映射成一个个对象。
作用
- 运行时动态获取类的信息:在编写代码时,对于类的信息是必须在编译时确定的,但在运行时,有时需要根据某些条件,动态获取某个类的信息,这时就可以使用Java中的反射机制。
- 动态生成对象:反射机制可以在运行时生成对象,这样就可以根据参数的不同,动态的创建不同的类的实例对象。
- 动态调用方法:通过反射机制可以调用类中的方法,不论这些方法是否是公共的,也不论这些方法的参数个数和类型是什么,反射机制都具有这样的能力。
- 动态修改属性:利用反射机制可以获取到类中的所有成员变量,并可以对其进行修改。
- 实现动态代理:利用反射机制可以实现代理模式,通过代理对象完成原对象对某些方法的调用,同时也可以在这些方法的调用前后做一些额外的处理。
Spring/Spring Boot、MyBatis 等等框架中都大量使用了反射机制。这些框架中也大量使用了动态代理,而动态代理的实现也依赖反射。
注解与反射
为什么你使用 Spring 的时候 ,一个@Component注解就声明了一个类为 Spring Bean 呢?为什么你通过一个 @Value注解就读取到配置文件中的值呢?究竟是怎么起作用的呢?这些都是因为你可以基于反射分析类,然后获取到类/属性/方法/方法的参数上的注解。你获取到注解之后,就可以做进一步的处理。
Annotation (注解) 是 Java5 开始引入的新特性,可以看作是一种特殊的注释,主要用于修饰类、方法或者变量,提供某些信息供程序在编译或者运行时使用。注解的引入主要是为了简化某些编程模式和减轻开发者的负担。例如,它们可以用来自动生成代码、序列化/反序列化数据、配置框架和处理权限。
注解本质是一个继承了Annotation 的特殊接口。基本语法:定义一个注解类似于定义一个接口,但是在关键字 interface 前加上 @ 符号。例如定义一个简单的注解 @MyAnnotation:
public @interface MyAnnotation {
String value();
}
注解本身只是一种标记,它不会自动执行任何操作。要使注解发挥作用,需要在运行时通过反射机制来读取和处理注解信息。具体步骤如下:
- 获取
Class对象:通过类名、对象实例等方式获取目标类的Class对象。 - 获取注解信息:使用
Class对象、Method对象、Field对象等的方法来检查是否存在特定的注解,并获取注解的实例。 - 处理注解信息:根据注解的属性值和类型,执行相应的逻辑。
注解只有被解析之后才会生效,常见的解析方法有两种:
- 编译期直接扫描:编译器在编译 Java 代码的时候扫描对应的注解并处理,比如某个方法使用
@Override注解,编译器在编译的时候就会检测当前的方法是否重写了父类对应的方法。 - 运行期通过反射处理:像框架中自带的注解(比如 Spring 框架的
@Value、@Component)都是通过反射来进行处理的
反射机制使得注解可以在运行时动态地应用于不同的类、方法和字段,而不需要在编译时就确定具体的使用位置。这大大增强了注解的灵活性和可扩展性。例如,在 Spring 框架中,通过反射和注解的结合,可以实现依赖注入、面向切面编程等功能,使得代码更加简洁和易于维护。
注解可以为反射操作提供额外的元数据信息,使得反射在处理类和对象时能够更加智能和灵活。例如,通过注解可以指定方法的执行顺序、字段的验证规则等,反射可以根据这些注解信息来执行相应的操作。
示例:
import java.lang.reflect.Method;
// 定义注解
@interface MyAnnotation {
String value() default "";
}
// 使用注解
class MyClass {
@MyAnnotation("Hello, Annotation!")
public void myMethod() {
System.out.println("This is my method.");
}
}
// 处理注解
public class AnnotationProcessor {
public static void main(String[] args) throws NoSuchMethodException {
Class<?> clazz = MyClass.class;
Method method = clazz.getMethod("myMethod");
// 根据反射获取类方法,判断方法是否带有MyAnnotation注解
if (method.isAnnotationPresent(MyAnnotation.class)) {
MyAnnotation annotation = method.getAnnotation(MyAnnotation.class);
// 获取注解实例,读取属性
System.out.println(annotation.value());
try { // 调用类方法
method.invoke(clazz.getDeclaredConstructor().newInstance());
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
在上述代码中,通过反射获取 MyClass 类的 myMethod 方法,检查该方法是否带有 MyAnnotation 注解。如果有,则获取注解的实例并读取其属性值,同时调用该方法。
获取class对象
Class 类对象将一个类的方法、变量等信息告诉运行的程序。Java 提供了四种方式获取 Class 对象:
// 知道具体类的情况下可以使用 -- 类名.class
Class clazz = TargetObject.class;
// 通过 Class.forName()传入类的全路径获取
Class clazz1 = Class.forName("cn.javaguide.TargetObject");
// 通过对象实例instance.getClass()获取
TargetObject o = new TargetObject();
Class clazz2 = o.getClass();
// 通过类加载器xxxClassLoader.loadClass()传入类路径获取
ClassLoader.getSystemClassLoader().loadClass("cn.javaguide.TargetObject");
// 可以使用 Class 对象的 newInstance() 方法(在 Java 9 及以后版本已被弃用)或 Constructor 对象的 newInstance() 方法来创建对象
// 反射实例化 -- 对象.newInstance()
Class<?> targetClass = Class.forName("cn.javaguide.TargetObject");
TargetObject targetObject = (TargetObject) targetClass.newInstance();
// 使用无参构造函数创建对象
Person person1 = personClass.getDeclaredConstructor().newInstance();
// 使用有参构造函数创建对象
Constructor<Person> constructor = personClass.getDeclaredConstructor(String.class, int.class);
Person person2 = constructor.newInstance("Alice", 25);
- 获得类中属性相关的方法
| 方法 | 用途 |
|---|---|
| getField(String name) | 获得某个公有的属性对象 |
| getFields() | 获得所有公有的属性对象 |
| getDeclaredField(String name) | 获得某个属性对象 |
| getDeclaredFields() | 获得所有属性对象 |
- 获得类中注解相关的方法
| 方法 | 用途 |
|---|---|
| getAnnotation(Class annotationClass) | 返回该类中与参数类型匹配的公有注解对象 |
| getAnnotations() | 返回该类所有的公有注解对象 |
| getDeclaredAnnotation(Class annotationClass) | 返回该类中与参数类型匹配的所有注解对象 |
| getDeclaredAnnotations() | 返回该类所有的注解对象 |
- 获得类中构造器相关的方法
| 方法 | 用途 |
|---|---|
| getConstructor(Class…<?> parameterTypes) | 获得该类中与参数类型匹配的公有构造方法 |
| getConstructors() | 获得该类的所有公有构造方法 |
| getDeclaredConstructor(Class…<?> parameterTypes) | 获得该类中与参数类型匹配的构造方法 |
| getDeclaredConstructors() | 获得该类所有构造方法 |
- 获得类中方法相关的方法
| 方法 | 用途 |
|---|---|
| getMethod(String name, Class…<?> parameterTypes) | 获得该类某个公有的方法 |
| getMethods() | 获得该类所有公有的方法 |
| getDeclaredMethod(String name, Class…<?> parameterTypes) | 获得该类某个方法 |
| getDeclaredMethods() | 获得该类所有方法 |
序列化
如果我们需要持久化 Java 对象比如将 Java 对象保存在文件中,或者在网络传输 Java 对象,这些场景都需要用到序列化。
- 序列化:将数据结构或对象转换成二进制字节流(或JSON、XML等存储格式)的过程。这些字节序列可以被存储到文件、数据库中,也可以通过网络传输到其他地方。序列化的主要目的是实现对象的持久化和远程通信。
- 反序列化:将在序列化过程中所生成的二进制字节流转换成原始数据结构或者对象的过程
序列化的主要目的是通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中。
Java 序列化机制基于对象的类信息和对象的状态(即对象的字段值)来实现。在序列化过程中,Java 会将对象的类信息(包括类名、字段类型等)和对象的字段值按照一定的格式转换为字节序列。在反序列化过程中,Java 会根据字节序列中的类信息加载相应的类,并根据字段值恢复对象的状态。
应用场景
- 对象持久化:将对象保存到文件或数据库中,以便在程序下次启动时可以恢复对象的状态。
- 远程通信:在分布式系统中,通过网络传输对象时,需要将对象序列化后发送到远程节点,然后在远程节点进行反序列化。
- 缓存:将对象序列化后存储在缓存中,如 Redis 等,以提高系统的性能。
JDK序列化
- 被序列化的类必须实现
java.io.Serializable接口,该接口是一个标记接口,没有任何方法,只是用于标识该类的对象可以被序列化。 - 提供一个
private static final long serialVersionUID字段,用于标识类的版本号,确保序列化和反序列化时使用的是同一个版本的类。如果不提供,Java 会根据类的结构自动生成一个serialVersionUID,但在类结构发生变化时可能会导致反序列化失败。
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@ToString
public class RpcRequest implements Serializable {
private static final long serialVersionUID = 1905122041950251207L;
private String requestId;
private String interfaceName;
private String methodName;
private Object[] parameters;
private Class<?>[] paramTypes;
private RpcMessageTypeEnum rpcMessageTypeEnum;
}
序列化号 serialVersionUID 属于版本控制的作用。反序列化时,会检查 serialVersionUID 是否和当前类的 serialVersionUID 一致。如果 serialVersionUID 不一致则会抛出 InvalidClassException 异常。强烈推荐每个序列化类都手动指定其 serialVersionUID,如果不手动指定,那么编译器会动态生成默认的 serialVersionUID。
static修饰的变量是静态变量,属于类而非类的实例,本身是不会被序列化的。然而,serialVersionUID是一个特例,serialVersionUID的序列化做了特殊处理。当一个对象被序列化时,serialVersionUID会被写入到序列化的二进制流中;在反序列化时,也会解析它并做一致性判断,以此来验证序列化对象的版本一致性。
如果有些字段不想进行序列化怎么办?
对于不想进行序列化的变量,可以使用 transient 关键字修饰。
transient 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复。
关于 transient 还有几点注意:
transient只能修饰变量,不能修饰类和方法。transient修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰int类型,那么反序列后结果就是0。static变量因为不属于任何对象(Object),所以无论有没有transient关键字修饰,均不会被序列化。
Kryo
JDK 自带的序列化方式一般不会用 ,因为序列化效率低并且存在安全问题。比较常用的序列化协议有 Hessian、Kryo、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议。
像 JSON 和 XML 这种属于文本类序列化方式。虽然可读性比较好,但是性能较差,一般不会选择。
Kryo 是一个高性能的序列化/反序列化工具,由于其变长存储特性并使用了字节码生成机制,拥有较高的运行速度和较小的字节码体积。
/**
* Kryo serialization class, Kryo serialization efficiency is very high, but only compatible with Java language
*
* @author shuang.kou
* @createTime 2020年05月13日 19:29:00
*/
@Slf4j
public class KryoSerializer implements Serializer {
/**
* Because Kryo is not thread safe. So, use ThreadLocal to store Kryo objects
*/
private final ThreadLocal<Kryo> kryoThreadLocal = ThreadLocal.withInitial(() -> {
Kryo kryo = new Kryo();
kryo.register(RpcResponse.class);
kryo.register(RpcRequest.class);
return kryo;
});
@Override
public byte[] serialize(Object obj) {
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
Output output = new Output(byteArrayOutputStream)) {
Kryo kryo = kryoThreadLocal.get();
// Object->byte:将对象序列化为byte数组
kryo.writeObject(output, obj);
kryoThreadLocal.remove();
return output.toBytes();
} catch (Exception e) {
throw new SerializeException("Serialization failed");
}
}
@Override
public <T> T deserialize(byte[] bytes, Class<T> clazz) {
try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
Input input = new Input(byteArrayInputStream)) {
Kryo kryo = kryoThreadLocal.get();
// byte->Object:从byte数组中反序列化出对象
Object o = kryo.readObject(input, clazz);
kryoThreadLocal.remove();
return clazz.cast(o);
} catch (Exception e) {
throw new SerializeException("Deserialization failed");
}
}
}
I/O流
IO 即 Input/Output,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。数据传输过程类似于水流,因此称为 IO 流。IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。
Java IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。
InputStream/Reader: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流。
字节流
InputStream
FileInputStream 是一个比较常用的字节输入流对象,可直接指定文件路径,可以直接读取单字节数据,也可以读取至字节数组中。
try (InputStream fis = new FileInputStream("input.txt")) {
System.out.println("Number of remaining bytes:"
+ fis.available()); // 返回输入流中可以读取的字节数。
int content;
long skip = fis.skip(2); // 忽略输入流中的 n 个字节 ,返回实际忽略的字节数。
System.out.println("The actual number of bytes skipped:" + skip);
System.out.print("The content read from file:");
while ((content = fis.read()) != -1) { // 返回输入流中下一个字节的数据。返回的值介于 0 到 255 之间。
// 如果未读取任何字节,则代码返回 -1 ,表示文件结束
System.out.print((char) content); // 将ascii码转为读到的字符
}
} catch (IOException e) {
e.printStackTrace();
}
输出:
Number of remaining bytes:11
The actual number of bytes skipped:2
The content read from file:JavaGuide
// 新建一个 BufferedInputStream 对象
BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("input.txt"));
// 读取文件的内容并复制到 String 对象中 读取输入流所有字节
String result = new String(bufferedInputStream.readAllBytes());
System.out.println(result);
FileInputStream fileInputStream = new FileInputStream("input.txt");
//必须将fileInputStream作为构造参数才能使用
DataInputStream dataInputStream = new DataInputStream(fileInputStream);
//可以读取任意具体的类型数据
dataInputStream.readBoolean();
dataInputStream.readInt();
dataInputStream.readUTF();
// ObjectInputStream 用于从输入流中读取 Java 对象(反序列化),ObjectOutputStream 用于将对象写入到输出流(序列化)。
ObjectInputStream input = new ObjectInputStream(new FileInputStream("object.data"));
MyClass object = (MyClass) input.readObject();
input.close();
OutputStream
try (FileOutputStream output = new FileOutputStream("output.txt")) {
byte[] array = "JavaGuide".getBytes();
output.write(array); // 将数组写入到输出流,等价于 write(b, 0, b.length)
} catch (IOException e) {
e.printStackTrace();
}
// 字节缓冲输出流
FileOutputStream fileOutputStream = new FileOutputStream("output.txt");
BufferedOutputStream bos = new BufferedOutputStream(fileOutputStream)
// 输出流
FileOutputStream fileOutputStream = new FileOutputStream("out.txt");
DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream);
// 输出任意数据类型
dataOutputStream.writeBoolean(true);
dataOutputStream.writeByte(1);
// 序列化,将对象写入到输出流
ObjectOutputStream output = new ObjectOutputStream(new FileOutputStream("file.txt")
Person person = new Person("Guide哥", "JavaGuide作者");
output.writeObject(person);
字符流
不管是文件读写还是网络发送接收,信息的最小存储单元都是字节,那为什么 I/O 流操作要分为字节流操作和字符流操作呢?
- 字符流是由 Java 虚拟机将字节转换得到的,这个过程比较耗时;
- 如果我们不知道编码类型的话,使用字节流的过程中很容易出现乱码问题。
- 所以, I/O 流就干脆提供了一个直接操作字符的接口,方便我们平时对字符进行流操作。
例如,如果你想从InputStream中读取字符,你需要考虑字符的编码方式。如果字符使用UTF-8编码,一个字符可能由一个或多个字节组成。因此,直接使用InputStream的read()方法可能无法完整地读取一个字符,因为它一次只读取一个字节。
要正确地从InputStream中读取字符,你可以使用Reader类及其子类,如InputStreamReader。Reader是字符输入流,专门用于读取字符。InputStreamReader是一个桥接类,它可以将字节流转换为字符流,同时指定字符编码。
1,ASCII码:一个英文字母(不分大小写)占一个字节的空间,一个中文汉字占两个字节的空间。
2,UTF-8编码:一个英文字符等于一个字节,一个中文(含繁体)等于三个字节。中文标点占三个字节,英文标点占一个字节
3,Unicode编码:一个英文等于两个字节,一个中文(含繁体)等于两个字节。中文标点占两个字节,英文标点占两个字节
4,GBK:英文占 1 字节,中文占 2 字节。
Reader
Reader用于从源头(通常是文件)读取数据(字符信息)到内存中,java.io.Reader抽象类是所有字符输入流的父类。
Reader 用于读取文本, InputStream 用于读取原始字节。
// 字节流转换为字符流的桥梁
public class InputStreamReader extends Reader {
}
// 用于读取字符文件
public class FileReader extends InputStreamReader {
}
try (FileReader fileReader = new FileReader("input.txt");) {
int content;
long skip = fileReader.skip(3);
System.out.println("The actual number of bytes skipped:" + skip);
System.out.print("The content read from file:");
while ((content = fileReader.read()) != -1) {
System.out.print((char) content);
}
} catch (IOException e) {
e.printStackTrace();
}
Writer
Writer用于将数据(字符信息)写入到目的地(通常是文件),java.io.Writer抽象类是所有字符输出流的父类。
// 字符流转换为字节流的桥梁
public class OutputStreamWriter extends Writer {
}
// 用于写入字符到文件
public class FileWriter extends OutputStreamWriter {
}
try (Writer output = new FileWriter("output.txt")) {
output.write("你好,我是Guide。");
} catch (IOException e) {
e.printStackTrace();
}
字节缓冲流
IO 操作是很消耗性能的,缓冲流将数据加载至缓冲区,一次性读取/写入多个字节,从而避免频繁的 IO 操作,提高流的传输效率。
字节缓冲流这里采用了装饰器模式来增强 InputStream 和OutputStream子类对象的功能。
举个例子,我们可以通过 BufferedInputStream(字节缓冲输入流)来增强 FileInputStream 的功能。
// 新建一个 BufferedInputStream 对象
BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("input.txt"));
字节流和字节缓冲流的性能差别主要体现在我们使用两者的时候都是调用 write(int b) 和 read() 这两个一次只读取一个字节的方法的时候。由于字节缓冲流内部有缓冲区(字节数组),因此,字节缓冲流会先将读取到的字节存放在缓存区,大幅减少 IO 次数,提高读取效率。
BufferedInputStream 内部维护了一个缓冲区,这个缓冲区实际就是一个字节数组
public
class BufferedInputStream extends FilterInputStream {
// 内部缓冲区数组
protected volatile byte buf[];
// 缓冲区的默认大小
private static int DEFAULT_BUFFER_SIZE = 8192;
// 使用默认的缓冲区大小
public BufferedInputStream(InputStream in) {
this(in, DEFAULT_BUFFER_SIZE);
}
// 自定义缓冲区大小
public BufferedInputStream(InputStream in, int size) {
super(in);
if (size <= 0) {
throw new IllegalArgumentException("Buffer size <= 0");
}
buf = new byte[size];
}
}
字节缓冲输出流
try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("output.txt"))) {
byte[] array = "JavaGuide".getBytes();
bos.write(array);
} catch (IOException e) {
e.printStackTrace();
}
字符缓冲流
BufferedReader (字符缓冲输入流)和 BufferedWriter(字符缓冲输出流)类似于 BufferedInputStream(字节缓冲输入流)和BufferedOutputStream(字节缓冲输入流),内部都维护了一个字节数组作为缓冲区。不过,前者主要是用来操作字符信息。
打印流
System.out 实际是用于获取一个 PrintStream 对象,print方法实际调用的是 PrintStream 对象的 write 方法。
PrintStream 属于字节打印流,与之对应的是 PrintWriter (字符打印流)。PrintStream 是 OutputStream 的子类,PrintWriter 是 Writer 的子类。
随机访问流
这里要介绍的随机访问流指的是支持随意跳转到文件的任意位置进行读写的 RandomAccessFile 。
RandomAccessFile 的构造方法如下,我们可以指定 mode(读写模式)。
// openAndDelete 参数默认为 false 表示打开文件并且这个文件不会被删除
public RandomAccessFile(File file, String mode)
throws FileNotFoundException {
this(file, mode, false);
}
// 私有方法
private RandomAccessFile(File file, String mode, boolean openAndDelete) throws FileNotFoundException{
// 省略大部分代码
}
RandomAccessFile 中有一个文件指针用来表示下一个将要被写入或者读取的字节所处的位置。我们可以通过 RandomAccessFile 的 seek(long pos) 方法来设置文件指针的偏移量(距文件开头 pos 个字节处)。如果想要获取文件指针当前的位置的话,可以使用 getFilePointer() 方法。
RandomAccessFile randomAccessFile = new RandomAccessFile(new File("input.txt"), "rw"); // 内容ABCDEFG
System.out.println("读取之前的偏移量:" + randomAccessFile.getFilePointer() + ",当前读取到的字符" + (char) randomAccessFile.read() + ",读取之后的偏移量:" + randomAccessFile.getFilePointer()); // 读取之前的偏移量:0,当前读取到的字符A,读取之后的偏移量:1
// 指针当前偏移量为 6
randomAccessFile.seek(6);
System.out.println("读取之前的偏移量:" + randomAccessFile.getFilePointer() + ",当前读取到的字符" + (char) randomAccessFile.read() + ",读取之后的偏移量:" + randomAccessFile.getFilePointer()); // 读取之前的偏移量:6,当前读取到的字符G,读取之后的偏移量:7
// 从偏移量 7 的位置开始往后写入字节数据
randomAccessFile.write(new byte[]{'H', 'I', 'J', 'K'}); // 文件内容变为 ABCDEFGHIJK
// 指针当前偏移量为 0,回到起始位置
randomAccessFile.seek(0);
System.out.println("读取之前的偏移量:" + randomAccessFile.getFilePointer() + ",当前读取到的字符" + (char) randomAccessFile.read() + ",读取之后的偏移量:" + randomAccessFile.getFilePointer());
RandomAccessFile 比较常见的一个应用就是实现大文件的 断点续传 。何谓断点续传?简单来说就是上传文件中途暂停或失败(比如遇到网络问题)之后,不需要重新上传,只需要上传那些未成功上传的文件分片即可。分片(先将文件切分成多个文件分片)上传是断点续传的基础。
IO模型
用户进程想要执行 IO 操作的话,必须通过 系统调用 来间接访问内核空间。
我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。
当应用程序发起 I/O 调用后,会经历两个步骤:
- 内核等待 I/O 设备准备好数据
- 内核将数据从内核空间拷贝到用户空间
Java 的 I/O 模型主要有三种:BIO(Blocking I/O,阻塞 I/O)、NIO(Non-blocking I/O,非阻塞 I/O)和 AIO(Asynchronous I/O,异步 I/O)。
BIO (Blocking I/O)
BIO 属于同步阻塞 IO 模型 。
同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。
在客户端连接数量不高的情况下,是没问题的。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。
NIO (Non-blocking/New I/O)
Java 中的 NIO 于 Java 1.4 中引入,对应 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它是支持面向缓冲的,基于通道的 I/O 操作方法。 对于高负载、高并发的(网络)应用,应使用 NIO 。Java 中的 NIO 可以看作是 I/O 多路复用模型。也有很多人认为,Java 中的 NIO 属于同步非阻塞 IO 模型。
同步非阻塞 IO 模型中,应用程序会一直发起 read 调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。相比于同步阻塞 IO 模型,同步非阻塞 IO 模型确实有了很大改进。通过轮询操作,避免了一直阻塞。
但是,这种 IO 模型同样存在问题:应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。
IO 多路复用模型中,线程首先发起 select 调用,询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起 read 调用。read 调用的过程(数据从内核空间 -> 用户空间)还是阻塞的。IO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。
Java 中的 NIO ,有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为 多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。
NIO采用了多路复用器(Selector)来实现非阻塞 I/O。一个线程可以管理多个连接,当某个连接有数据可读或可写时,Selector 会通知线程进行相应的处理。
- Channel(通道):类似于传统 I/O 中的流,但 Channel 是双向的,可以同时进行读写操作。常见的 Channel 有
FileChannel、SocketChannel、ServerSocketChannel等。 - Buffer(缓冲区):用于存储数据,所有的数据都必须先读到 Buffer 中,或者从 Buffer 中写入。常见的 Buffer 有
ByteBuffer、CharBuffer等。 - Selector(选择器):用于监听多个 Channel 的事件(如连接、读、写等),当某个 Channel 有事件发生时,Selector 会将其选中。
AIO (Asynchronous I/O)
AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。
异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
| I/O 模型 | 阻塞特性 | 线程管理 | 适用场景 |
|---|---|---|---|
| BIO | 阻塞直到处理完成 | 每个连接一个线程 | 连接数少且固定 |
| NIO | 非阻塞,selector监听多个channel | 一个线程管理多个连接 | 连接数多且连接时间短 |
| AIO | 异步,通知回调 | 异步回调,无需线程等待 | 连接数多且连接时间长 |
语法糖
语法糖(Syntactic sugar) 代指的是编程语言为了方便程序员开发程序而设计的一种特殊语法,这种语法对编程语言的功能并没有影响。实现相同的功能,基于语法糖写出来的代码往往更简单简洁且更易阅读。
不过,JVM 其实并不能识别语法糖,Java 语法糖要想被正确执行,需要先通过编译器进行解糖,也就是在程序编译阶段将其转换成 JVM 认识的基本语法。这也侧面说明,Java 中真正支持语法糖的是 Java 编译器而不是 JVM。如果你去看com.sun.tools.javac.main.JavaCompiler的源码,你会发现在compile()中有一个步骤就是调用desugar(),这个方法就是负责解语法糖的实现的。
Java 中最常用的语法糖主要有泛型、自动拆装箱、变长参数、枚举、内部类、增强 for 循环、try-with-resources 语法、lambda 表达式等。
增强for循环:
for (Student stu : students) {
if (stu.getId() == 2)
students.remove(stu);
}
会抛出ConcurrentModificationException异常。
Iterator 是工作在一个独立的线程中,并且拥有一个 mutex 锁。 Iterator 被创建之后会建立一个指向原来对象的单链索引表,当原来的对象数量发生变化时,这个索引表的内容不会同步改变,所以当索引指针往后移动的时候就找不到要迭代的对象,所以按照 fail-fast 原则 Iterator 会马上抛出java.util.ConcurrentModificationException异常。
所以 Iterator 在工作的时候是不允许被迭代的对象被改变的。但你可以使用 Iterator 本身的方法remove()来删除对象,**Iterator.remove() 方法会在删除当前迭代对象的同时维护索引的一致性。**
集合
Java 集合,也叫作容器,主要是由两大接口派生而来:一个是 Collection接口,主要用于存放单一元素;另一个是 Map 接口,主要用于存放键值对。对于Collection 接口,下面又有三个主要的子接口:List、Set 、 Queue。
List: 存储的元素是有序的、可重复的。Set: 存储的元素不可重复的。Queue: 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。Map: 使用键值对(key-value)存储,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。
简要概括
| 接口 | 集合类 | 底层数据结构 | 线程安全性 | 元素顺序 | 允许 null 值 | 查找效率 | 插入 / 删除效率 | 适用场景 |
|---|---|---|---|---|---|---|---|---|
List |
ArrayList |
动态数组 | 否 | 有序(插入顺序) | 允许 | 快,(通过索引) | 尾部插入快;中间或头部插入慢, | 需要频繁随机访问元素,插入 / 删除操作主要在尾部的场景 |
LinkedList |
双向链表 | 否 | 有序(插入顺序) | 允许 | 慢 | 头部、尾部插入删除快;中间插入删除需遍历 | 需要频繁在列表头部、尾部进行插入 / 删除操作的场景 | |
Vector |
动态数组 | 是 | 有序(插入顺序) | 允许 | 快,(通过索引) | 尾部插入快;中间或头部插入慢 | 线程安全,get、set、add 这些方法都加了 synchronized 关键字,执行效率比较低,所以现在已经很少用了 |
|
Stack |
动态数组 | 是 | 有序,后进先出 | Stack 执行效率比较低(方法上同样加了 synchronized 关键字) | ||||
Set |
HashSet |
HashMap(键存储元素,值为固定对象) |
否 | 无序 | 允许一个 | 快,平均 | 快,平均 | 需要存储不重复元素,不关心元素顺序的场景 |
LinkedHashSet |
哈希表 + 双向链表 | 否 | 有序(插入顺序) | 允许一个 | 快,平均 | 快,平均 | 需要存储不重复元素,且希望保持插入顺序的场景 | |
TreeSet |
红黑树 | 否 | 有序(自然顺序或指定比较器顺序) | 不允许 | 中 | 中 | 需要存储不重复元素,且需要元素按自然顺序或自定义顺序排序的场景 | |
Queue |
ArrayDeque |
循环数组 | 否 | 先进先出(FIFO) | 不允许 | - | 头部、尾部插入删除快 | 作为栈或队列使用,需要高效的双端操作场景 |
PriorityQueue |
堆(二叉堆) | 否 | 按元素优先级排序 | 不允许 | - | 插入 ,删除头部元素快 | 需要根据元素优先级进行排序和处理的场景 | |
Map |
HashMap |
哈希表(数组 + 链表 / 红黑树) | 否 | 无序 | 键允许一个 null,值允许多个 null | 快,平均 | 快,平均 | 存储键值对,不关心键的顺序,需要快速查找的场景 |
LinkedHashMap |
哈希表 + 双向链表 | 否 | 有序(插入顺序或访问顺序) | 键允许一个 null,值允许多个 null | 快,平均 | 快,平均 | 存储键值对,需要保持键的插入顺序或访问顺序的场景 | |
TreeMap |
红黑树 | 否 | 有序(键的自然顺序或指定比较器顺序) | 键不允许 null,值允许多个 null | 中 | 中 | 存储键值对,需要键按自然顺序或自定义顺序排序的场景 | |
Hashtable |
哈希表 | 是 | 无序 | 键和值都不允许 null | 快,平均 | 快,平均 | 需要线程安全且对性能要求不高的存储键值对场景 | |
ConcurrentHashMap |
分段锁(JDK 7)/ CAS + synchronized(JDK 8) | 是 | 无序 | 键和值都不允许 null | 快,平均 | 快,平均 | 高并发场景下存储键值对的场景 |
List
ArrayList
ArrayList 的底层是数组队列,相当于动态数组。与 Java 中的数组相比,它的容量能动态增长。在添加大量元素前,应用程序可以使用ensureCapacity操作来增加 ArrayList 实例的容量。这可以减少递增式再分配的数量。
ArrayList 继承于 AbstractList ,实现了 List, RandomAccess, Cloneable, java.io.Serializable 这些接口。
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable{
}
List: 表明它是一个列表,支持添加、删除、查找等操作,并且可以通过下标进行访问。RandomAccess:这是一个标志接口,表明实现这个接口的List集合是支持 快速随机访问 的。在ArrayList中,我们即可以通过元素的序号快速获取元素对象,这就是快速随机访问。Cloneable:表明它具有拷贝能力,可以进行深拷贝或浅拷贝操作。Serializable: 表明它可以进行序列化操作,也就是可以将对象转换为字节流进行持久化存储或网络传输,非常方便。
初始化
JDK1.8
// 默认初始容量为10
private static final int DEFAULT_CAPACITY = 10;
// 若初始化时传入参数new ArrayList(0),则创建空数组EMPTY_ELEMENTDATA
private static final Object[] EMPTY_ELEMENTDATA = {};
// 用于默认大小空实例的共享空数组实例。即无参构造函数,初始为空数组,添加第一个元素时容量变为DEFAULT_CAPACITY = 10
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
// 实际保存arraylist数据的数组
transient Object[] elementData;
private int size; // arraylist包含元素的个数
// 有参构造函数如下。无参函数 this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
//如果传入的参数大于0,创建initialCapacity大小的数组
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
//如果传入的参数等于0,创建空数组
this.elementData = EMPTY_ELEMENTDATA;
} else {
//其他情况,抛出异常
throw new IllegalArgumentException("Illegal Capacity: " +
initialCapacity);
}
}
扩容
ensureCapacity 这个方法 ArrayList 内部没有被调用过,是给用户调用的。
理论上来说,最好在向 ArrayList 添加大量元素之前用 ensureCapacity 方法,以减少增量重新分配的次数
public void ensureCapacity(int minCapacity)
- 函数内部判断elementdata数据数组是否是DEFAULTCAPACITY_EMPTY_ELEMENTDATA,如果是的话赋值minExpand=10,表示已有的最大容量是10,否则为0,表示在初始化传参的情况下,动态数组可以扩容为任意大小。
- 若minCapacity > minExpand,调用ensureExplicitCapacity(minCapacity); 以保证最小需求容量能够达到。
- 在ensureExplicitCapacity(minCapacity)内,若minCapacity>当前elementdata元素数组的大小,则调用grow(minCapacity)方法进行扩容。
/**
* 扩容:新容量扩大为Max(minCapacity, 1.5倍oldCapacity),若超出了预定义的最大array大小,则一次性扩容为MAX_VALUE
*/
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
private void grow(int minCapacity) {
// oldCapacity为旧容量,newCapacity为新容量
int oldCapacity = elementData.length;
//将oldCapacity 右移一位,其效果相当于oldCapacity /2,
//我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍,
int newCapacity = oldCapacity + (oldCapacity >> 1);
//然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量,
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
//再检查新容量是否超出了ArrayList所定义的最大容量,
//若超出了,则调用hugeCapacity()来比较minCapacity和 MAX_ARRAY_SIZE,
//如果minCapacity大于MAX_ARRAY_SIZE,则新容量则为Integer.MAX_VALUE,否则,新容量大小则为 MAX_ARRAY_SIZE。
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
插入删除时用到的扩容判断函数:
// 确保内部容量达到指定的最小容量。
private void ensureCapacityInternal(int minCapacity) {
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
// 根据给定的最小容量和当前数组元素来计算所需容量。
private static int calculateCapacity(Object[] elementData, int minCapacity) {
// 如果当前数组元素为空数组(初始情况),返回默认容量和最小容量中的较大值作为所需容量
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
// 否则直接返回最小容量
return minCapacity;
}
插入删除
对于插入:
- 头部插入:由于需要将所有元素都依次向后移动一个位置,因此时间复杂度是 O(n)。
- 尾部插入:当
ArrayList的容量未达到极限时,往列表末尾插入元素的时间复杂度是 O(1),因为它只需要在数组末尾添加一个元素即可;当容量已达到极限并且需要扩容时,则需要执行一次 O(n) 的操作将原数组复制到新的更大的数组中,然后再执行 O(1) 的操作添加元素。 - 指定位置插入:需要将目标位置之后的所有元素都向后移动一个位置,然后再把新元素放入指定位置。这个过程需要移动平均 n/2 个元素,因此时间复杂度为 O(n)。
// 尾部插入
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
//这里看到ArrayList添加元素的实质就相当于为数组赋值
elementData[size++] = e;
return true;
}
// 指定位置插入先调用 rangeCheckForAdd 对index进行界限检查;然后调用 ensureCapacityInternal 方法保证capacity足够大;
// 在容量判断方法内,若容量不足会进行扩容。若数组为空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA,在第一次插入时扩容到10
// 否则传入ensureExplicitCapacity的扩容参数是size+1,即当前数组大小+1。而在执行时实际newCapacity是原始1.5倍
// 将从index开始之后的所有成员后移一个位置;将element插入index位置;最后size加1
public void add(int index, E element) {
rangeCheckForAdd(index);
ensureCapacityInternal(size + 1); // Increments modCount!!
// arraycopy(Object src源数组, int srcPos起始位置, Object dest目标, int destPos目标位置, int length复制长度)
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
elementData[index] = element;
size++;
}
对于删除:
- 头部删除:由于需要将所有元素依次向前移动一个位置,因此时间复杂度是 O(n)。
- 尾部删除:当删除的元素位于列表末尾时,时间复杂度为 O(1)。
- 指定位置删除:需要将目标元素之后的所有元素向前移动一个位置以填补被删除的空白位置,因此需要移动平均 n/2 个元素,时间复杂度为 O(n)。
机制总结
初始化时,默认无参构造函数给elementData(保存ArrayList数据的数组)赋值DEFAULTCAPACITY_EMPTY_ELEMENTDATA={},也就是一个默认大小0的空实例。在第一次添加数据的时候才会真正分配容量DEFAULT_CAPACITY = 10。此后添加第2,3,,,一直到10个元素,minCapacity - elementData.length > 0都不成立,也就是现有的Object数组的长度都大于需要的最小数组长度,所以不会扩容。到第11个元素时,进入grow方法扩容,新的容量newCapacity = oldCapacity + (oldCapacity >> 1);也就是原始大小的1.5倍。
此外,外部方法 ensureCapacity可以供调用者手动传入 minCapacity,这个值会在grow方法中与newCapacity比较, 如果1.5倍的old 仍然小于需要的minCapacity,则更新newCapacity为minCapacity。
如果新容量大于 MAX_ARRAY_SIZE,进入(执行) hugeCapacity() 方法来比较 minCapacity 和 MAX_ARRAY_SIZE,如果 minCapacity 大于最大容量,则新容量则为Integer.MAX_VALUE,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 Integer.MAX_VALUE - 8。
LinkeadList
LinkedList 是一个基于双向链表实现的集合类,经常被拿来和 ArrayList 做比较。
不过,我们在项目中一般是不会使用到 LinkedList 的,需要用到 LinkedList 的场景几乎都可以使用 ArrayList 来代替,并且,性能通常会更好。
LinkedList 为什么不能实现 RandomAccess 接口?
RandomAccess 是一个标记接口,用来表明实现该接口的类支持随机访问(即可以通过索引快速访问元素)。由于 LinkedList 底层数据结构是链表,内存地址不连续,只能通过指针来定位,不支持随机快速访问,所以不能实现 RandomAccess 接口。
LinkedList 的类定义如下:
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{
// ...
}
LinkedList 继承了 AbstractSequentialList ,而 AbstractSequentialList 又继承于 AbstractList 。
阅读过 ArrayList 的源码我们就知道,ArrayList 同样继承了 AbstractList , 所以 LinkedList 会有大部分方法和 ArrayList 相似。
LinkedList 实现了以下接口:
List: 表明它是一个列表,支持添加、删除、查找等操作,并且可以通过下标进行访问。Deque:继承自Queue接口,具有双端队列的特性,支持从两端插入和删除元素,方便实现栈和队列等数据结构。Cloneable:表明它具有拷贝能力,可以进行深拷贝或浅拷贝操作。Serializable: 表明它可以进行序列化操作,也就是可以将对象转换为字节流进行持久化存储或网络传输,非常方便。
初始化
LinkedList 中的元素是通过 Node 定义的:
private static class Node<E> {
E item;// 节点值
Node<E> next; // 指向的下一个节点(后继节点)
Node<E> prev; // 指向的前一个节点(前驱结点)
// 初始化参数顺序分别是:前驱结点、本身节点值、后继节点
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
LinkedList 中有一个无参构造函数和一个有参构造函数。
// 创建一个空的链表对象
public LinkedList() {
}
// 接收一个集合类型作为参数,会创建一个与传入集合相同元素的链表对象
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);
}
插入删除
add(E e):尾部插入,复杂度O(1)。调用linkLast(E e)方法。维护last引用为最后一个节点,创建新节点,新节点prev为last,next为null。如果是第一个节点,还要将其赋值给first。如果不是,则让原始的last的next指向新节点。
// 将元素节点插入到链表尾部
void linkLast(E e) {
// 将最后一个元素赋值(引用传递)给节点 l
final Node<E> l = last;
// 创建节点,并指定节点前驱为链表尾节点 last,后继引用为空
final Node<E> newNode = new Node<>(l, e, null);
// 将 last 引用指向新节点
last = newNode;
// 判断尾节点是否为空
// 如果 l 是null 意味着这是第一次添加元素
if (l == null)
// 如果是第一次添加,将first赋值为新节点,此时链表只有一个元素
first = newNode;
else
// 如果不是第一次添加,将新节点赋值给l(添加前的最后一个元素)的next
l.next = newNode;
size++;
modCount++;
}
add(int index, E element):指定位置插入,需要移动平均 n/4 个元素,时间复杂度为 O(n)。调用linkBefore(element, node(index)),先移动指针,再修改指针- 先判断index是否是尾部,如果是则调用linkLast进行尾部插入(尾部插入要更新last指针,所以单独处理
- node(int index)函数会遍历找到要插入位置的元素。根据index位置从前往后或从尾向前找。
- linkBefore中将定位到的node的prev指向新节点,新节点prev指向node的prev,next指向node。如果node之前的前驱为空,则插入的新节点为第一个节点,赋值first,否则node的前驱的后继指向新节点。
删除:
removeFirst():删除并返回链表的第一个元素。内部调用unlinkFirst(first)方法:取出头节点用于返回。头节点item及next置空,帮助GC回收。first引用更新为next元素,如果next是空需要把last更新为null;否则将next的prev置null。removeLast():删除并返回链表的最后一个元素。内部调用unlinkLast(last)方法:取出尾节点。尾节点item及prev置空,上一个节点赋值last,如果上一个节点为null说明原来只有last一个元素,设置first为null;否则让上一节点的next更新为null。
private E unlinkLast(Node<E> l) {
// assert l == last && l != null;
final E element = l.item;
final Node<E> prev = l.prev;
l.item = null;
l.prev = null; // help GC
last = prev;
if (prev == null)
first = null;
else
prev.next = null;
size--;
modCount++;
return element;
}
remove(E e):删除链表中首次出现的指定元素,如果不存在该元素则返回 false。通过遍历链表来找要删除的元素位置,然后调用unlink(node)方法。remove(int index):删除指定索引处的元素,并返回该元素的值。先检查下标是否越界,然后调用unlink(node(idx))。- node(idx)是通过下标找到元素并返回Node元素。unlink(node)方法删除对应元素。
void clear():移除此链表中的所有元素。
核心机制
在定位第idx个元素时,调用node(index)方法,该方法通过比较索引值与链表 size 的一半大小来确定从链表头还是尾开始遍历。如果索引值小于 size 的一半,就从链表头开始遍历,反之从链表尾开始遍历。这样可以在较短的时间内找到目标节点,充分利用了双向链表的特性来提高效率。
unlink(x) 方法的逻辑如下:
- 首先获取待删除节点 x 的前驱和后继节点;
- 判断待删除节点是否为头节点或尾节点:
- 如果 x 是头节点(x的prev为null),则直接将 first 指向 x 的后继节点 next
- 如果 x 是尾节点(x的next为null),则将 last 指向 x 的前驱节点 prev
- 如果 x 不是头节点也不是尾节点,执行345断开链接并清除元素
- 将待删除节点 x 的前驱的后继指向待删除节点的后继 next,断开 x 和 x.prev 之间的链接;
- 将待删除节点 x 的后继的前驱指向待删除节点的前驱 prev,断开 x 和 x.next 之间的链接;
- 将待删除节点 x 的元素置空(方便GC回收),修改链表长度。
ArrayList vs LinkedList
是否保证线程安全:
ArrayList和LinkedList都是不同步的,也就是不保证线程安全;- 当多个线程同时对ArrayList进行修改操作时,可能会导致数据不一致或者出现异常。这是因为ArrayList的内部结构不是线程安全的,它没有提供对并发修改的支持。例如,当一个线程正在向ArrayList中添加元素,而另一个线程同时在删除元素,就有可能导致索引越界或者元素丢失的问题。
- 推荐使用并发集合类(例如
ConcurrentHashMap、CopyOnWriteArrayList等)或者手动实现线程安全的方法来提供安全的多线程操作支持。
底层数据结构:
ArrayList底层使用的是Object数组;LinkedList底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别)插入和删除是否受元素位置的影响:
ArrayList采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)方法的时候,ArrayList会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element)),时间复杂度就为 O(n)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。LinkedList采用链表存储,所以在头尾插入或者删除元素不受元素位置的影响(add(E e)、addFirst(E e)、addLast(E e)、removeFirst()、removeLast()),时间复杂度为 O(1),如果是要在指定位置i插入和删除元素的话(add(int index, E element),remove(Object o),remove(int index)), 时间复杂度为 O(n) ,因为需要先移动到指定位置再插入和删除。- 总结:ArrayList查询O(1),开头或指定位置插入删除O(n)。LinkedList查询O(n),插入删除自身操作O(1),所以在中间特定位置插入删除整体O(n)
是否支持快速随机访问:
LinkedList不支持高效的随机元素访问,而ArrayList(实现了RandomAccess接口) 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)。- LinkedList是双向链表,不能根据下标直接取元素;ArrayList是动态数组,所以支持快速随机访问。
内存空间占用:
ArrayList的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。
CopyOnWriteArrayList
在 JDK1.5 之前,如果想要使用并发安全的 List 只能选择 Vector。而 Vector 是一种老旧的集合,已经被淘汰。Vector 对于增删改查等方法基本都加了 synchronized,这种方式虽然能够保证同步,但这相当于对整个 Vector 加上了一把大锁,使得每个方法执行的时候都要去获得锁,导致性能非常低下。
JDK1.5 引入了 Java.util.concurrent(JUC)包,其中提供了很多线程安全且并发性能良好的容器,其中唯一的线程安全 List 实现就是 CopyOnWriteArrayList 。
对于大部分业务场景来说,读取操作往往是远大于写入操作的。由于读取操作不会对原有数据进行修改,因此,对于每次读取都进行加锁其实是一种资源浪费。相比之下,我们应该允许多个线程同时访问
List的内部数据,毕竟对于读取操作来说是安全的。
为了将读操作性能发挥到极致,CopyOnWriteArrayList 中的读取操作是完全无需加锁的。写入操作也不会阻塞读取操作,只有写写才会互斥。这样一来,读操作的性能就可以大幅度提升。
CopyOnWriteArrayList名字中的“Copy-On-Write”即写时复制,简称 COW,是线程安全的核心。
写入时复制(英语:Copy-on-write,简称 COW)是一种计算机程序设计领域的优化策略。其核心思想是,如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的。此作法主要的优点是如果调用者没有修改该资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源。
当需要修改( add,set、remove 等操作) CopyOnWriteArrayList 的内容时,不会直接修改原数组,而是会先创建底层数组的副本,对副本数组进行修改,修改完之后再将修改后的数组赋值给底层数组的引用,替换掉旧的数组,这样就可以保证写操作不会影响读操作了。写时复制机制非常适合读多写少的并发场景,能够极大地提高系统的并发性能。
缺点:
- 内存占用:每次写操作都需要复制一份原始数据,会占用额外的内存空间,在数据量比较大的情况下,可能会导致内存资源不足。
- 写操作开销:每一次写操作都需要复制一份原始数据,然后再进行修改和替换,所以写操作的开销相对较大,在写入比较频繁的场景下,性能可能会受到影响。
- 数据一致性问题:修改操作不会立即反映到最终结果中,还需要等待复制完成,这可能会导致一定的数据一致性问题。
核心机制
public class CopyOnWriteArrayList<E>
extends Object
implements List<E>, RandomAccess, Cloneable, Serializable
{
//...
}
实现list,randomaccess,cloneable,serializable,和arraylist一样
CopyOnWriteArrayList 的 add()方法有三个版本:
add(E e):在CopyOnWriteArrayList的尾部插入元素。add(int index, E element):在CopyOnWriteArrayList的指定位置插入元素。addIfAbsent(E e):如果指定元素不存在,那么添加该元素。如果成功添加元素则返回 true。
这里以add(E e)为例进行介绍:
// 插入元素到 CopyOnWriteArrayList 的尾部
public boolean add(E e) {
final ReentrantLock lock = this.lock;
// 加锁
lock.lock();
try {
// 获取原来的数组
Object[] elements = getArray();
// 原来数组的长度
int len = elements.length;
// 创建一个长度+1的新数组,并将原来数组的元素复制给新数组
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 元素放在新数组末尾
newElements[len] = e;
// array指向新数组
setArray(newElements);
return true;
} finally {
// 解锁
lock.unlock();
}
}
add方法内部用到了ReentrantLock加锁,保证了同步,避免了多线程写的时候会复制出多个副本出来。锁被final修饰保证了锁的内存地址肯定不会被修改,并且,释放锁的逻辑放在finally中,可以保证锁能被释放。- 每次写操作都需要通过
Arrays.copyOf复制底层数组,时间复杂度是 O(n) 的,且会占用额外的内存空间。因此,CopyOnWriteArrayList适用于读多写少的场景,在写操作不频繁且内存资源充足的情况下,可以提升系统的性能表现。
CopyOnWriteArrayList中并没有类似于ArrayList的grow()方法扩容的操作。
读取元素:CopyOnWriteArrayList 的读取操作是基于内部数组 array 并没有发生实际的修改,因此在读取操作时不需要进行同步控制和锁操作,可以保证数据的安全性。这种机制下,多个线程可以同时读取列表中的元素。不过,get方法是弱一致性的,在某些情况下可能读到旧的元素值。(比如,线程1读数据,线程2写数据,线程1取值,此时取值就是旧的值)
删除元素:
CopyOnWriteArrayList删除元素相关的方法一共有 4 个:
remove(int index):移除此列表中指定位置上的元素。将任何后续元素向左移动(从它们的索引中减去 1)。boolean remove(Object o):删除此列表中首次出现的指定元素,如果不存在该元素则返回 false。boolean removeAll(Collection<?> c):从此列表中删除指定集合中包含的所有元素。void clear():移除此列表中的所有元素。
public E remove(int index) {
// 获取可重入锁
final ReentrantLock lock = this.lock;
// 加锁
lock.lock();
try {
// 获取当前array数组
Object[] elements = getArray();
// 获取当前array长度
int len = elements.length;
//获取指定索引的元素(旧值)
E oldValue = get(elements, index);
int numMoved = len - index - 1;
// 判断删除的是否是最后一个元素
if (numMoved == 0)
// 如果删除的是最后一个元素,直接复制该元素前的所有元素到新的数组
setArray(Arrays.copyOf(elements, len - 1));
else {
// 分段复制,将index前的元素和index+1后的元素复制到新数组
// 新数组长度为旧数组长度-1
Object[] newElements = new Object[len - 1];
System.arraycopy(elements, 0, newElements, 0, index);
System.arraycopy(elements, index + 1, newElements, index,
numMoved);
// 将新数组赋值给array引用
setArray(newElements);
}
return oldValue;
} finally {
// 解锁
lock.unlock();
}
}
ArrayList vs Array
ArrayList 内部基于动态数组实现,比 Array(静态数组) 使用起来更加灵活:
ArrayList会根据实际存储的元素动态地扩容或缩容,而Array被创建之后就不能改变它的长度了。ArrayList允许你使用泛型来确保类型安全,Array则不可以。ArrayList中只能存储对象。对于基本类型数据,需要使用其对应的包装类(如 Integer、Double 等)。Array可以直接存储基本类型数据,也可以存储对象。ArrayList支持插入、删除、遍历等常见操作,并且提供了丰富的 API 操作方法,比如add()、remove()等。Array只是一个固定长度的数组,只能按照下标访问其中的元素,不具备动态添加、删除元素的能力。ArrayList创建时不需要指定大小,而Array创建时必须指定大小。
Set
无序性和不可重复性:
- 无序性不等于随机性 ,无序性是指存储的数据在底层数组中并非按照数组索引的顺序添加 ,而是根据数据的哈希值决定的。
- 所以HashSet/HashMap是无序的,而LinkedHashSet通过链表维护了插入和取出的顺序,是有序的
- 不可重复性是指添加的元素按照
equals()判断时 ,返回 false,需要同时重写equals()方法和hashCode()方法。
HashSet
HashSet 内部使用一个 HashMap 来存储元素。HashSet 中的元素被存储为 HashMap 的键,而 HashMap 的值则统一使用一个静态的 PRESENT 对象。
// HashMap用于存储操作,HashSet底层封装类对象
private transient HashMap<E,Object> map;
// HashSet是将元素存放在HashMap的key中,因此使用一个静态常量来充当HashMap的value值
private static final Object PRESENT = new Object();
// 返回集合中是否包含指定元素o
public boolean contains(Object o) {
return map.containsKey(o);
}
// 添加指定元素e
// 将e作为HashMap的key 常量PRESENT作为所有元素的value
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
// 移出指定元素o
public boolean remove(Object o) {
return map.remove(o)==PRESENT;
}
HashSet、LinkedHashSet vs TreeSet
HashSet、LinkedHashSet和TreeSet都是Set接口的实现类,都能保证元素唯一,并且都不是线程安全的。- 不安全的原因是因为HashMap不是线程安全的。在HashSet中,底层源码,其实就是一个HashMap,HashMap的key为HashSet中的值,而value为一个Object对象常量。
HashSet、LinkedHashSet和TreeSet的主要区别在于底层数据结构不同。HashSet的底层数据结构是哈希表(基于HashMap实现)。LinkedHashSet的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。TreeSet底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。- 底层数据结构不同又导致这三者的应用场景不同。
HashSet用于不需要保证元素插入和取出顺序的场景,LinkedHashSet用于保证元素的插入和取出顺序满足 FIFO 的场景,TreeSet用于支持对元素自定义排序规则的场景。
HashSet如何检查重复
当你把对象加入
HashSet时,HashSet会先计算对象的hashcode值来判断对象加入的位置,同时也会与其他加入的对象的hashcode值作比较,如果没有相符的hashcode,HashSet会假设对象没有重复出现。但是如果发现有相同hashcode值的对象,这时会调用equals()方法来检查hashcode相等的对象是否真的相同。如果两者相同,HashSet就不会让加入操作成功。
在 JDK1.8 中,HashSet的add()方法只是简单的调用了HashMap的put()方法,并且判断了一下返回值以确保是否有重复元素。实际上无论HashSet中是否已经存在了某元素,HashSet都会直接插入,只是会在add()方法的返回值处告诉我们插入前是否存在相同元素。
// Returns: true if this set did not already contain the specified element
// 返回值:当 set 中没有包含 add 的元素时返回真
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
Queue
ArrayDeque
ArrayDeque 允许在队列的两端(头部和尾部)进行快速的插入、删除和访问操作,底层数据结构为循环数组。既可以当作栈使用(后进先出,LIFO),也可以当作队列使用(先进先出,FIFO)。它不允许存储 null 元素,并且线程不安全,在单线程环境下使用。
当需要使用栈时,Java 已不推荐使用Stack,而是推荐使用更高效的ArrayDeque(双端队列),因为Stack的核心方法上都加了 synchronized 关键字以确保线程安全,当我们不需要线程安全(比如说单线程环境下)性能就会比较差。
ArrayDeque 又实现了 Deque 接口(Deque 又实现了 Queue 接口),因此,当我们需要使用队列的时候,也可以选择 ArrayDeque。
head指向首端第一个有效元素,tail指向尾端第一个可以插入元素的空位。因为是循环数组,所以head不一定总等于 0,tail也不一定总是比head大。
- 插入和删除操作:在队列的头部和尾部进行插入和删除操作的时间复杂度都是 O(1),因为只需要移动指针,不需要像链表那样修改节点的引用。
- 随机访问操作:支持随机访问,通过索引可以直接访问数组中的元素,时间复杂度为 。
- 扩容操作:当队列中的元素数量达到数组容量时,会触发扩容操作,新容量是原容量的两倍。扩容操作需要创建新数组并复制元素,会带来一定的性能开销,但平均情况下插入和删除操作的性能仍然较好。
public void addFirst(E e) {
if (e == null)//不允许放入null
throw new NullPointerException();
elements[head = (head - 1) & (elements.length - 1)] = e;//2.下标是否越界
if (head == tail)//1.空间是否够用
doubleCapacity();//扩容
}
private void doubleCapacity() {
assert head == tail;
int p = head;
int n = elements.length;
int r = n - p; // head右边元素的个数
int newCapacity = n << 1;//原空间的2倍
if (newCapacity < 0)
throw new IllegalStateException("Sorry, deque too big");
Object[] a = new Object[newCapacity];
System.arraycopy(elements, p, a, 0, r);//复制右半部分,对应上图中绿色部分
System.arraycopy(elements, 0, a, r, p);//复制左半部分,对应上图中灰色部分
elements = (E[])a;
head = 0;
tail = n;
}
空间问题是在插入之后解决的,因为tail总是指向下一个可插入的空位,也就意味着elements数组至少有一个空位,所以插入元素的时候不用考虑空间问题。
下标越界处理:head = (head - 1) & (elements.length - 1)就可以了,这段代码相当于取余,同时解决了head为负值的情况。
ArrayDeque 与 LinkedList
ArrayDeque 和 LinkedList 都实现了 Deque 接口,两者都具有队列的功能,但两者有什么区别呢?
ArrayDeque是基于可变长的数组和双指针来实现,而LinkedList则通过双向链表来实现。ArrayDeque不支持存储NULL数据,但LinkedList支持。ArrayDeque是在 JDK1.6 才被引入的,而LinkedList早在 JDK1.2 时就已经存在。ArrayDeque插入时可能存在扩容过程, 不过**均摊后的插入操作依然为 O(1)**。虽然LinkedList不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。- 从性能的角度上,选用
ArrayDeque来实现队列要比LinkedList更好。此外,ArrayDeque也可以用于实现栈。 ArrayDeque和LinkedList都不是线程安全的。如果在多线程环境下使用,需要进行额外的同步操作,或者使用线程安全的替代类,如ConcurrentLinkedDeque。
性能分析
因为ArrayDeque 的底层实现是数组,而 LinkedList 的底层实现是链表。数组是一段连续的内存空间,而链表是由多个节点组成的,每个节点存储数据和指向下一个节点的指针。因此,在使用 LinkedList 时,需要频繁进行内存分配和释放,而 ArrayDeque 在创建时就一次性分配了连续的内存空间,不需要频繁进行内存分配和释放,这样可以更好地利用 CPU 缓存,提高访问效率。
现代计算机CPU对于数据的局部性有很强的依赖,如果需要访问的数据在内存中是连续存储的,那么就可以利用CPU的缓存机制,提高访问效率。而当数据存储在不同的内存块里时,每次访问都需要从内存中读取,效率会受到影响。
PriorityQueue
PriorityQueue 是在 JDK1.5 中被引入的, 其与 Queue 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。
这里列举其相关的一些要点:
PriorityQueue利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据。- 小顶堆是一个完全二叉树,任何一个非叶子节点的权值,都不大于其左右子节点的权值。
- 完全二叉树:除了最后一层,其他层的节点数都是满的,最后一层的节点都靠左对齐。
- 完全二叉树的结构比较规则,可以使用数组存储。对于数组中索引为
i的元素,其左子节点的索引为2 * i + 1,右子节点的索引为2 * i + 2,父节点的索引为(i - 1) / 2。
PriorityQueue通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。PriorityQueue是非线程安全的,且不支持存储NULL和non-comparable的对象。PriorityQueue默认是小顶堆,但可以接收一个Comparator作为构造参数,从而来自定义元素优先级的先后。
10 ------ 0
/ \
20 15 ------ 1和2 = 2i+1和2i+2
/ \
30 40 ------ 3和4
// 存储元素的数组
transient Object[] queue;
// 队列中元素的数量
private int size = 0;
// 比较器,用于定义元素的排序规则
private final Comparator<? super E> comparator;
// 修改次数,用于快速失败机制
transient int modCount = 0;
offer
public boolean offer(E e) {
if (e == null) // 不允许放入null元素
throw new NullPointerException();
modCount++;
int i = size;
if (i >= queue.length)
grow(i + 1); // 自动扩容
size = i + 1;
if (i == 0) // 队列原来为空,这是插入的第一个元素
queue[0] = e;
else
siftUp(i, e);// 调整,维持堆的性质
return true;
}
// 将元素x插入到位置k,上浮操作维护堆的性质
private void siftUp(int k, E x) {
if (comparator != null) // 如果指定了比较器,则使用带有比较器的上浮方法
siftUpUsingComparator(k, x, queue, comparator);
else // 否则,使用基于元素自然顺序的上浮方法
siftUpComparable(k, x, queue);
}
private static <T> void siftUpComparable(int k, T x, Object[] es) {
// 将元素 x 转换为 Comparable 类型,以便进行比较
Comparable<? super T> key = (Comparable<? super T>) x;
while (k > 0) { // 从插入位置 k 开始,不断向上比较并交换,直到满足堆的性质
int parent = (k - 1) >>> 1; // parentNo = (nodeNo-1)/2
Object e = es[parent];
// 如果插入元素 x 大于或等于父节点的值,说明已经找到了合适的位置(满足小顶堆),停止上浮
if (key.compareTo((T) e) >= 0)
break;
es[k] = e; // 否则,将父节点的值下移到当前位置 k(满足x作为根,比原父节点e及其子节点小)
k = parent; // 更新当前位置 k 为父节点的位置,继续向上比较
}
es[k] = key;
}
private static <T> void siftUpUsingComparator(
int k, T x, Object[] es, Comparator<? super T> cmp) {
while (k > 0) {
int parent = (k - 1) >>> 1;
Object e = es[parent];
// 使用指定的比较器比较插入元素 x 和父节点的值
if (cmp.compare(x, (T) e) >= 0)
break;
es[k] = e;
k = parent;
}
es[k] = x;
}
poll
将元素x插入到位置k,实际使用时poll弹出堆顶权值最小的元素,然后siftDown(0, queue最后一个元素x)
private void siftDown(int k, E x) {
int half = size >>> 1;
while (k < half) {
//首先找到左右孩子中较小的那个,记录到c里,并用child记录其下标
int child = (k << 1) + 1;//leftNo = parentNo*2+1
Object c = queue[child];
int right = child + 1;
if (right < size &&
comparator.compare((E) c, (E) queue[right]) > 0)
c = queue[child = right];
if (comparator.compare(x, (E) c) <= 0) // x已经比孩子节点小了
break;
queue[k] = c;//然后用c取代原来的值
k = child;
}
queue[k] = x;
}
同理如果是remove(object o):
- 删除的是最后一个元素。直接删除即可,不需要调整。
- 删除的不是最后一个元素,从删除点开始以最后一个元素为参照调用一次
siftDown()即可。
BlockingQueue
BlockingQueue (阻塞队列)是一个接口,继承自 Queue。BlockingQueue阻塞的原因是其支持当队列没有元素时一直阻塞,直到有元素;还支持如果队列已满,一直等到队列可以放入新元素时再放入。
BlockingQueue 常用于生产者-消费者模型中,生产者线程会向队列中添加数据,而消费者线程会从队列中取出数据进行处理。
实现类:
Java 中常用的阻塞队列实现类有以下几种:
ArrayBlockingQueue:使用数组实现的有界阻塞队列。在创建时需要指定容量大小,并支持公平和非公平两种方式的锁访问机制。LinkedBlockingQueue:使用单向链表实现的可选有界阻塞队列。在创建时可以指定容量大小,如果不指定则默认为Integer.MAX_VALUE。和ArrayBlockingQueue不同的是, 它仅支持非公平的锁访问机制。PriorityBlockingQueue:支持优先级排序的无界阻塞队列。元素必须实现Comparable接口或者在构造函数中传入Comparator对象,并且不能插入 null 元素。SynchronousQueue:同步队列,是一种不存储元素的阻塞队列。每个插入操作都必须等待对应的删除操作,反之删除操作也必须等待插入操作。因此,SynchronousQueue通常用于线程之间的直接传递数据。DelayQueue:延迟队列,其中的元素只有到了其指定的延迟时间,才能够从队列中出队。
ArrayBlockingQueue 和 LinkedBlockingQueue
ArrayBlockingQueue 和 LinkedBlockingQueue 是 Java 并发包中常用的两种阻塞队列实现,它们都是线程安全的。它们之间存在下面这些区别:
- 底层实现:
ArrayBlockingQueue基于数组实现,而LinkedBlockingQueue基于链表实现。 - 是否有界:
ArrayBlockingQueue是有界队列,必须在创建时指定容量大小。LinkedBlockingQueue创建时可以不指定容量大小,默认是Integer.MAX_VALUE,也就是无界的。但也可以指定队列大小,从而成为有界的。 - 锁是否分离:
ArrayBlockingQueue中的锁是没有分离的,即生产和消费用的是同一个锁;LinkedBlockingQueue中的锁是分离的,即**生产用的是putLock,消费是takeLock**,这样可以防止生产者和消费者线程之间的锁争夺。 - 内存占用:
ArrayBlockingQueue需要提前分配数组内存,而LinkedBlockingQueue则是动态分配链表节点内存。这意味着,ArrayBlockingQueue在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而LinkedBlockingQueue则是根据元素的增加而逐渐占用内存空间。
Map
HashMap
HashMap 主要用来存放键值对,它基于哈希表的 Map 接口实现,是常用的 Java 集合之一,是非线程安全的。
JDK1.8 之前
JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列。HashMap 通过 key 的 hashcode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。
所谓扰动函数指的就是 HashMap 的 hash 方法。使用 hash 方法也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法 换句话说使用扰动函数之后可以减少碰撞。
JDK 1.8 HashMap 的 hash 方法源码:
JDK 1.8 的 hash 方法 相比于 JDK 1.7 hash 方法更加简化,但是原理不变。
JDK1.8 之后
相比于之前的版本, JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
TreeMap、TreeSet 以及 JDK1.8 之后的 HashMap 底层都用到了红黑树。红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。
HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。并且, HashMap 总是使用 2 的幂作为哈希表的大小。
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
// 序列号
private static final long serialVersionUID = 362498820763181265L;
// 默认的初始容量是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当桶(bucket)上的结点数大于等于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
// 当桶(bucket)上的结点数小于等于这个值时树转链表
static final int UNTREEIFY_THRESHOLD = 6;
// 桶中结构转化为红黑树对应的table的最小容量
static final int MIN_TREEIFY_CAPACITY = 64;
// =====存储元素的数组,总是2的幂次倍=====
transient Node<k,v>[] table;
// 一个包含了映射中所有键值对的集合视图
transient Set<map.entry<k,v>> entrySet;
// 存放元素的个数,注意这个不等于数组的长度。
transient int size;
// 每次扩容和更改map结构的计数器
transient int modCount;
// 阈值(容量*负载因子) 当实际大小超过阈值时,会进行扩容
int threshold;
// 负载因子
final float loadFactor;
}
// Node节点类,继承自 Map.Entry<K,V>
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;// 哈希值,存放元素到hashmap中时用来与其他元素hash值比较
final K key;//键
V value;//值
// 指向下一个节点->链式结构
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
// 重写hashCode()方法
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
// 重写 equals() 方法
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
// 树节点类 -- 红黑树
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // 父
TreeNode<K,V> left; // 左
TreeNode<K,V> right; // 右
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red; // 判断颜色
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
// 返回根节点
final TreeNode<K,V> root() {
for (TreeNode<K,V> r = this, p;;) {
if ((p = r.parent) == null)
return r;
r = p;
}
- loadFactor 负载因子
- loadFactor 负载因子是控制数组存放数据的疏密程度,loadFactor 越趋近于 1,那么 数组中能存放的数据(entry)也就越多(要达到临界值threshold = capacity * loadFactor的时候才会扩容),也就越密,也就是会让链表的长度增加(因为要很久才扩容,这段数组本身很密,冲突的数据也多,链表就长),loadFactor 越小,也就是趋近于 0,数组中存放的数据(entry)也就越少,也就越稀疏。
- loadFactor 太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactor 的默认值为 0.75f 是官方给出的一个比较好的临界值。
- 给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量超过了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。
初始化
构造方法都初始化了负载因子 loadFactor,由于 HashMap 中没有 capacity 这样的字段,即使指定了初始化容量 initialCapacity ,也只是通过 tableSizeFor 将其扩容到与 initialCapacity 最接近的 2 的幂次方大小,然后暂时赋值给 threshold ,后续通过 resize 方法将 threshold 赋值给 newCap 进行 table 的初始化。
// 默认构造函数。
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
// 包含另一个“Map”的构造函数
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);//下面会分析到这个方法
}
// 指定“容量大小”的构造函数
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 指定“容量大小”和“负载因子”的构造函数
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
this.loadFactor = loadFactor;
// 初始容量暂时存放到 threshold ,在resize函数中再赋值给 newCap 进行table初始化
this.threshold = tableSizeFor(initialCapacity);
}
putMapEntries 方法:
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict)
- 先判断table是否已经初始化,如果没有,计算承载传入的m所有元素需要的最小容量
ft = m的长度s / loadFactor + 1 - 如果计算出来的容量t大于初始化阈值容量threshold,执行tableSizeFor(t),将阈值更新为邻近的2的幂次
- 如果table已经初始化,并且元素个人大于初始阈值,进行扩容
- 容量更新后,将m中的所有元素添加至HashMap中,如果table未初始化,putVal中会调用resize初始化或扩容
核心机制
put方法插入元素:
如果定位到的数组位置没有元素 就直接插入,对应
tab[i] = new Node(hash, key, value, null)。如果定位到的数组位置有元素就和要插入的 key 比较,如果 key 相同就直接覆盖(将 数组位置元素p赋值给插入节点e)。
如果 key 不相同,遍历树/链表找插入位置:判断 桶内第一个元素p 是否是一个树节点,如果是就调用
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value)找到元素添加位置;如果不是就遍历链表插入(尾插法)
只要数组铀元素,就是进行了hash冲突处理。通过判断首节点,或遍历树(链表),要插入的位置为e。e不为空就说明在现有元素中找到了key和hash相等的节点,此时直接将新值赋值给旧节点,返回旧值。e为空则为插入新节点,返回null表示没有旧值。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// table未初始化或者长度为0,进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
// 对应桶的第一个节点赋值给p
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = new Node(hash, key, value, null);
// 桶中已经存在元素(处理hash冲突)
else {
Node<K,V> e; K k;
// 快速判断第一个节点table[i]的key是否与插入的key一样
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 判断插入的是否是红黑树节点
else if (p instanceof TreeNode)
// 放入树中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 不是红黑树节点则说明为链表结点
else {
// 在链表最末插入结点
for (int binCount = 0; ; ++binCount) {
// 到达链表的尾部
if ((e = p.next) == null) {
// 在尾部插入新结点
p.next = newNode(hash, key, value, null);
// 结点数量达到阈值(默认为 8 ),执行 treeifyBin 方法
// 这个方法会根据 HashMap 数组来决定是否转换为红黑树。
// 只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是对数组扩容。
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
// 跳出循环
break;
}
// 判断链表中结点的key值与插入的元素的key值是否相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 相等,跳出循环
break;
// 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
p = e;
}
}
// 表示在桶中找到key值、hash值与插入元素相等的结点
if (e != null) {
// 记录e的value
V oldValue = e.value;
// onlyIfAbsent为false或者旧值为null
if (!onlyIfAbsent || oldValue == null)
//用新值替换旧值
e.value = value;
// 访问后回调
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
// 结构性修改
++modCount;
// 实际大小大于阈值则扩容
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}
resize扩容
进行扩容,会伴随着一次重新 hash 分配,并且会遍历 hash 表中所有的元素,是非常耗时的。在编写程序中,要尽量避免 resize。resize 方法实际上是将 table 初始化和 table 扩容 进行了整合,底层的行为都是给 table 赋值一个新的数组。
resize() 方法的主要作用是调整 HashMap 的容量,具体包括以下几个方面:
- 扩容:将哈希表的容量扩大为原来的两倍。
- 重新哈希:将原哈希表中的所有键值对重新计算哈希值,并放入新的哈希表中。
- 更新阈值:根据新的容量更新阈值
threshold。
过程:
计算新容量和新阈值
如果旧容量已经达到最大容量(2^30),则无法再扩容,通过链表或红黑树添加元素。如果没超过最大值,新容量和新阈值扩充为原来的2倍。
如果旧容量为 0,但旧阈值大于 0,说明是通过构造函数指定了初始容量。此时新容量=设置的旧阈值。
如果旧容量和旧阈值都为 0,说明是使用默认构造函数创建的 HashMap。此时新容量=DEFAULT_INITIAL_CAPACITY;新阈值=(int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
Node<K,V>[] oldTab = table;
......
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
// 阈值=Integer.MAX_VALUE,返回oldTab
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
} else if (oldThr > 0) {
// 初始化容量
} else {
// 无参构造函数初始化容量
}
if (newThr == 0) {
// 根据新容量和负载因子计算新阈值
}
创建新的哈希表数组:根据新容量创建一个新的哈希表数组,并将其赋值给 table。
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; // 创建新的哈希表数组
table = newTab;
if (oldTab != null) {
// 迁移元素
}
迁移元素
- 遍历旧的哈希表数组,对于每个位置的元素:
- 如果该位置只有一个元素,直接重新计算哈希值并放入新表。
- 如果是树节点,调用树的拆分方法。
split()方法会根据元素在新哈希表中的位置将树拆分为两个部分,可能会将部分节点转换为链表,也可能会继续保持树结构。 - 如果是链表节点,将链表拆分为两个链表:一个链表中的元素在新表中的位置不变,另一个链表中的元素在新表中的位置为原位置加上旧容量。然后将这两个链表分别放入新表的相应位置。
在
HashMap中,元素存储位置的索引是通过hash & (capacity - 1)计算得到的,其中hash是键的哈希值,capacity是哈希表的容量。这种计算方式等价于对capacity取模运算,但由于位运算的效率更高,所以采用了按位与运算。
- 例如,当
capacity = 4时,capacity - 1 = 3,二进制表示为0011。假设某个元素的哈希值hash = 5,二进制表示为0101,则hash & (capacity - 1) = 0101 & 0011 = 0001,所以该元素在容量为 4 的哈希表中存储在索引为 1 的位置。
HashMap的扩容规则是将容量扩大为原来的两倍,即newCap = oldCap * 2。在二进制表示中,**newCap相当于oldCap左移一位。在二进制层面,newCap - 1相比于oldCap - 1只是多了一位高位。**举例如下:假设旧容量oldCap = 4(二进制0100),那么oldCap - 1 = 3(二进制0011);新容量newCap = 8(二进制1000),newCap - 1 = 7(二进制0111)。对于一个元素的哈希值
hash,在旧哈希表中的索引是hash & (oldCap - 1),在新哈希表中的索引是hash & (newCap - 1)。而(e.hash & oldCap) == 0这个判断,本质上就是在检查hash的二进制表示中对应oldCap为 1 的那一位是否为 0。
- 如果
(e.hash & oldCap) == 0,说明hash在这一位是 0,那么hash & (newCap - 1)的结果和hash & (oldCap - 1)的结果是一样的,也就是元素在新哈希表中的位置和旧哈希表中相同。- 如果
(e.hash & oldCap) != 0,说明hash在这一位是 1,那么hash & (newCap - 1)的结果就等于hash & (oldCap - 1)再加上oldCap,即元素在新哈希表中的位置是旧位置加上oldCap。所以,通过
(e.hash & oldCap) == 0判断得到的元素在新哈希表中的位置和重新计算hash & (newCap - 1)得到的位置是完全一致的。这种设计的主要依据是为了在扩容时能够高效地将元素分配到新的哈希表中,避免对每个元素都重新计算完整的哈希值和索引。通过简单的按位与运算,可以快速判断元素在新哈希表中的位置是保持不变还是需要移动到新的位置(原位置 +oldCap),从而减少了计算开销,提高了扩容的效率。
// 遍历旧的哈希表数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
// 释放旧表的引用,帮助垃圾回收
oldTab[j] = null;
if (e.next == null)
// 如果该位置只有一个元素,直接重新计算哈希值并放入新表
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 如果是树节点,调用树的拆分方法
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
// 链表节点处理
......
// 将位置不变的链表放入新表的原位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 将位置变化的链表放入新表的新位置(原位置 + 旧容量)
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
长度问题
总结一下 HashMap 的长度是 2 的幂次方的原因:
- 位运算效率更高:位运算(&)比取余运算(%)更高效。当长度为 2 的幂次方时,
hash % length等价于hash & (length - 1)。 - 可以更好地保证哈希值的均匀分布:扩容之后,在旧数组元素 hash 值比较均匀的情况下,新数组元素也会被分配的比较均匀,最好的情况是会有一半在新数组的前半部分,一半在新数组后半部分。
- 扩容机制变得简单和高效:扩容后只需检查哈希值高位的变化来决定元素的新位置,要么位置不变(高位为 0),要么就是移动到新位置(高位为 1,原索引位置+原容量)。
线程安全问题
HashMap 线程不安全主要体现在多线程环境下进行并发操作时可能会出现数据不一致、死循环等问题
JDK 1.8 后,在 HashMap 中,多个键值对可能会被分配到同一个桶(bucket),并以链表或红黑树的形式存储。多个线程对 HashMap 的 put 操作会导致线程不安全,具体来说会有数据覆盖的风险。
举个例子:
- 两个线程 1,2 同时进行 put 操作,并且发生了哈希冲突(hash 函数计算出的插入下标是相同的)。
- 不同的线程可能在不同的时间片获得 CPU 执行的机会,当前线程 1 执行完哈希冲突判断后,由于时间片耗尽挂起。线程 2 先完成了插入操作。
- 随后,线程 1 获得时间片,由于之前已经进行过 hash 碰撞的判断,所有此时会直接进行插入,这就导致线程 2 插入的数据被线程 1 覆盖了。
JDK1.7及以前: 多线程下的 resize 操作可能导致死循环。在 JDK 7 及以前,HashMap 的 resize 方法在扩容时采用头插法将原链表中的元素插入到新链表中。在多线程环境下,当多个线程同时触发 resize 操作时,可能会导致链表形成环形结构,从而造成死循环。
综上所述,由于 HashMap 在多线程环境下的 put、resize 和 size 等操作没有进行有效的同步控制,所以它是线程不安全的。在多线程环境中,如果需要使用线程安全的哈希表,可以考虑使用 ConcurrentHashMap 或 Hashtable。
HashMap vs HashTable
线程是否安全:
HashMap是非线程安全的,Hashtable是线程安全的,因为Hashtable内部的方法基本都经过synchronized修饰。(如果你要保证线程安全的话就使用ConcurrentHashMap吧!);效率: 因为线程安全的问题,
HashMap要比Hashtable效率高一点。另外,Hashtable基本被淘汰,不要在代码中使用它;对 Null key 和 Null value 的支持:
HashMap可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出NullPointerException。(ConcurrentHashMap也不支持存储null)初始容量大小和每次扩充容量大小的不同: ① 创建时如果不指定容量初始值,
Hashtable默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。**HashMap默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么Hashtable会直接使用你给定的大小,而HashMap会将其扩充为 2 的幂次方大小**(HashMap中的tableSizeFor()方法保证,上面给出了源代码)。也就是说===HashMap总是使用 2 的幂作为哈希表的大小==。- Hash函数的算法设计:取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作(也就是说
hash%length==hash&(length-1)的前提是 length 是 2 的 n 次方)。并且 采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是 2 的幂次方。
- Hash函数的算法设计:取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作(也就是说
底层数据结构: JDK1.8 以后的
HashMap在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间。Hashtable没有这样的机制。
HashMap vs TreeMap
TreeMap 和HashMap 都继承自AbstractMap ,但是需要注意的是TreeMap它还实现了NavigableMap接口和SortedMap 接口。
NavigableMap 接口提供了丰富的方法来探索和操作键值对,可以对集合元素进行搜索:
- 定向搜索:
ceilingEntry(),floorEntry(),higherEntry()和lowerEntry()等方法可以用于定位大于、小于、大于等于、小于等于给定键的最接近的键值对。 - 子集操作:
subMap(),headMap()和tailMap()方法可以高效地创建原集合的子集视图,而无需复制整个集合。 - 逆序视图:
descendingMap()方法返回一个逆序的NavigableMap视图,使得可以反向迭代整个TreeMap。 - 边界操作:
firstEntry(),lastEntry(),pollFirstEntry()和pollLastEntry()等方法可以方便地访问和移除元素。
这些方法都是基于红黑树数据结构的属性实现的,红黑树保持平衡状态,从而保证了搜索操作的时间复杂度为 O(log n),这让 TreeMap 成为了处理有序集合搜索问题的强大工具。
实现SortedMap接口让 TreeMap 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。
综上,相比于HashMap来说, TreeMap 主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力
ConcurrentHashMap
1.7版本
1.7版本:Java 7 中 ConcurrentHashMap 的存储结构如上图,ConcurrnetHashMap 由很多个 Segment 组合,而每一个 Segment 是一个类似于 HashMap 的结构,所以每一个 HashMap 的内部可以进行扩容。但是 Segment 的个数一旦初始化就不能改变,默认 Segment 的个数是 16 个,你也可以认为 ConcurrentHashMap 默认支持最多 16 个线程并发。
在 Java 7 中 ConcurrentHashMap 的初始化逻辑。
- 必要参数校验。
- 校验并发级别
concurrencyLevel大小,如果大于最大值,重置为最大值。无参构造默认值是 16. - 寻找并发级别
concurrencyLevel之上最近的 2 的幂次方值,作为初始化容量大小,默认是 16。 - 记录
segmentShift偏移量,这个值为【容量 = 2 的 N 次方】中的 N,在后面 Put 时计算位置时会用到。默认是 32 - sshift = 28. - 记录
segmentMask,默认是 ssize - 1 = 16 -1 = 15. - 初始化
segments[0],默认大小为 2,负载因子 0.75,扩容阀值是 2*0.75=1.5,插入第二个值时才会进行扩容。
......
// 创建 Segment 数组,设置 segments[0]
Segment<K,V> s0 = new Segment<K,V>(loadFactor, (int)(cap * loadFactor),(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
========put========
ConcurrentHashMap 在**put **一个数据时的处理流程:
计算要 put 的 key 的位置,获取指定位置的
Segment。如果指定位置的
Segment为空,则初始化这个Segment.初始化 Segment 流程:
- 检查计算得到的位置的
Segment是否为 null. - 为 null 继续初始化,使用
Segment[0]的容量和负载因子创建一个HashEntry数组。 - 再次检查计算得到的指定位置的
Segment是否为 null。因为这时可能有其他线程进行了操作 - 使用创建的
HashEntry数组初始化这个 Segment. - 自旋判断计算得到的指定位置的
Segment是否为 null,使用 CAS 在这个位置赋值为Segment
- 检查计算得到的位置的
Segment.put插入 key,value 值。由于
Segment继承了ReentrantLock,所以Segment内部可以很方便的获取锁,put 流程就用到了这个功能。tryLock()获取锁,获取不到使用scanAndLockForPut方法继续获取。CAS计算 put 的数据要放入的 index 位置,然后获取这个位置上的
HashEntry。遍历 put 新元素,为什么要遍历?因为这里获取的
HashEntry可能是一个空元素,也可能是链表已存在,所以要区别对待。如果这个位置上的
HashEntry不存在:- 如果当前容量大于扩容阀值,小于最大容量,进行扩容。
- 直接头插法插入。
如果这个位置上的
HashEntry存在:- 判断链表当前元素 key 和 hash 值是否和要 put 的 key 和 hash 值一致。一致则替换值
- 不一致,获取链表下一个节点,直到发现相同进行值替换,或者链表遍历完毕没有相同的。
- 如果当前容量大于扩容阀值,小于最大容量,进行扩容。
- 直接链表头插法插入。
如果要插入的位置之前已经存在,替换后返回旧值,否则返回 null.
========扩容rehash========
ConcurrentHashMap 的扩容只会扩容到原来的两倍。老数组里的数据移动到新的数组时,位置要么不变,要么变为 index+ oldSize,参数里的 node 会在扩容之后使用链表头插法插入到指定位置。
===========get=========
- 计算得到 key 的存放的segment的对应HashEntry数组位置。
- 遍历指定位置的链表查找相同 key 的 value 值。
1.8版本
可以发现 Java8 的 ConcurrentHashMap 相对于 Java7 来说变化比较大,不再是之前的 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树。当冲突链表达到一定长度时,链表会转换成红黑树。
==========初始化==========
ConcurrentHashMap 的初始化是通过自旋和 CAS 操作完成的。里面需要注意的是变量 sizeCtl (sizeControl 的缩写),它的值决定着当前的初始化状态。
- -1 说明正在初始化,其他线程需要自旋等待
- -N 说明 table 正在进行扩容,高 16 位表示扩容的标识戳,低 16 位减 1 为正在进行扩容的线程数
- 0 表示 table 初始化大小,如果 table 没有初始化
- >0 表示 table 扩容的阈值,如果 table 已经初始化。
/**
* Initializes table, using the size recorded in sizeCtl.
*/
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// 如果 sizeCtl < 0 ,说明另外的线程执行CAS 成功,正在进行初始化。
if ((sc = sizeCtl) < 0)
// 让出 CPU 使用权,自旋等待
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
==========put===========
- 根据 key 计算出 hashcode 。
- 判断是否需要进行初始化。
- 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
- 如果当前位置的
hashcode == MOVED == -1,则需要进行扩容。 - 如果都不满足(桶里有数据,数组不需要扩容),则利用 synchronized 锁写入数据,写入时判断结构是链表还是红黑树,执行对应的插入操作。
- 如果数量大于
TREEIFY_THRESHOLD则要执行树化方法,在treeifyBin中会首先判断当前数组长度 ≥64 时才会将链表转换为红黑树。
==========get===========
- 根据 hash 值计算node数组位置。
- 查找到指定位置,如果头节点就是要找的,直接返回它的 value.
- 如果头节点 hash 值小于 0 ,说明正在扩容或者是红黑树,使用find查找。
- 如果是链表,遍历查找之。
线程安全实现
JDK1.8之前:首先将数据分为一段一段(这个“段”就是
Segment)的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。**Segment继承了ReentrantLock,所以Segment是一种可重入锁**,扮演锁的角色。HashEntry用于存储键值对数据。对同一Segment的并发写入会被阻塞,不同Segment的写入是可以并发执行的。JDK1.8之后:
ConcurrentHashMap取消了Segment分段锁,采用Node + CAS + synchronized来保证并发安全。数据结构跟HashMap1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。Java 8 中,锁粒度更细,
synchronized只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。
总结:1.7中使用segment分段锁,锁范围较大,最大并发数为segment数量,默认是16。1.8中使用Node+CAS+synchronized,只锁定链表或红黑树的头节点,锁粒度更细,最大并发数是node数组的大小。
ConcurrentHashMap vs Hashtable
ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。
底层数据结构: JDK1.7 的
ConcurrentHashMap底层采用 分段的数组+HashEntry 实现,JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。Hashtable和 JDK1.8 之前的HashMap的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;实现线程安全的方式(重要):
在 JDK1.7 的时候,
ConcurrentHashMap对整个桶数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。到了 JDK1.8 的时候,
ConcurrentHashMap已经摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用synchronized和 CAS 来操作。(JDK1.6 以后synchronized锁做了很多优化) 整个看起来就像是**优化过且线程安全的HashMap**,虽然在 JDK1.8 中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本;Hashtable(同一把锁) :使用
synchronized来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
LinkedHashMap
LinkedHashMap 是 Java 提供的一个集合类,它继承自 HashMap,并在 HashMap 基础上维护一条双向链表,使得具备如下特性:
- 支持遍历时会按照插入顺序有序进行迭代。–
LinkedHashMap内部维护了一个双向链表,用于记录元素的插入顺序。因此,当使用迭代器迭代元素时,元素的顺序与它们最初插入的顺序相同。 - 支持按照元素访问顺序排序,用于封装 LRU 缓存工具。–
LinkedHashMap可以通过构造函数中的accessOrder参数指定按照访问顺序迭代元素。当accessOrder为 true 时,每次访问一个元素时,该元素会被移动到链表的末尾,因此下次访问该元素时,它就会成为链表中的最后一个元素,从而实现按照访问顺序迭代元素。 - 因为内部使用双向链表维护各个节点,所以遍历时的效率和元素个数成正比,相较于和容量成正比的 HashMap 来说,迭代效率会高很多。
LinkedHashMap 逻辑结构如下图所示,它是在 HashMap 基础上在各个节点之间维护一条双向链表,使得原本散列在不同 bucket 上的节点、链表、红黑树有序关联起来。
核心机制
LinkedHashMap的节点内部类Entry基于HashMap的基础上,增加before和after指针使节点具备双向链表的特性。HashMap的树节点TreeNode继承了具备双向链表特性的LinkedHashMap的Entry。
总结:Entry类是LinkedHashMap中的节点类,充当HashMap中Node类的作用。
HashMap 的节点集合 Node则仅包含kv对和下一个元素指针,避免使用HashMap的时候也出现无关的双向链表元素。
TreeNode用于在内部链表转化为红黑树的时候使用,继承enry类来获取双向链表指针。但是这样做,也使得使用 HashMap 时的 TreeNode 多了两个没有必要的引用。对于这个问题,引用作者的一段注释,作者们认为在良好的 hashCode 算法时,HashMap 转红黑树的概率不大。就算转为红黑树变为树节点,也可能会因为移除或者扩容将 TreeNode 变为 Node,所以 TreeNode 的使用概率不算很大,对于这一点资源空间的浪费是可以接受的。
get方法
get 方法是 LinkedHashMap 增删改查操作中唯一一个重写的方法。accessOrder 为 true 的情况下, 它会在元素查询完成之后,将当前访问的元素移到链表的末尾。
public V get(Object key) {
Node < K, V > e;
//获取key的键值对,若为空直接返回
if ((e = getNode(hash(key), key)) == null)
return null;
//若accessOrder为true,则调用afterNodeAccess将当前元素移到链表末尾
if (accessOrder)
afterNodeAccess(e);
//返回键值对的值
return e.value;
}
void afterNodeAccess(Node < K, V > e) { // move node to last
LinkedHashMap.Entry < K, V > last;
//如果accessOrder 且当前节点不为链表尾节点
if (accessOrder && (last = tail) != e) {
//获取当前节点、以及前驱节点和后继节点
LinkedHashMap.Entry < K, V > p =
(LinkedHashMap.Entry < K, V > ) e, b = p.before, a = p.after;
//将当前节点的后继节点指针指向空,使其和后继节点断开联系(清除p->p.after)
p.after = null;
//如果前驱节点为空,则说明当前节点是链表的首节点,故将后继节点设置为首节点
if (b == null)
head = a;
else
//如果前驱节点不为空,则让前驱节点指向后继节点(清除p.before->p)
b.after = a;
//如果后继节点不为空,则让后继节点指向前驱节点(清除p.after->p)
if (a != null)
a.before = b;
else
//如果后继节点为空,则说明当前节点在链表最末尾,直接让last 指向前驱节点,这个 else其实 没有意义,因为最开头if已经确保了p不是尾结点了,自然after不会是null
last = b;
//如果last为空,则说明当前链表只有一个节点p,则将head指向p
if (last == null)
head = p;
else {
//反之让p的前驱指针指向尾节点,再让尾节点的前驱指针指向p(构建last<-p & last->p)
p.before = last;
last.after = p;
}
//tail指向p,自此将节点p移动到链表末尾(更新last为p)
tail = p;
++modCount;
}
}
remove 方法后置操作——afterNodeRemoval:
LinkedHashMap并没有对remove方法进行重写,而是直接继承HashMap的remove方法,为了保证键值对移除后双向链表中的节点也会同步被移除,LinkedHashMap重写了HashMap的空实现方法afterNodeRemoval。afterNodeRemoval方法的整体操作就是让当前节点 p 和前驱节点、后继节点断开联系,等待 gc 回收put 方法后置操作——afterNodeInsertion:同样的
LinkedHashMap并没有实现插入方法,而是直接继承HashMap的所有插入方法交由用户使用,但为了维护双向链表访问的有序性,它做了这样两件事:重写
afterNodeAccess(上文提到过),如果当前被插入的 key 已存在与map中,因为LinkedHashMap的插入操作会将新节点追加至链表末尾,所以对于存在的 key 则调用afterNodeAccess将其放到链表末端。重写了
HashMap的afterNodeInsertion方法,当removeEldestEntry返回 true 时,会将链表首节点移除。
实现LRU缓存
- 继承
LinkedHashMap; - 构造方法中指定
accessOrder为 true(遍历时,需要访问顺序则为 true,需要插入顺序则为 false) ,这样在访问元素时就会把该元素移动到链表尾部,链表首元素就是最近最少被访问的元素; - 重写
removeEldestEntry方法,该方法会返回一个 boolean 值,告知LinkedHashMap是否需要移除链表首元素(缓存容量有限)。
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
public LRUCache(int capacity) {
super(capacity, 0.75f, true);
this.capacity = capacity;
}
/**
* 判断size超过容量时返回true,告知LinkedHashMap移除最老的缓存项(即链表的第一个元素)
*/
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity;
}
}
LinkedHashMap vs HashMap
LinkedHashMap 和 HashMap 都是 Java 集合框架中的 Map 接口的实现类。它们的最大区别在于迭代元素的顺序。HashMap 迭代元素的顺序是不确定的,而 LinkedHashMap 提供了按照插入顺序或访问顺序迭代元素的功能。此外,LinkedHashMap 内部维护了一个双向链表,用于记录元素的插入顺序或访问顺序,而 HashMap 则没有这个链表。因此,LinkedHashMap 的插入性能可能会比 HashMap 略低,但它提供了更多的功能并且迭代效率相较于 HashMap 更加高效。
LinkedHashMap维护了一个双向链表来记录数据插入的顺序,因此在迭代遍历生成的迭代器的时候,是按照双向链表的路径进行遍历的。这一点相比于HashMap那种遍历整个 bucket 的方式来说,高效许多。
TreeMap
TreeMap 由红黑树实现,可以保持key元素的自然顺序,或者实现了 Comparator 接口的自定义顺序。
红黑树(英语:Red–black tree)是一种自平衡的二叉查找树(Binary Search Tree),结构复杂,但却有着良好的性能,完成查找、插入和删除的时间复杂度均为 log(n)。
常见的平衡二叉树包括AVL树、红黑树等等,它们都是通过旋转操作来调整树的平衡,使得左子树和右子树的高度尽可能接近。
AVL树是一种高度平衡的二叉查找树,它要求左子树和右子树的高度差不超过1。由于AVL树的平衡度比较高,因此在进行插入和删除操作时需要进行更多的旋转操作来保持平衡,但是在查找操作时效率较高。AVL树适用于读操作比较多的场景。
红黑树,顾名思义,就是节点是红色或者黑色的平衡二叉树,它通过颜色的约束来维持二叉树的平衡:
- 1)每个节点都只能是红色或者黑色
- 2)根节点是黑色
- 3)每个叶节点(NIL 节点,空节点)是黑色的。
- 4)如果一个节点是红色的,则它两个子节点都是黑色的。也就是说在一条路径上不能出现相邻的两个红色节点。
- 5)从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。
public class TreeMap<K,V>
extends AbstractMap<K,V>
implements NavigableMap<K,V>, Cloneable, java.io.Serializable
{
// 自定义比较器
@SuppressWarnings("serial") // Conditionally serializable
private final Comparator<? super K> comparator;
// 元素根节点
private transient Entry<K,V> root;
// entry数量
private transient int size = 0;
// 修改记录
private transient int modCount = 0;
static final class Entry<K,V> implements Map.Entry<K,V> {
K key;
V value;
Entry<K,V> left;
Entry<K,V> right;
Entry<K,V> parent;
boolean color = BLACK;
Entry(K key, V value, Entry<K,V> parent) {
this.key = key;
this.value = value;
this.parent = parent;
}
}
}
put
public V put(K key, V value) {
Entry<K,V> t = root; // 将根节点赋值给变量t
if (t == null) { // 如果根节点为null,说明TreeMap为空
compare(key, key); // type (and possibly null) check,检查key的类型是否合法
root = new Entry<>(key, value, null); // 创建一个新节点作为根节点
size = 1; // size设置为1
return null; // 返回null,表示插入成功
}
int cmp;
Entry<K,V> parent;
// split comparator and comparable paths,根据使用的比较方法进行查找
Comparator<? super K> cpr = comparator; // 获取比较器
if (cpr != null) { // 如果使用了Comparator
do {
parent = t; // 将当前节点赋值给parent
cmp = cpr.compare(key, t.key); // 使用Comparator比较key和t的键的大小
if (cmp < 0) // 如果key小于t的键
t = t.left; // 在t的左子树中查找
else if (cmp > 0) // 如果key大于t的键
t = t.right; // 在t的右子树中查找
else // 如果key等于t的键
return t.setValue(value); // 直接更新t的值
} while (t != null);
}
else { // 如果没有使用Comparator
if (key == null) // 如果key为null
throw new NullPointerException(); // 抛出NullPointerException异常
Comparable<? super K> k = (Comparable<? super K>) key; // 将key强制转换为Comparable类型
do {
parent = t; // 将当前节点赋值给parent
cmp = k.compareTo(t.key); // 使用Comparable比较key和t的键的大小
if (cmp < 0) // 如果key小于t的键
t = t.left; // 在t的左子树中查找
else if (cmp > 0) // 如果key大于t的键
t = t.right; // 在t的右子树中查找
else // 如果key等于t的键
return t.setValue(value); // 直接更新t的值
} while (t != null);
}
// 如果没有找到相同的键,需要创建一个新节点插入到TreeMap中
Entry<K,V> e = new Entry<>(key, value, parent); // 创建一个新节点
if (cmp < 0) // 如果key小于parent的键
parent.left = e; // 将e作为parent的左子节点
else
parent.right = e; // 将e作为parent的右子节点
fixAfterInsertion(e); // ======== 注意这里,插入节点后需要进行平衡操作
size++; // size加1
return null; // 返回null,表示插入成功
}
fail-fast / fail-safe
快速失败的思想即针对可能发生的异常进行提前表明故障并停止运行,通过尽早的发现和停止错误,降低故障系统级联的风险。
fail-fast 是 Java 集合(如 ArrayList、HashMap 等)中一种错误检测机制。当一个线程正在迭代集合时,如果其他线程对该集合的结构进行了修改(例如添加、删除元素等),迭代器会立即抛出 ConcurrentModificationException 异常,从而快速失败并终止迭代过程。
fail-fast 机制的实现依赖于集合中的一个计数器 modCount。每当集合的结构发生变化时,modCount 的值就会增加。迭代器在创建时会记录当前的 modCount 值(记为 expectedModCount),在每次迭代操作时,会检查 modCount 和 expectedModCount 是否相等。如果不相等,说明集合的结构在迭代过程中被修改了,迭代器会立即抛出 ConcurrentModificationException 异常。
fail-fast 机制主要用于检测并发修改错误,适用于单线程环境下快速发现程序中的错误。在多线程环境中,如果需要对集合进行并发操作,使用 fail-fast 集合可能会导致频繁抛出异常,因此不适合。
fail-safe也就是安全失败的含义,它旨在即使面对意外情况也能恢复并继续运行,这使得它特别适用于不确定或者不稳定的环境
fail-safe 是一种相对安全的迭代机制。当一个线程正在迭代集合时,如果其他线程对该集合的结构进行了修改,迭代器不会抛出 ConcurrentModificationException 异常,而是继续迭代,使用的是集合的一个副本,因此不会影响原集合的迭代过程。
fail-safe 机制的实现通常是在迭代时创建集合的一个副本,迭代器在副本上进行操作。由于副本和原集合是相互独立的,因此在迭代过程中对原集合的修改不会影响副本,也就不会抛出异常。
fail-safe 机制适用于多线程环境下对集合进行并发操作的场景,它可以避免因并发修改而导致的异常。但由于需要创建集合的副本,会消耗额外的内存,并且在迭代过程中可能无法及时反映原集合的最新状态。
Comparable / Comparator
Comparable 接口和 Comparator 接口都是 Java 中用于排序的接口,它们在实现类对象之间比较大小、排序等方面发挥了重要作用:
Comparable接口实际上是出自java.lang包 它有一个compareTo(Object obj)方法用来排序Comparator接口实际上是出自java.util包它有一个compare(Object obj1, Object obj2)方法用来排序
Comparable 接口位于 java.lang 包下,它定义了一个对象本身的自然排序规则。一个类实现了 Comparable 接口,就意味着该类的对象可以进行自我比较,从而支持使用一些排序算法(如 Arrays.sort()、Collections.sort())对对象数组或集合进行排序
- 当一个类的排序规则是固定的、唯一的,并且该类的对象在大多数情况下都按照这个规则进行排序时,适合实现
Comparable接口。例如,String类就实现了Comparable接口,其compareTo()方法按照字典序对字符串进行比较。
Comparator 接口位于 java.util 包下,它提供了一种外部比较器的机制。与 Comparable 不同,Comparator 允许在不修改类本身的情况下,为类的对象定义多种不同的排序规则。
// void sort(List list),按自然排序的升序排序
Collections.sort(arrayList);
System.out.println("Collections.sort(arrayList):");
System.out.println(arrayList);
// 定制排序的用法
Collections.sort(arrayList, new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o2.compareTo(o1);
}
});
// person对象没有实现Comparable接口,所以必须实现,===这样才可以使treemap中的数据按顺序排列===
// 前面一个例子的String类已经默认实现了Comparable接口,详细可以查看String类的API文档,另外其他
// 像Integer类等都已经实现了Comparable接口,所以不需要另外实现了
public class Person implements Comparable<Person> {
private String name;
private int age;
public Person(String name, int age) {
super();
this.name = name;
this.age = age;
}
// set, get methods
/**
* T重写compareTo方法实现按年龄来排序
* 若返回值小于 0,表示当前对象小于传入对象。若返回值大于 0,表示当前对象大于传入对象。
*/
@Override
public int compareTo(Person o) {
return this.age - o.age;
}
public static void main(String[] args) {
TreeMap<Person, String> pdata = new TreeMap<Person, String>();
pdata.put(new Person("张三", 30), "zhangsan");
pdata.put(new Person("李四", 20), "lisi");
pdata.put(new Person("王五", 10), "wangwu");
pdata.put(new Person("小红", 5), "xiaohong");
// 得到key的值的同时得到key所对应的值
Set<Person> keys = pdata.keySet();
for (Person key : keys) {
System.out.println(key.getAge() + "-" + key.getName());
}
}
}
并发
线程
进程是程序的一次执行过程,是系统运行程序的基本单位。线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的堆和方法区(JDK1.8 之后的元空间)资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间做切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。
私有:
- 程序计数器:字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。计数器私有是为了各线程之间切换,便于恢复到正确的执行位置。
- 虚拟机栈: 每个 Java 方法在执行之前会创建一个栈帧用于存储===局部变量表、操作数栈、常量池引用===等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。如果虚拟机栈是共享的,多个线程可能会同时修改栈中的数据,导致数据不一致和程序崩溃。
- 例如,线程 A 和线程 B 同时调用同一个方法,若共享虚拟机栈,线程 A 的局部变量可能会被线程 B 覆盖,从而产生不可预期的结果。线程私有可以保证每个线程的方法调用和局部变量的独立性,避免线程间的干扰,确保线程安全。
- 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
- 本地方法栈用于支持本地方法(使用非 Java 语言编写的方法,如 C、C++ 等)的执行。本地方法在执行过程中也需要自己的栈空间来存储相关信息。
为了保证线程中的===局部变量===不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。
公有:
堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
================================================
- 用户线程:由用户空间程序管理和调度的线程,运行在用户空间(专门给应用程序使用)。
- 内核线程:由操作系统内核管理和调度的线程,运行在内核空间(只有内核程序可以访问)。
- 用户线程创建和切换成本低,但不可以利用多核。内核态线程,创建和切换成本高,可以利用多核。
现在的 Java 线程的本质其实就是操作系统的线程。
线程模型:线程模型是用户线程和内核线程之间的关联方式。
- 一对一(一个用户线程对应一个内核线程)
- 多对一(多个用户线程映射到一个内核线程)
- 多对多(多个用户线程映射到多个内核线程)
在 Windows 和 Linux 等主流操作系统中,Java 线程采用的是一对一的线程模型,也就是一个 Java 线程对应一个系统内核线程。
线程创建
Java语言的JVM允许程序运行多个线程,使用java.lang.Thread类代表线程,所有的线程对象都必须是Thread类或其子类的实例。
Thread类的特性
每个线程都是通过某个特定Thread对象的run()方法来完成操作的,因此把run()方法体称为线程执行体。
- 实现Runnable、Callable接口,包括继承Thread类重写run()方法,都是创建线程体的方式。
- 线程是一个独立的执行单元,可以被操作系统调度;而线程体仅仅只是任务,就类似于一段普通的代码,需要线程作为载体才能运行。线程是执行线程体的容器,线程体是一个可运行的任务。
通过该Thread对象的start()方法来启动这个线程,而非直接调用run()。
在
Java中,创建线程的方式就只有一种:调用Thread.start()方法!只有这种形式,才能在真正意义上创建一条线程!而例如
ExecutorService线程池、ForkJoin线程池、CompletableFuture类、Timer定时器类、parallelStream并行流……,它们最终都依赖于Thread.start()方法创建线程。
要想实现多线程,必须在主线程中创建新的线程对象。
继承Thread类,重写run方法。创建该类的实例,并调用 start() 方法启动线程。
优点:编写简单;缺点:因为继承了Thread,不能再继承其他类。
public class ExtendsThread extends Thread {
@Override
public void run() {
System.out.println("1......");
}
public static void main(String[] args) {
new ExtendsThread().start();
}
}
实现Runnable接口,重写run方法。创建该类的实例,将其作为参数传递给 Thread 类的构造函数,创建 Thread 对象,最后调用 start() 方法启动线程。
优点:只是实现了Runnable接口,可以继承其他类。可以多线程共享同一个目标对象。缺点:编程稍微复杂,访问当前线程需要Thread.currentThread()方法。
public class ImplementsRunnable implements Runnable {
@Override
public void run() {
System.out.println("2......");
}
public static void main(String[] args) {
ImplementsRunnable runnable = new ImplementsRunnable();
new Thread(runnable).start();
}
}
实现Callable接口并结合 FutureTask:和上一种方式类似,只不过这种方式可以拿到线程执行完的返回值。方法可以抛出异常。支持泛型的返回值(需要借助FutureTask类,获取返回结果)
创建该类的实例,将其作为参数传递给 FutureTask 类的构造函数,创建 FutureTask 对象。将 FutureTask 对象作为参数传递给 Thread 类的构造函数(FutureTask实现了Runnable接口),创建 Thread 对象,调用 start() 方法启动线程。
优缺点同Runnable。
public class ImplementsCallable implements Callable<String> {
@Override
public String call() throws Exception {
System.out.println("3......");
return "zhuZi";
}
public static void main(String[] args) throws Exception {
ImplementsCallable callable = new ImplementsCallable();
FutureTask<String> futureTask = new FutureTask<>(callable);
new Thread(futureTask).start();
System.out.println(futureTask.get()); // 获取线程执行结果
}
}
使用ExecutorService线程池:使用 Executors 工具类创建线程池,或者直接使用 ThreadPoolExecutor 类创建自定义线程池。提交任务到线程池,可以提交 Runnable 或 Callable 任务。
优点:
- 提高响应速度(减少了创建新线程的时间)
- 降低资源消耗(重复利用线程池中线程,降低创建和销毁线程的开销)
- 便于线程管理(统一分配,调优和监控)
缺点:程序复杂度高。错误的配置可能导致线程死锁或资源耗尽。缺乏异步组合能力:对于多个异步任务的组合和编排支持不够方便,需要手动编写大量的代码来处理任务之间的依赖关系。
ublic class UseExecutorService {
public static void main(String[] args) {
ExecutorService poolA = Executors.newFixedThreadPool(2);
poolA.execute(()->{
System.out.println("4A......");
});
poolA.shutdown();
// 又或者自定义线程池
ThreadPoolExecutor poolB = new ThreadPoolExecutor(2, 3, 0,
TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(3),
Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy());
poolB.submit(()->{
System.out.println("4B......");
});
poolB.shutdown();
}
}
使用CompletableFuture类:CompletableFuture是JDK1.8引入的新类,可以用来执行异步任务。
- 优点
- 强大的异步组合能力:可以方便地对多个异步任务进行组合和编排,如任务的串行执行、并行执行、合并结果等。例如,可以使用
thenApply()、thenCompose()等方法实现任务的串行执行,使用allOf()、anyOf()等方法实现任务的并行执行。 - 链式调用:支持链式调用,代码更加简洁易读。可以通过链式调用的方式将多个异步操作连接起来,形成一个异步操作链。
- 异常处理方便:提供了丰富的异常处理方法,如
exceptionally()、handle()等,可以方便地处理任务执行过程中的异常。
- 强大的异步组合能力:可以方便地对多个异步任务进行组合和编排,如任务的串行执行、并行执行、合并结果等。例如,可以使用
- 缺点
- 学习成本较高:由于
CompletableFuture提供了丰富的功能和方法,对于初学者来说,学习和掌握这些方法的使用需要花费一定的时间和精力。 - 线程管理不够精细:
CompletableFuture默认使用ForkJoinPool.commonPool()线程池,对于一些对线程管理有特殊要求的场景,可能无法满足需求。例如,无法像ExecutorService那样灵活地创建和配置线程池。
- 学习成本较高:由于
public class UseCompletableFuture {
public static void main(String[] args) throws InterruptedException {
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
System.out.println("5......");
return "zhuZi";
});
// 需要阻塞,否则看不到结果
Thread.sleep(1000);
}
}
基于ThreadGroup线程组:Java线程可以分组,可以创建多条线程作为一个组。
优点:
统一管理:
ThreadGroup提供了一种简单的方式来对一组线程进行统一管理。可以通过线程组一次性对组内的所有线程进行操作,例如中断组内的所有线程,调用ThreadGroup的interrupt()方法就可以中断组内所有未被中断的线程。层次结构清晰:
ThreadGroup可以形成树形的层次结构,便于组织和管理大量的线程。例如,在一个大型的应用程序中,可以根据不同的功能模块创建不同的线程组,每个线程组下再包含具体的线程,这样可以使线程的管理更加清晰。
缺点:
- 功能有限:
ThreadGroup的主要功能集中在线程的分组和统一管理上,对于线程的执行控制和任务调度功能相对较弱。与ExecutorService相比,它不能像线程池那样灵活地控制线程的数量、复用线程以及处理任务队列。
public class UseThreadGroup {
public static void main(String[] args) {
ThreadGroup group = new ThreadGroup("groupName");
new Thread(group, ()->{
System.out.println("6-T1......");
}, "T1").start();
new Thread(group, ()->{
System.out.println("6-T2......");
}, "T2").start();
}
}
生命周期
线程创建之后它将处于 NEW(新建/初始) 状态,调用
start()方法后开始运行,线程这时候处于 READY(可运行) 状态。可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 RUNNING(运行) 状态。- 在操作系统层面,线程有 READY 和 RUNNING 状态;而在 JVM 层面,只能看到 RUNNABLE 状态, Java 系统一般将这两个状态统称为 RUNNABLE(运行中) 状态 。JVM没有区分这两种状态,时分(time-sharing)多任务(multi-task)操作系统架构通常都是用“时间分片”方式进行抢占式(preemptive)轮转调度(round-robin 式)。这个时间分片通常是很小的,一个线程一次最多只能在 CPU 上运行比如 10-20ms 的时间(此时处于 running 状态),也即大概只有 0.01 秒这一量级,时间片用后就要被切换下来放入调度队列的末尾等待再次调度。(也即回到 ready 状态)。线程切换的如此之快,区分这两种状态就没什么意义了。
当线程执行
wait()方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态。(等待状态,表示该线程需要等待其他线程做出一些特定动作如通知或中断)TIMED_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过
sleep(long millis)方法或wait(long millis)方法可以将线程置于 TIMED_WAITING 状态。当超时时间结束后,线程将会返回到 RUNNABLE 状态。当线程进入
synchronized方法/块或者调用wait被notify重新进入synchronized方法/块,但是锁被其它线程占有,这个时候线程就会进入 BLOCKED(阻塞) 状态。、线程在执行完了
run()方法之后将会进入到 TERMINATED(终止) 状态。
线程上下文切换:保存当前线程的上下文(线程运行过程中的条件和状态),留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。线程切换可能发生在这些场景:主动让出 CPU,比如调用了 sleep(), wait() 等。时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。被终止或结束运行。
一些方法
Thread#sleep() 方法和 Object#wait() 方法:都可以暂停线程的执行。区别是sleep是让当前线程休眠一会,之后就会自动恢复,所以不会释放锁。而wait()对应线程生命周期中的等待状态,目的是线程之间的通信和交互,需要释放锁等待其他线程通知才能回到运行状态。sleep() 是 Thread 类的静态本地方法,wait() 则是 Object 类的本地方法。
wait()是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(Object)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(Object)而非当前的线程(Thread)。sleep()是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。所以定义在Thread中。
关于run和start:调用 start() 方法启动线程并使线程进入就绪状态,会执行线程的相应准备工作,然后自动执行 run() 方法的内容。如果开发者手动直接执行 run() 方法的话,会把 run() 方法当成一个 main 线程下的普通方法去执行,不会以多线程的方式执行。
多线程
- 并发:两个及两个以上的作业在同一 时间段 内执行。
- 并行:两个及两个以上的作业在同一 时刻 执行。
- 同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
- 异步:调用在发出之后,不用等待返回结果,该调用直接返回。
操作系统主要通过两种线程调度方式来管理多线程的执行:
- 抢占式调度(Preemptive Scheduling):操作系统决定何时暂停当前正在运行的线程,并切换到另一个线程执行。这种切换通常是由系统时钟中断(时间片轮转)或其他高优先级事件(如 I/O 操作完成)触发的。这种方式存在上下文切换开销,但公平性和 CPU 资源利用率较好,不易阻塞。
- 协同式调度(Cooperative Scheduling):线程执行完毕后,主动通知系统切换到另一个线程。这种方式可以减少上下文切换带来的性能开销,但公平性较差,容易阻塞。
Java 使用的线程调度是抢占式的。也就是说,JVM 本身不负责线程的调度,而是将线程的调度委托给操作系统。操作系统通常会基于线程优先级和时间片来调度线程的执行,高优先级的线程通常获得 CPU 时间片的机会更多。
并发编程的目的就是为了能提高程序的执行效率进而提高程序的运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。
线程安全和不安全是在多线程环境下对于同一份数据的访问是否能够保证其正确性和一致性的描述。
- 线程安全指的是在多线程环境下,对于同一份数据,不管有多少个线程同时访问,都能保证这份数据的正确性和一致性。
- 线程不安全则表示在多线程环境下,对于同一份数据,多个线程同时访问时可能会导致数据混乱、错误或者丢失。
死锁
死锁是多线程或多进程并发编程中的一种常见问题,它发生在两个或多个线程(或进程)相互等待对方释放资源的情况下,导致它们都无法继续执行下去的状态。这种情况下,每个线程都在等待某个资源,而同时也拥有一些资源。
public class DeadLockDemo {
private static Object resource1 = new Object();//资源 1
private static Object resource2 = new Object();//资源 2
public static void main(String[] args) {
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
}
}
}, "线程 1").start();
new Thread(() -> {
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource1");
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
}
}
}, "线程 2").start();
}
}
死锁的四个必要条件:
- 互斥条件:该资源任意一个时刻只由一个线程占用。
- 请求与保持/占有并等待条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
- 非抢占条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
预防死锁(破坏死锁的产生的必要条件即可):
1.破坏占有并等待条件:一次性申请所有资源;
2.破坏非抢占条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
3.破坏循环等待条件:对资源进行排序,按照固定的顺序请求资源,反序释放资源。
例如为系统中的所有资源分配唯一的编号,进程在请求资源时,必须按照编号从小到大的顺序进行请求。这样可以保证不会出现循环等待的情况,因为如果一个进程已经持有了编号较大的资源,它就不能再请求编号较小的资源,从而打破了循环等待的环路。
避免死锁:避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。
JMM
Java 内存模型(Java Memory Model,JMM)是 Java 语言规范中定义的一种抽象概念(并不真实存在),它屏蔽了各种硬件和操作系统的内存访问差异,以实现 Java 程序在不同平台下都能达到一致的内存访问效果。JMM 规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步地访问共享变量。
- 定义:Java 内存模型是一组规则,它规定了一个线程对共享变量的写入何时对另一个线程可见,即解决了多线程环境下共享变量的可见性、有序性和原子性问题。
- 主要目标:提供一种跨平台的内存访问协议,保证 Java 程序在不同的硬件和操作系统上都能具有一致的并发行为,使得开发者可以更方便地编写多线程程序,而无需关心底层硬件的内存访问细节。
主要结构
- 主内存(Main Memory):主内存是所有线程共享的内存区域,它存储了对象实例、静态变量等共享数据。可以把主内存看作是计算机的物理内存,它是数据的最终存储位置。
- 工作内存(Working Memory):每个线程都有自己独立的工作内存,它是线程私有的。线程在工作内存中保存了该线程使用到的主内存中共享变量的副本。线程对共享变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。
数据交互流程
当一个线程要访问共享变量时,会先从主内存中读取该变量的值到自己的工作内存中,形成一个副本。线程对副本进行操作后,在某个时刻(具体由 JMM 决定)将修改后的值刷新回主内存。由于不同线程的工作内存是相互独立的,所以一个线程对共享变量的修改需要通过刷新到主内存,然后其他线程再从主内存中读取新值,才能被其他线程看到。
三大特性
- 原子性:指一个操作是不可中断的,要么全部执行成功,要么全部执行失败。在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作,但像
i++这种复合操作不是原子性的。可以使用synchronized或Lock来保证操作的原子性。 - 可见性:当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。JMM 通过控制主内存和工作内存之间的交互,来实现可见性。例如,使用
volatile关键字可以保证变量的可见性,当一个变量被声明为volatile时,它会保证对该变量的写操作会立即刷新到主内存,读操作会直接从主内存中读取。 - 有序性:程序执行的顺序按照代码的先后顺序执行。但在实际执行过程中,为了提高性能,编译器和处理器可能会对指令进行重排序。JMM 提供了
happens-before原则来保证一定的有序性,确保在某些情况下指令不会被重排序。
volatile
volatile是Java提供的一种轻量级的同步机制,在并发编程中,它也扮演着比较重要的角色。volatile 关键字可以保证变量的可见性, 所谓可见性,是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更。如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
JMM规定了所有的变量都存储在主内存中。普通变量不能保证内存可见性。而volatile则保证了可见性和有序性。
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中。
- 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,重新回到主内存中读取最新共享变量。
有序性,即禁止指令重排序。在对volatile变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。
- 重排序是指编译器和处理器为了优化程序性能面对指令序列进行重新排序的一种手段,有时候会改变程序予以的先后顺序。(但重排后的指令绝对不能改变原有串行语义)
- 不存在数据依赖关系,可以重排序;
- 存在数据依赖关系,禁止重排序。
内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。
- 读屏障(Load Memory Barrier) :在读指令之前插入读屏障,让工作内存或CPU高速缓存当中的缓存数据失效,重新回到主内存中获取最新数据。
- 写屏障(Store Memory Barrier) :在写指令之后插入写屏障,强制把写缓冲区的数据刷回到主内存中。
因此重排序时,不允许把内存屏障之后的指令重排序到内存屏障之前。一句话:对一个volatile变量的写,先行发生于任意后续对这个volatile变量的读,也叫写后读。
双重校验锁实现对象单例(线程安全)
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:
- 为
uniqueInstance分配内存空间 - 初始化
uniqueInstance - 将
uniqueInstance指向分配的内存地址
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。使用volatile修饰,就能禁止指令重排。
乐观锁和悲观锁
悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。
像 Java 中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。
乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。
在 Java 中java.util.concurrent.atomic包下面的原子变量类(比如AtomicInteger、LongAdder)就是使用了乐观锁的一种实现方式 CAS 实现的。
高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。
理论上来说:
- 悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如
LongAdder),也是可以考虑使用乐观锁的,要视实际情况而定。 - 乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考
java.util.concurrent.atomic包下面的原子变量类)。
CAS
CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令:
- 在Java中,通过
sun.misc.Unsafe类调用本地(Native)方法实现。 - 示例:
Unsafe.compareAndSwapInt()、Unsafe.compareAndSwapObject()。
CAS 涉及到三个操作数:V:要更新的变量值(Var);E:预期值(Expected);N:拟写入的新值(New)
当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。(和版本号机制思想一致)
Unsafe#getAndAddInt源码:
// 原子地获取并增加整数值
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
// 以 volatile 方式获取对象 o 在内存偏移量 offset 处的整数值
v = getIntVolatile(o, offset);
} while (!compareAndSwapInt(o, offset, v, v + delta));
// 返回旧值
return v;
}
可以看到,getAndAddInt 使用了 do-while 循环:在compareAndSwapInt操作失败时,会不断重试直到成功。也就是说,getAndAddInt方法会通过 compareAndSwapInt 方法来尝试更新 value 的值,如果更新失败(当前值在此期间被其他线程修改),它会重新获取当前值并再次尝试更新,直到操作成功。
由于 CAS 操作可能会因为并发冲突而失败,因此通常会与while循环搭配使用,在失败后不断重试,直到操作成功。这就是 自旋锁机制。
问题:如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 “ABA”问题。
解决方案:在变量前面追加上版本号或者时间戳。AtomicStampedReference 类的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
另一个问题:循环时间长开销大。CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升。
Atomic原子类
Java 中的 Atomic 原子类是一组基于 CAS(Compare and Swap) 实现的无锁线程安全工具类,位于 java.util.concurrent.atomic 包中。它们提供了一种高效的方式来操作共享变量,避免了传统锁机制带来的性能开销。
- 所有操作都是原子的,确保多线程环境下的数据一致性。
- 直接操作内存,通过硬件指令保证原子性。
- 支持多种数据类型包括基本类型(如 int、long)、数组类型和引用类型(如对象)。
JUC 常用的类
java.util.concurrent这是 JUC 最核心的包,包含了大量用于并发编程的类和接口,主要有以下几类:
- 线程池相关类
ExecutorService:线程池的核心接口,定义了线程池的基本操作,如提交任务、关闭线程池等。ThreadPoolExecutor:ExecutorService的一个具体实现类,开发者可以通过它自定义线程池的各种参数,如核心线程数、最大线程数、线程空闲时间等。Executors:线程池工厂类,提供了一系列静态方法用于创建不同类型的线程池,如newFixedThreadPool(固定大小线程池)、newCachedThreadPool(缓存线程池)、newSingleThreadExecutor(单线程线程池)等。
- 锁相关类
Lock:一个接口,定义了锁的基本操作,如加锁、解锁等。与传统的synchronized关键字相比,Lock提供了更灵活的锁机制。ReentrantLock:Lock接口的一个实现类,是可重入锁,支持公平锁和非公平锁。ReadWriteLock:一个接口,定义了读写锁的基本操作,将锁分为读锁和写锁,允许多个线程同时获取读锁,但写锁是排他的。ReentrantReadWriteLock:ReadWriteLock接口的一个实现类。
- 并发容器类
ConcurrentHashMap:线程安全的哈希表,在多线程环境下可以高效地进行读写操作。ConcurrentLinkedQueue:线程安全的链表队列,适用于多线程环境下的队列操作。CopyOnWriteArrayList:线程安全的动态数组,在进行写操作时会复制一份原数组,适用于读多写少的场景。
- 同步工具类
CountDownLatch:一种同步辅助工具,允许一个或多个线程等待其他线程完成操作后再继续执行。CyclicBarrier:也是一种同步辅助工具,它允许一组线程相互等待,直到所有线程都到达某个屏障点后再继续执行,并且可以重复使用。Semaphore:用于控制同时访问某个资源的线程数量,通过获取和释放许可证来实现。Exchanger:用于两个线程之间交换数据,当两个线程都到达交换点时,它们会交换彼此的数据。
java.util.concurrent.atomic
该包提供了一些原子类,这些类可以在多线程环境下进行原子操作,避免了使用传统的同步机制带来的性能开销。常见的原子类有:
AtomicInteger:用于对整数进行原子操作,如自增、自减等。AtomicLong:用于对长整数进行原子操作。AtomicBoolean:用于对布尔值进行原子操作。AtomicReference:用于对引用类型进行原子操作。
并发工具
CountDownLatch
- 功能:
CountDownLatch是一个同步辅助类,允许一个或多个线程等待其他线程完成操作。它使用一个计数器来实现,初始化时设置计数器的值,当某个线程完成操作后,调用countDown()方法将计数器减 1,当计数器的值变为 0 时,等待的线程将被唤醒继续执行。**CountDownLatch是一次性的**,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当CountDownLatch使用完毕后,它不能再次被使用。 CountDownLatch是共享锁的一种实现,它默认构造 AQS 的state值为count。当线程使用countDown()方法时,其实使用了tryReleaseShared方法以 CAS 的操作来减少state,直至state为 0 。当调用await()方法的时候,如果state不为 0,那就证明任务还没有执行完毕,await()方法就会一直阻塞,也就是说await()方法之后的语句不会被执行。直到count个线程调用了countDown()使 state 值被减为 0,或者调用await()的线程被中断,该线程才会从阻塞中被唤醒,await()方法之后的语句得到执行。- 使用场景:适用于一个或多个线程需要等待其他一组线程完成任务后再继续执行的场景,比如主线程等待多个子线程完成数据加载或计算任务。
CyclicBarrier
- 功能:
CyclicBarrier也是一个同步辅助类,它允许一组线程在某个屏障点等待,直到所有线程都到达该屏障点后,再一起继续执行后续操作。与CountDownLatch不同的是,**CyclicBarrier的计数器可以重置**,因此可以重复使用。 - CyclicBarrier 内部通过一个 count 变量作为计数器,count 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。
- 使用场景:在并行计算中,当多个线程需要协同工作,在某个阶段等待所有线程都完成部分任务后,再进行下一步计算时,
CyclicBarrier非常有用。
CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用 await() 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。
当调用 CyclicBarrier 对象调用 await() 方法时,实际上调用的是 dowait(false, 0L)方法。 await() 方法就像树立起一个栅栏的行为一样,将线程挡住了,当拦住的线程数量达到 parties 的值时,栅栏才会打开,线程才得以通过执行。
Semaphore
- 功能:
Semaphore是一个计数信号量,用于控制同时访问某个资源的线程数量。它有一个初始值,表示可用的资源数量。线程在访问资源前需要先获取信号量,如果信号量的值大于 0,则获取成功,信号量的值减 1;如果信号量的值为 0,则线程会被阻塞,直到有其他线程释放信号量。 Semaphore是共享锁的一种实现,它默认构造 AQS 的state值为permits,你可以将permits的值理解为许可证的数量,只有拿到许可证的线程才能执行。- 使用场景:常用于限制对有限资源的访问,如数据库连接池、线程池的最大并发数控制等。
// 初始共享资源数量
final Semaphore semaphore = new Semaphore(5);
// 获取1个许可
semaphore.acquire();
// 释放1个许可
semaphore.release();
Semaphore 有两种模式:公平模式: 调用 acquire() 方法的顺序就是获取许可证的顺序,遵循 FIFO;非公平模式: 抢占式的。
Future 和 Callable
Callable是一个泛型接口,类似于Runnable,但Callable可以有返回值并且可以抛出异常。Future是一个接口,用于获取Callable任务的执行结果或取消任务的执行。FutureTask类实现了RunnableFuture接口(继承自Runnable和Future),既可以作为Runnable被线程执行,又可以作为Future获取Callable任务的执行结果。使用场景:在需要异步执行任务并获取任务执行结果的场景中,如异步计算、异步数据加载等。
Synchronized
synchronized 是 Java 中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
在 Java 6以前,synchronized 属于 重量级锁,效率低下。它的实现依赖于操作系统的互斥量(Mutex),线程在获取锁和释放锁时需要进行用户态和内核态的切换,这种切换的开销非常大,导致性能较低。(也就是挂起或唤醒线程进行线程上下文切换时,都需要从用户态转换成内核态)
在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。(JDK18 中,偏向锁已经被彻底废弃)锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
- 偏向锁:偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,当这个线程再次请求锁时,无需做任何同步操作,这样可以在单线程环境下提高性能。偏向锁适用于大多数情况下只有一个线程访问同步块的场景。
- 轻量级锁:轻量级锁是为了在没有多线程竞争的情况下减少传统重量级锁使用操作系统互斥量产生的性能开销。当线程尝试获取锁时,如果发现锁是偏向锁且偏向的线程不是自己,会尝试将偏向锁升级为轻量级锁。轻量级锁使用 CAS(Compare and Swap)操作来获取和释放锁,避免了用户态和内核态的切换。
- 锁粗化:锁粗化是指将多个连续的加锁、解锁操作合并为一个更大范围的加锁、解锁操作。例如,在一个循环中多次对同一个对象加锁和解锁,JVM 会将锁的范围扩大到循环外部,减少锁的竞争和同步开销。
- 锁消除:锁消除是指 JVM 在编译时,通过逃逸分析技术,发现某些代码块中的锁是不必要的,就会将这些锁消除。例如,在方法内部创建的对象,并且该对象不会被其他线程访问,那么对该对象的加锁操作就是不必要的,JVM 会将其消除。
使用
// 修饰实例方法,给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁 。
synchronized void method() {
//业务代码
}
// 修饰静态方法,当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。
synchronized static void method() {
//业务代码
}
// 修饰代码块,对括号里指定的对象/类加锁:synchronized(object)或synchronized(类.class)
synchronized(this) {
//业务代码
}
原理
synchronized 关键字底层原理属于 JVM 层面的东西。
同步语句块
synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。
当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。对象锁的的拥有者线程才可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
同步方法
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。不过两者的本质都是对对象监视器 monitor 的获取。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。
synchronized vs volatile
synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!
volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好 。但是volatile关键字只能用于变量而synchronized关键字可以修饰方法以及代码块 。volatile关键字能保证数据的可见性,但不能保证数据的原子性。synchronized关键字两者都能保证。volatile关键字主要用于解决变量在多个线程之间的可见性,而synchronized关键字解决的是多个线程之间访问资源的同步性。
ReentrantLock
ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。不过,ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。
public class ReentrantLock implements Lock, java.io.Serializable {}
ReentrantLock 里面有一个**内部类 Sync**,Sync 继承 AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在 Sync 中实现的。Sync 有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。
ReentrantLock 默认使用非公平锁,也可以通过构造器来显式的指定使用公平锁。ReentrantLock 的底层就是由 AQS 来实现的。
公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
- 优点:所有的线程都能得到资源,不会饿死在队列中。
- 缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。
非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。
- 非公平锁比公平锁效率高的原因主要在于减少了线程切换和同步操作的次数。
- 当线程在运行期间直接抢占到锁资源时,不需要进行“执行现场保存和恢复”的操作,从而能够更快地执行业务代码。相比之下,如果一个就绪态的线程想要获得锁资源,首先需要恢复现场,之后争抢锁(可能成功也可能失败),这个过程浪费了大量的CPU资源,只有在获取锁成功后才能继续执行业务代码。因此,非公平锁在效率上优于公平锁,主要原因就在于是否需要进行现场恢复和不同态之间的切换。非公平锁减少了线程挂起的几率,后来的线程有一定几率逃离被挂起的开销。
synchronized vs ReentrantLock
- 两者都是可重入锁。可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。
- 可重入锁主要用在线程需要多次进入临界区代码时,需要使用可重入锁。
- 每一个锁关联一个线程持有者和计数器,当计数器为 0 时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而调用相应的方法;当某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为 1;此时其它线程请求该锁,则必须等待;而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增;当线程退出同步代码块时,计数器会递减,如果计数器为 0,则释放该锁。
synchronized是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。- 相比
synchronized,ReentrantLock增加了一些高级功能:- 支持超时 :
ReentrantLock提供了tryLock(timeout)的方法,可以指定等待获取锁的最长等待时间,如果超过了等待时间,就会获取锁失败,不会一直等待。 - 等待可中断 :
ReentrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。- 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。
ReentrantLock就属于是可中断锁。 - 不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。
synchronized就属于是不可中断锁。
- 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。
- 可实现公平锁 :
ReentrantLock可以指定是公平锁还是非公平锁。而**synchronized只能是非公平锁**。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过ReentrantLock类的ReentrantLock(boolean fair)构造方法来指定是否是公平的。 - 可实现选择性通知(锁可以绑定多个条件):
synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。Condition是 JDK1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是Condition接口默认提供的。而**synchronized关键字就相当于整个Lock对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程,这样会造成很大的效率问题。而Condition实例的signalAll()方法,只会唤醒注册在该Condition实例中的所有等待线程。**
- 支持超时 :
public class MyRentrantlock {
Thread t = new Thread() {
@Override
public void run() {
ReentrantLock r = new ReentrantLock();
// 1.1、第一次尝试获取锁,可以获取成功
r.lock();
// 1.2、此时锁的重入次数为 1
System.out.println("lock() : lock count :" + r.getHoldCount());
// 2、中断当前线程,通过 Thread.currentThread().isInterrupted() 可以看到当前线程的中断状态为 true
interrupt();
System.out.println("Current thread is intrupted");
// 3.1、尝试获取锁,可以成功获取
r.tryLock();
// 3.2、此时锁的重入次数为 2
System.out.println("tryLock() on intrupted thread lock count :" + r.getHoldCount());
try {
// 4、打印线程的中断状态为 true,那么调用 lockInterruptibly() 方法就会抛出 InterruptedException 异常
System.out.println("Current Thread isInterrupted:" + Thread.currentThread().isInterrupted());
r.lockInterruptibly();
System.out.println("lockInterruptibly() --NOt executable statement" + r.getHoldCount());
} catch (InterruptedException e) {
r.lock();
System.out.println("Error");
} finally {
r.unlock();
}
// 5、打印锁的重入次数,可以发现 lockInterruptibly() 方法并没有成功获取到锁
System.out.println("lockInterruptibly() not able to Acqurie lock: lock count :" + r.getHoldCount());
r.unlock();
System.out.println("lock count :" + r.getHoldCount());
r.unlock();
System.out.println("lock count :" + r.getHoldCount());
}
};
public static void main(String str[]) {
MyRentrantlock m = new MyRentrantlock();
m.t.start();
}
}
ReentrantReadWriteLock
ReentrantReadWriteLock 实现了 ReadWriteLock ,是一个可重入的读写锁,既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。
ReentrantReadWriteLock内部维护了两个锁,一个是读锁(共享锁),一个是写锁(排他锁)。这两个锁是通过 AQS(AbstractQueuedSynchronizer,抽象队列同步器)来实现的。AQS 是一个用于构建锁和同步器的框架,它通过一个状态变量(state)来表示锁的状态。
public class ReentrantReadWriteLock
implements ReadWriteLock, java.io.Serializable{
}
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
ReentrantReadWriteLock 其实是两把锁,一把是 WriteLock (写锁),一把是 ReadLock(读锁) 。读锁是共享锁,写锁是独占锁。读锁可以被同时读,可以同时被多个线程持有,而写锁最多只能同时被一个线程持有。和 ReentrantLock 一样,ReentrantReadWriteLock 底层也是基于 AQS 实现的。ReentrantReadWriteLock 也支持公平锁和非公平锁,默认使用非公平锁,可以通过构造器来显示的指定。
在读多写少的情况下,使用 ReentrantReadWriteLock 能够明显提升系统性能。
- 共享锁:一把锁可以被多个线程同时获得。
- 独占锁:一把锁只能被一个线程获得。
读写锁进行并发控制的规则:读读不互斥、读写互斥、写写互斥(只有读读不互斥)。
在线程持有读锁的情况下,该线程不能取得写锁。在线程持有写锁的情况下,该线程可以继续获取读锁(可重入)。
当线程获取读锁的时候,可能有其他线程同时也在持有读锁,因此不能把获取读锁的线程“升级”为写锁;而对于获得写锁的线程,它一定独占了读写锁,因此可以继续让它获取读锁,当它同时获取了写锁和读锁后,还可以先释放写锁继续持有读锁,这样一个写锁就“降级”为了读锁。
- 读锁的获取与释放
- 获取读锁:多个线程可以同时获取读锁,只要没有线程持有写锁。在获取读锁时,会检查当前是否有线程持有写锁,如果没有,则将读锁的持有线程数增加。读锁的持有线程数是通过对
state的高 16 位进行记录的。 - 释放读锁:当线程释放读锁时,会将读锁的持有线程数减少。当读锁的持有线程数为 0 时,表示没有线程持有读锁。
- 获取读锁:多个线程可以同时获取读锁,只要没有线程持有写锁。在获取读锁时,会检查当前是否有线程持有写锁,如果没有,则将读锁的持有线程数增加。读锁的持有线程数是通过对
- 写锁的获取与释放
- 获取写锁:写锁是排他锁,同一时间只能有一个线程持有写锁。在获取写锁时,会检查当前是否有线程持有读锁或写锁,如果有,则当前线程会被阻塞,进入等待队列。如果没有,则将写锁的持有者设置为当前线程,并将
state的低 16 位设置为 1,表示持有写锁。 - 释放写锁:当线程释放写锁时,会将
state的低 16 位设置为 0,表示不再持有写锁,并唤醒等待队列中的线程。
- 获取写锁:写锁是排他锁,同一时间只能有一个线程持有写锁。在获取写锁时,会检查当前是否有线程持有读锁或写锁,如果有,则当前线程会被阻塞,进入等待队列。如果没有,则将写锁的持有者设置为当前线程,并将
StampedLock
StampedLock 是 JDK 1.8 引入的性能更好的读写锁,相比于 ReentrantReadWriteLock 等传统读写锁,它提供了更灵活和高效的并发控制方式。不可重入且不支持条件变量 Condition。
StampedLock 并不是直接实现 Lock或 ReadWriteLock接口,而是基于 CLH 锁 独立实现的(AQS 也是基于这玩意),CLH 锁是对自旋锁的一种改良,是一种隐式的链表队列。StampedLock 通过 CLH 队列进行线程的管理,通过同步状态值 state 来表示锁的状态和类型。
StampedLock 使用一个 stamp(时间戳)来表示锁的状态。stamp 是一个长整型数值,它在每次获取锁或释放锁时都会发生变化。StampedLock 支持三种锁模式:写锁(独占锁)、悲观读锁和乐观读锁。
- 写锁:独占锁,一把锁只能被一个线程获得。当一个线程获取写锁后,其他请求读锁和写锁的线程必须等待。类似于
ReentrantReadWriteLock的写锁,不过这里的写锁是不可重入的。 - 读锁 (悲观读):共享锁,没有线程获取写锁的情况下,多个线程可以同时持有读锁。如果己经有线程持有写锁,则其他线程请求获取该读锁会被阻塞。类似于
ReentrantReadWriteLock的读锁,不过这里的读锁是不可重入的。 - 乐观读:允许多个线程获取乐观读以及读锁。同时允许一个写线程获取写锁。
StampedLock 在获取锁的时候会返回一个 long 型的数据戳,该数据戳用于稍后的锁释放参数,如果返回的数据戳为 0 则表示锁获取失败。当前线程持有了锁再次获取锁还是会返回一个新的数据戳,这也是StampedLock不可重入的原因。
StampedLock比ReentrantReadWriteLock性能更好,主要体现在以下几个方面:
1、增加乐观读功能,减少写线程饥饿现象出现
当线程尝试获取乐观读锁时,StampedLock 会检查当前是否有写锁被持有。如果没有,它会增加一个读锁计数器并返回一个 stamp(通常是当前状态的一个快照)。乐观读锁不会阻塞其他读线程或写线程,但可能在写线程获得锁后读取到不一致的数据。
2、StampedLock要比ReentrantReadWriteLock消耗小
3、StampedLock增加了更多的无锁操作,使线程间阻塞减少到最小。
ThreadLocal
ThreadLocal类主要解决的就是让每个线程绑定自己的值,拥有自己的私有数据(专属本地变量)。如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal变量名的由来。他们可以使用 get() 和 set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。
ThreadLocal 适用于每个线程需要自己独立的实例且该实例需要在多个方法中被使用,也即变量在线程间隔离而在方法或类间共享的场景
对比synchronized
ThreadLocal和Synchonized都用于解决多线程并发访问。但是ThreadLocal与synchronized有本质的区别:
1、Synchronized用于线程间的数据共享,而ThreadLocal则用于线程间的数据隔离。
2、Synchronized是利用锁的机制,使变量或代码块在某一时该只能被一个线程访问。而ThreadLocal为每一个线程都提供了变量的副本,使得每个线程在某一时间访问到的并不是同一个对象,这样就隔离了多个线程对数据的数据共享。
原理
一句话理解ThreadLocal,ThreadLocal是作为当前线程Thread中 属性ThreadLocalMap集合 中的某一个Entry的key值Entry(threadlocal, value),虽然不同的线程之间ThreadLocal这个key值是一样,但是不同的线程所拥有的ThreadLocalMap是独一无二的,也就是不同的线程间同一个ThreadLocal(key)对应存储的值(value)不一样,从而到达了线程间变量隔离的目的,但是在同一个线程中这个value变量地址是一样的。
ThreadLocal的set()方法
public class Thread implements Runnable {
//......
//与此线程有关的ThreadLocal值。由ThreadLocal类维护。存储线程本地变量
ThreadLocal.ThreadLocalMap threadLocals = null;
//与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
//......
}
Thread 类中有一个 threadLocals 和 一个 inheritableThreadLocals 变量,它们都是 ThreadLocalMap 类型的变量,我们可以把 ThreadLocalMap 理解为ThreadLocal 类实现的定制化的 HashMap。默认情况下这两个变量都是 null,只有当前线程调用 ThreadLocal 类的 set或get方法时才创建它们,实际上调用这两个方法的时候,我们调用的是ThreadLocalMap类对应的 get()、set()方法。
ThreadLocalMap 的结构
ThreadLocalMap 是 ThreadLocal 的静态内部类,本质是一个自定义哈希表:
static class ThreadLocalMap {
// Entry 继承自 WeakReference,键是 ThreadLocal 实例
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value; // 实际存储的值
Entry(ThreadLocal<?> k, Object v) {
super(k); // 键是弱引用
value = v;
}
}
private Entry[] table; // 哈希表数组
private int size; // 元素数量
private int threshold; // 扩容阈值
}
public void set(T value) {
//1、获取当前线程
Thread t = Thread.currentThread();
//2、获取线程中的属性 threadLocalMap ,如果threadLocalMap 不为空,
//则直接更新要保存的变量值,否则创建threadLocalMap,并赋值
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
// 初始化thradLocalMap 并赋值
createMap(t, value);
}
ThreadLocal set赋值的时候首先会获取当前线程thread,并获取thread线程中的ThreadLocalMap属性。如果map属性不为空,则直接更新value值,如果map为空,则实例化threadLocalMap,并将value值初始化。
每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为 key ,Object 对象为 value 的键值对。比如在同一个线程中声明了两个 ThreadLocal 对象的话, Thread内部都是使用仅有的那个ThreadLocalMap 存放数据的,ThreadLocalMap的 key 就是 ThreadLocal对象,value 就是 ThreadLocal 对象调用set方法设置的值。
ThreadLocal 内存泄露问题是怎么导致的
ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。
这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后最好手动调用remove()方法
AQS
AQS 的全称为 AbstractQueuedSynchronizer ,翻译过来的意思就是抽象队列同步器。这个类在 java.util.concurrent.locks 包下面。AQS 就是一个抽象类,主要用来构建锁和同步器。使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 ReentrantLock,Semaphore,其他的诸如 ReentrantReadWriteLock,SynchronousQueue等等皆是基于 AQS 的。
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable {
}
AQS 解决了开发者在实现同步器时的复杂性问题。它提供了一个通用框架,用于实现各种同步器,例如 可重入锁(ReentrantLock)、信号量(Semaphore)和 倒计时器(CountDownLatch)。通过封装底层的线程同步机制,AQS 将复杂的线程管理逻辑隐藏起来,使开发者只需专注于具体的同步逻辑。
原理
AQS 提供了一种通用的机制来实现阻塞锁和相关的同步器,其核心思想是通过一个 FIFO 队列 和一个 状态变量 来管理线程的阻塞和唤醒。如果被请求的共享资源空闲,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中。
CLH:Craig、Landin and Hagersten队列,是单向链表,AQS中的队列是CLH变体的虚拟双向队列(FIFO)(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系),AQS是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配。
CLH 锁通过引入一个队列来组织并发竞争的线程,对自旋锁进行了改进:
- 每个线程会作为一个节点加入到队列中,并通过自旋监控前一个线程节点的状态,而不是直接竞争共享变量。
- 线程按顺序排队,确保公平性,从而避免了 “饥饿” 问题。
AQS(AbstractQueuedSynchronizer)在 CLH 锁的基础上进一步优化,形成了其内部的 CLH 队列变体。主要改进点有以下两方面:
自旋 + 阻塞: CLH 锁使用纯自旋方式等待锁的释放,但大量的自旋操作会占用过多的 CPU 资源。AQS 引入了 自旋 + 阻塞 的混合机制: 如果线程获取锁失败,会先短暂自旋尝试获取锁;如果仍然失败,则线程会进入阻塞状态,等待被唤醒,从而减少 CPU 的浪费。
单向队列改为双向队列:CLH 锁使用单向队列,节点只知道前驱节点的状态,而当某个节点释放锁时,需要通过队列唤醒后续节点。AQS 将队列改为 双向队列,新增了
next指针,使得节点不仅知道前驱节点,也可以直接唤醒后继节点,从而简化了队列操作,提高了唤醒效率。
核心组件:
- 状态变量(state):表示共享资源的状态,可以是锁的持有次数、信号量的许可数等。通过
getState()、setState()和compareAndSetState()方法操作。- 这里state的具体含义,会根据具体实现类的不同而不同:比如在Semapore里,他表示剩余许可证的数量;在CountDownLatch里,它表示还需要倒数的数量;在ReentrantLock中,state用来表示”锁”的占有情况,包括可重入计数,当state的值为O的时候,标识该Lock不被任何线程所占有。
- state是volatile修饰的,并被并发修改,所以修改state的方法都需要保证线程安全,比如getState、setState以及compareAndSetState操作来读取和更新这个状态。这些方法都依赖于unsafe类。
- FIFO 队列:一个双向链表,用于存储等待获取资源的线程。每个节点(
Node)包含线程(thread)、等待状态(waitStatus)和前驱/后继指针。- 这个队列用来存放“等待的线程,AQS就是“排队管理器”,当多个线程争用同一把锁时,必须有排队机制将那些没能拿到锁的线程串在一起。当锁释放时,锁管理器就会挑选一个合适的线程来占有这个刚刚释放的锁。
- 节点状态(waitStatus):
CANCELLED(1):线程已取消。SIGNAL(-1):当前节点的后继节点需要被唤醒。CONDITION(-2):节点在条件队列中等待。PROPAGATE(-3):共享模式下,释放资源时需要传播给后续节点。- 在 AQS 中,一个节点加入队列之后,初始状态为
0。 - 当有新的节点加入队列,此时新节点的前继节点状态就会由
0更新为SIGNAL,表示前继节点释放锁之后,需要对新节点进行唤醒操作。 - 如果一个节点在队列中等待获取锁锁时,因为某种原因失败了,该节点的状态就会变为
CANCELLED,表明取消获取锁,这种状态的节点是异常的,无法被唤醒,也无法唤醒后继节点。
- 在 AQS 中,一个节点加入队列之后,初始状态为
同步状态的获取和释放
- 独占模式
acquire(int arg):尝试以独占模式获取同步状态,如果获取失败则将当前线程加入到队列中等待。release(int arg):尝试以独占模式释放同步状态,如果释放成功则唤醒队列中的后继节点。
- 共享模式
acquireShared(int arg):尝试以共享模式获取同步状态,如果获取失败则将当前线程加入到队列中等待。releaseShared(int arg):尝试以共享模式释放同步状态,如果释放成功则唤醒队列中的后继节点。
基于 AQS 可以实现自定义的同步器, AQS 提供了 5 个模板方法(模板方法模式)。如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):
- 自定义的同步器继承
AbstractQueuedSynchronizer。 - 重写 AQS 暴露的模板方法。
AQS 使用了模板方法模式,自定义同步器时需要重写下面几个 AQS 提供的钩子方法:
//独占方式。尝试获取资源,成功则返回true,失败则返回false。
protected boolean tryAcquire(int)
//独占方式。尝试释放资源,成功则返回true,失败则返回false。
protected boolean tryRelease(int)
//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
protected int tryAcquireShared(int)
//共享方式。尝试释放资源,成功则返回true,失败则返回false。
protected boolean tryReleaseShared(int)
//该线程是否正在独占资源。只有用到condition才需要去实现它。
protected boolean isHeldExclusively()
什么是钩子方法呢? 钩子方法是一种被声明在抽象类中的方法,一般使用 protected 关键字修饰,它可以是空方法(由子类实现),也可以是默认实现的方法。模板设计模式通过钩子方法控制固定步骤的实现。
获取资源:
- acquire(int):acquire是一种以独占方式获取资源,如果获取到资源,线程直接返回,否则进入等待队列,直到获取到资源为止,且整个过程忽略中断的影响。该方法是独占模式下线程获取共享资源的顶层入口。获取到资源后,线程就可以去执行其临界区代码了。
// AQS
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
tryAcquire():尝试获取锁(模板方法),AQS不提供具体实现,由子类实现。addWaiter():如果获取锁失败,会将当前线程封装为 Node 节点加入到 AQS 的 CLH 变体队列中等待获取锁。acquireQueued():CAS对线程进行阻塞、唤醒,并调用tryAcquire()方法让队列中的线程尝试获取锁。
在 AQS 中,当前节点的唤醒需要依赖于上一个节点。如果上一个节点取消获取锁,它的状态就会变为 CANCELLED ,CANCELLED 状态的节点没有获取到锁,也就无法执行解锁操作对当前节点进行唤醒。因此在阻塞当前线程之前,需要跳过 CANCELLED 状态的节点。
shouldParkAfterFailedAcquire(Node pred, Node node):调整前驱节点的waitStatus为SIGNAL,确保后续唤醒。
关于selfInterrupt:
- 当
if判断为true时,需要tryAcquire()返回false,并且acquireQueued()返回true。 - 其中
acquireQueued()方法返回的是线程被唤醒之后的 中断状态 ,通过执行Thread.interrupted()来返回。该方法在返回中断状态的同时,会清除线程的中断状态。 - 因此如果
if判断为true,表明线程的中断状态为true,但是调用Thread.interrupted()之后,线程的中断状态被清除为false,因此需要重新执行selfInterrupt()来重新设置线程的中断状态。
调用自定义同步器的tryAcquire()尝试直接去获取资源,如果成功则直接返回;
没成功,则addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;
acquireQueued()使线程在等待队列中休息,有机会时(轮到自己,会被unpark())会去尝试获取资源。获取到资源后才返回。如果在整个等待过程中被中断过,则返回true(在true的情况下,才会执行selfInterrupt()),否则返回false。
如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。
释放资源:
AQS 中以独占模式释放资源的入口方法是 release() ,主要做两件事:尝试释放锁和唤醒后继节点。
// AQS
public final boolean release(int arg) {
// 1、尝试释放锁 -- 计算释放锁之后的 state 值,为0表明完全释放
if (tryRelease(arg)) {
Node h = head;
// 2、唤醒后继节点
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
线程池
线程池就是管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务。
核心参数,拒绝策略,任务提交流程,线程创建时机,销毁时机,线程池关闭
为什么要用线程池
线程池提供了一种限制和管理资源(包括执行一个任务)的方式。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。
使用线程池的好处:
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。(池化技术,减少每次获取资源的消耗,提高对资源的利用率)
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
创建线程池
/**
* 用给定的初始参数创建一个新的ThreadPoolExecutor。
*/
public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量
int maximumPoolSize,//线程池的最大线程数
long keepAliveTime,//===当线程数大于核心线程数时===,多余的空闲线程存活的最长时间
TimeUnit unit,//时间单位
BlockingQueue<Runnable> workQueue,//任务队列,用来储存等待执行任务的队列
ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可
RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}
最重要的三个参数:
corePoolSize: 任务队列未达到队列容量时,最大可以同时运行的线程数量。maximumPoolSize: 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。workQueue: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
使用示例
- execute用于提交不需要返回值的任务。
- submit用于提交需要返回值的任务。
- shutdown平缓关闭线程池,不再接受新任务,已提交任务继续执行。
- shutdownNow试图停止所有正在执行的活动任务,暂停处理正在等待的任务,并返回等待执行的任务列表。
// 实际项目中使用ThreadPoolExecutor的示例
import java.util.concurrent.*;
public class RequestHandler {
private final ThreadPoolExecutor executor;
public RequestHandler(int corePoolSize, int maxPoolSize, long keepAliveTime, TimeUnit unit, int queueCapacity) {
BlockingQueue<Runnable> workQueue = new LinkedBlockingQueue<>(queueCapacity);
this.executor = new ThreadPoolExecutor(corePoolSize, maxPoolSize, keepAliveTime, unit, workQueue);
}
public void handleRequest(Runnable task) {
// 提交一个任务
executor.execute(task);
}
// 停止线程池的方法,通常在服务停止时调用
public void shutdown() {
executor.shutdown();
}
}
线程池的拒绝策略
如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolExecutor 定义一些策略:
ThreadPoolExecutor.AbortPolicy:抛出RejectedExecutionException来拒绝新任务的处理。ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果你的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。- 将任务回退给调用者,使用调用者的线程来执行任务。除非线程池被停止或任务队列已有空缺。
ThreadPoolExecutor.DiscardPolicy:不做任何处理,直接丢弃掉,静默拒绝新任务。ThreadPoolExecutor.DiscardOldestPolicy:丢弃最早的未处理的任务请求,然后执行当前任务。- 自定义拒绝策略:实现RejectedExecutionHandler接口来自定义拒绝策略。
如果不允许丢弃任务,只能选择CallerRunsPolicy,问题:如果走到CallerRunsPolicy的任务是个非常耗时的任务,且处理提交任务的线程是主线程,可能会导致主线程阻塞,影响程序的正常运行。
解决思路
我们从问题的本质入手,调用者采用CallerRunsPolicy是希望所有的任务都能够被执行,暂时无法处理的任务又被保存在阻塞队列BlockingQueue中。这样的话,在内存允许的情况下,我们可以增加阻塞队列BlockingQueue的大小并调整堆内存以容纳更多的任务,确保任务能够被准确执行。
为了充分利用 CPU,我们还可以调整线程池的maximumPoolSize (最大线程数)参数,这样可以提高任务处理速度,避免累计在 BlockingQueue的任务过多导致内存用完。
进一步:为了保证任务不被丢弃且后续能被处理,可以把任务持久化到数据库/缓存/消息队列
如果服务器资源已达到可利用的极限,这就意味我们要在设计策略上改变线程池的调度了,我们都知道,导致主线程卡死的本质就是因为我们不希望任何一个任务被丢弃。换个思路,有没有办法既能保证任务不被丢弃且在服务器有余力时及时处理呢?
这里提供的一种任务持久化的思路,这里所谓的任务持久化,包括但不限于:
- 设计一张任务表将任务存储到 MySQL 数据库中。
Redis缓存任务。- 将任务提交到消息队列中。
线程池种类
1. 固定大小的线程池(FixedThreadPool)
核心线程数(corePoolSize)和最大线程数(maximumPoolSize)相等。
使用无界队列(
LinkedBlockingQueue)存储任务。线程池中的线程数量固定,不会动态增加或减少。
适合任务数量稳定且需要限制并发线程数的场景。例如:Web 服务器的请求处理。
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(10); // 10 个线程
2. 缓存线程池(CachedThreadPool)
核心线程数为 0,最大线程数为
Integer.MAX_VALUE。(线程数量不固定使用直接传递队列(
SynchronousQueue),任务不会排队,直接交给线程执行。空闲线程的存活时间为 60 秒,超过时间后会被回收。
适合任务数量不确定且任务处理时间短的场景。例如:短时异步任务处理。
ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
3. 单线程线程池(SingleThreadExecutor)
核心线程数和最大线程数均为 1。
使用无界队列(
LinkedBlockingQueue)存储任务。保证所有任务按顺序执行。
适合需要顺序执行任务的场景。例如:日志记录、任务调度。
ExecutorService singleThreadPool = Executors.newSingleThreadExecutor();
4. 定时任务线程池(ScheduledThreadPool)
核心线程数由用户指定,最大线程数为
Integer.MAX_VALUE。使用延迟队列(
DelayedWorkQueue)存储任务。支持定时任务和周期性任务。
适合需要定时执行或周期性执行任务的场景。例如:定时数据同步、心跳检测。
ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(5);
// 延迟 10 秒后执行任务
scheduledThreadPool.schedule(() -> System.out.println("Task executed"), 10, TimeUnit.SECONDS);
// 延迟 10 秒后,每隔 5 秒执行一次任务
scheduledThreadPool.scheduleAtFixedRate(() -> System.out.println("Task executed"), 10, 5, TimeUnit.SECONDS);
5.单线程定时任务线程池(SingleThreadScheduledExecutor)
用于在单个线程中调度定时任务或周期性任务。
6. 工作窃取线程池(WorkStealingPool)
基于 ForkJoinPool 实现。
线程池中的线程可以窃取其他线程的任务,充分利用 CPU 资源。
默认线程数为 CPU 核心数。
适合任务可以拆分为子任务的场景。例如:并行计算、分治算法。
ExecutorService workStealingPool = Executors.newWorkStealingPool();
7. 自定义线程池(ThreadPoolExecutor)
- 通过
ThreadPoolExecutor类自定义线程池参数。 - 可以灵活设置核心线程数、最大线程数、队列类型、线程存活时间、拒绝策略等。
线程池常用阻塞队列
不同的线程池会选用不同的阻塞队列,我们可以结合内置线程池来分析。
- 容量为
Integer.MAX_VALUE的LinkedBlockingQueue(无界队列):FixedThreadPool(可重用固定线程数的线程池) 和SingleThreadExector。FixedThreadPool最多只能创建核心线程数的线程(核心线程数和最大线程数相等),SingleThreadExector只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满。 SynchronousQueue(同步队列):CachedThreadPool(根据需要创建新线程的线程池) 。SynchronousQueue没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool的最大线程数是Integer.MAX_VALUE,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。CachedThreadPool的corePoolSize被设置为空(0),maximumPoolSize被设置为Integer.MAX.VALUE,即它是无界的,这也就意味着如果主线程提交任务的速度高于maximumPool中线程处理任务的速度时,CachedThreadPool会不断创建新的线程。极端情况下,这样会导致耗尽 cpu 和内存资源。
DelayedWorkQueue(延迟阻塞队列):ScheduledThreadPool和SingleThreadScheduledExecutor。DelayedWorkQueue的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达Integer.MAX_VALUE,所以最多只能创建核心线程数的线程。
对比:Java 中常用的阻塞队列实现类有以下几种:
ArrayBlockingQueue:使用数组实现的有界阻塞队列。在创建时需要指定容量大小,并支持公平和非公平两种方式的锁访问机制。LinkedBlockingQueue:使用单向链表实现的可选有界阻塞队列。在创建时可以指定容量大小,如果不指定则默认为Integer.MAX_VALUE。和ArrayBlockingQueue不同的是, 它仅支持非公平的锁访问机制。PriorityBlockingQueue:支持优先级排序的无界阻塞队列。元素必须实现Comparable接口或者在构造函数中传入Comparator对象,并且不能插入 null 元素。SynchronousQueue:同步队列,是一种不存储元素的阻塞队列。每个插入操作都必须等待对应的删除操作,反之删除操作也必须等待插入操作。因此,SynchronousQueue通常用于线程之间的直接传递数据。DelayQueue:延迟队列,其中的元素只有到了其指定的延迟时间,才能够从队列中出队。
假如我们需要实现一个优先级任务线程池的话,那可以考虑使用 PriorityBlockingQueue (优先级阻塞队列)作为任务队列(ThreadPoolExecutor 的构造函数有一个 workQueue 参数可以传入任务队列)。
优先级任务队列
PriorityBlockingQueue 是一个支持优先级的无界阻塞队列,可以看作是线程安全的 PriorityQueue,两者底层都是使用小顶堆形式的二叉堆,即值最小的元素优先出队。不过,PriorityQueue 不支持阻塞操作。
要想让 PriorityBlockingQueue 实现对任务的排序,传入其中的任务必须是具备排序能力的,方式有两种:
- 提交到线程池的任务实现
Comparable接口,并重写compareTo方法来指定任务之间的优先级比较规则。 - 创建
PriorityBlockingQueue时传入一个Comparator对象来指定任务之间的排序规则(推荐)。
不过,这存在一些风险和问题,比如:
PriorityBlockingQueue是无界的,可能堆积大量的请求,从而导致 OOM。- 解决:继承
PriorityBlockingQueue并重写一下offer方法(入队)的逻辑,当插入的元素数量超过指定值就返回 false 。
- 解决:继承
- 可能会导致饥饿问题,即低优先级的任务长时间得不到执行。
- 解决:优化设计,等待时间过长的任务会被移除并重新添加到队列中,但是优先级会被提升。
- 由于需要对队列中的元素进行排序操作以及保证线程安全(并发控制采用的是可重入锁
ReentrantLock),因此会降低性能。
线程池处理任务的流程
- 如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。
- 当提交一个新任务到线程池时,如果线程池中的线程数量小于核心线程数,即使其他工作线程是空闲的,也会创建一个新线程来处理该任务。
- 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。
- 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就创建一个新线程来执行当前提交的任务。
- 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,拒绝策略会调用
RejectedExecutionHandler.rejectedExecution()方法。
线程异常后,销毁还是复用
使用execute()提交任务:当任务通过execute()提交到线程池并在执行过程中抛出异常时,如果这个异常没有在任务内被捕获,那么该异常会导致当前线程终止,并且异常会被打印到控制台或日志文件中。线程池会检测到这种线程终止,并创建一个新线程来替换它,从而保持配置的线程数不变。
使用submit()提交任务:对于通过submit()提交的任务,如果在任务执行中发生异常,这个异常不会直接打印出来。相反,异常会被封装在由submit()返回的Future对象中。当调用Future.get()方法时,可以捕获到一个ExecutionException。在这种情况下,线程不会因为异常而终止,它会继续存在于线程池中,准备执行后续的任务。
execute和submit区别
1、返回结果:submit()方法可以接受并返回Future对象,用于表示异步任务的结果。你可以通过Future对象获取任务的执行结果,或者等待任务执行完成。而execute()方法没有返回值,无法获取任务的执行结果。
2、异常处理:submit()方法能够处理任务执行过程中抛出的异常。你可以通过调用Future对象的get()方法来获取任务执行过程中的异常,或者通过捕获ExecutionException异常来处理异常情况。而execute()方法无法处理任务执行过程中的异常,异常会被传播到线程池的未捕获异常处理器(UncaughtExceptionHandler)。
3、方法重载:submit()方法有多种重载形式,可以接受Runnable、Callable和其他可执行任务作为参数。它们的返回值类型分别为Future、Future和Future,其中T为Callable返回结果的类型。这使得submit()方法更加灵活,可以处理不同类型的任务。而execute()方法只接受Runnable类型的任务作为参数,没有方法重载的选项。
Runnable与Callable
- Callable规定的方法是 call(), Runnable规定的方法是 run()。
- Callable的任务执行后可返回值,而 Runnable的任务是不能返回值。
- call方法可以抛出异常, run方法不可以。
- 运行 Callable任务可以拿到一个 Future对象
shutdown() VS shutdownNow()
shutdown():关闭线程池,线程池的状态变为SHUTDOWN。线程池不再接受新任务了,但是队列里的任务得执行完毕。shutdownNow():关闭线程池,线程池的状态变为STOP。线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List。
isTerminated() VS isShutdown()
isShutDown当调用shutdown()方法后返回为 true。isTerminated当调用shutdown()方法后,并且所有提交的任务完成后返回为 true
设定线程池大小
- 如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的,CPU 根本没有得到充分利用。
- 如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。
有一个简单并且适用面比较广的公式:
- CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。过多的线程会导致频繁的上下文切换,反而降低性能。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
- 最大线程数(maximumPoolSize):与核心线程数相同,避免创建过多线程。
- 队列容量(workQueue):使用有界队列(如
ArrayBlockingQueue),防止任务堆积。 - 拒绝策略(RejectedExecutionHandler):根据业务需求选择。
- I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。由于任务会频繁等待 IO,可以创建更多线程以充分利用 CPU。
- 最大线程数(maximumPoolSize):根据任务的平均等待时间和 CPU 负载动态调整。
- 可以设置为较大的值(如
2 * CPU 核心数 + 1或更高)。
- 可以设置为较大的值(如
- 最大线程数(maximumPoolSize):根据任务的平均等待时间和 CPU 负载动态调整。
CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。
线程池实践规范
线程池必须手动通过 ThreadPoolExecutor 的构造函数来声明,避免使用Executors类创建线程池,会有 OOM 风险。使用有界队列,控制线程创建数量。
除了避免 OOM 的原因之外,不推荐使用 Executors提供的两种快捷的线程池的原因还有:
- 实际使用中需要根据自己机器的性能、业务场景来手动配置线程池的参数比如核心线程数、使用的任务队列、饱和策略等等。
- 我们应该显示地给我们的线程池命名,这样有助于我们定位问题。
不同的业务使用不同的线程池,配置线程池的时候根据当前业务的情况对当前线程池进行配置,因为不同的业务的并发以及对资源的使用情况都不同,重心优化系统性能瓶颈相关的业务。如果父业务和子业务调用同一个线程池,可能产生死锁;
当线程池不再需要使用时,应该显式地关闭线程池,释放线程资源。
调用完 shutdownNow 和 shuwdown 方法后,并不代表线程池已经完成关闭操作,它只是异步的通知线程池进行关闭处理。如果要同步等待线程池彻底关闭后才继续往下执行,需要调用awaitTermination方法进行同步等待。
在调用 awaitTermination() 方法时,应该设置合理的超时时间,以避免程序长时间阻塞而导致性能问题。另外。由于线程池中的任务可能会被取消或抛出异常,因此在使用 awaitTermination() 方法时还需要进行异常处理。awaitTermination() 方法会抛出 InterruptedException 异常,需要捕获并处理该异常,以避免程序崩溃或者无法正常退出。
线程池本身的目的是为了提高任务执行效率,避免因频繁创建和销毁线程而带来的性能开销。如果将耗时任务提交到线程池中执行,可能会导致线程池中的线程被长时间占用,无法及时响应其他任务,甚至会导致线程池崩溃或者程序假死。
因此,在使用线程池时,我们应该尽量避免将耗时任务提交到线程池中执行。对于一些比较耗时的操作,如网络请求、文件读写等,可以采用异步操作的方式来处理,以避免阻塞线程池中的线程
线程池和 ThreadLocal共用,可能会导致线程从ThreadLocal获取到的是旧值/脏数据。这是因为线程池会复用线程对象,与线程对象绑定的类的静态属性 ThreadLocal 变量也会被重用,这就导致一个线程可能获取到其他线程的ThreadLocal 值。
Future类
Future 是 Java 提供的一个接口,用于表示异步计算的结果。它允许我们在一个线程中提交一个任务,然后在另一个线程中获取任务的执行结果。虽然 Future 提供了一种简单的方式来进行异步编程,但它的功能有限,不能很好地处理复杂的并发场景。这其实就是多线程中经典的 Future 模式,你可以将其看作是一种设计模式,核心思想是异步调用,主要用在多线程领域,并非 Java 语言独有。
- Callable:代表一个可异步执行的任务,通常包含需要返回结果的逻辑。
- Future:作为异步计算的句柄,用于跟踪任务状态(如是否完成、取消)和获取结果。任务提交后立即返回 Future,允许程序继续执行其他操作。
在 Java 中,Future 类只是一个泛型接口,位于 java.util.concurrent 包下,其中定义了 5 个方法,主要包括下面这 4 个功能:
- 取消任务;mayInterruptIfRunning参数表示是否允许中断已经
- 判断任务是否被取消;
- 判断任务是否已经执行完成;
- 获取任务执行结果。
// V 代表了Future执行的任务返回值的类型
public interface Future<V> {
// 取消任务执行
// 成功取消返回 true,否则返回 false
boolean cancel(boolean mayInterruptIfRunning);
// 判断任务是否被取消
boolean isCancelled();
// 判断任务是否已经执行完成
boolean isDone();
// 获取任务执行结果
V get() throws InterruptedException, ExecutionException;
// 指定时间内没有返回计算结果就抛出 TimeOutException 异常
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException
}
FutureTask 提供了 Future 接口的基本实现,常用来封装 Callable 和 Runnable。ExecutorService.submit() 方法返回的其实就是 Future 的实现类 FutureTask 。FutureTask 实现了Runnable 接口,因此可以作为任务直接被线程执行。
FutureTask 有两个构造函数,可传入 Callable 或者 Runnable 对象。实际上,传入 Runnable 对象也会在方法内部转换为Callable 对象。
FutureTask相当于对Callable 进行了封装,管理着任务执行的情况,存储了 Callable 的 call 方法的任务执行结果。
import java.util.concurrent.*;
public class FutureExample {
public static void main(String[] args) {
// 创建线程池
ExecutorService executor = Executors.newSingleThreadExecutor();
// 提交任务并获取 Future 对象
Future<String> future = executor.submit(() -> {
Thread.sleep(2000); // 模拟耗时操作
return "Task completed";
});
System.out.println("Task submitted");
try {
// 获取任务结果(阻塞直到任务完成)
String result = future.get();
System.out.println("Task result: " + result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
// 关闭线程池
executor.shutdown();
}
}
CompletableFuture
Future 在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get() 方法为阻塞调用。
Java 8 才被引入CompletableFuture 类可以解决Future 的这些缺陷。CompletableFuture 除了提供了更为好用和强大的 Future 特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。
CompletableFuture 同时实现了 Future 和 CompletionStage 接口。
CompletionStage 接口描述了一个异步计算的阶段。很多计算可以分成多个阶段或步骤,此时可以通过它将所有步骤组合起来,形成异步计算的流水线。CompletionStage 接口中的方法比较多,**CompletableFuture 的函数式能力就是这个接口赋予的。**
创建方式:
//使用默认内置线程池ForkJoinPool.commonPool(),根据supplier构建执行任务
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier)
//自定义线程,根据supplier构建执行任务 -- supply支持返回值
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier, Executor executor)
//使用默认内置线程池ForkJoinPool.commonPool(),根据runnable构建执行任务
public static CompletableFuture<Void> runAsync(Runnable runnable)
//自定义线程,根据runnable构建执行任务 --- run不支持返回值
public static CompletableFuture<Void> runAsync(Runnable runnable, Executor executor)
获取结果:
@Test
public void testCompletableGet() throws InterruptedException, ExecutionException {
CompletableFuture<String> cp1 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "商品A";
});
// getNow方法测试 -- 立即获取结果不阻塞,结果计算已完成将返回结果或计算过程中的异常,如果未计算完成将返回设定的 valueIfAbsent 参数值,这里会输出商品B,因为cp1没有执行完成,getNow就已经获取了
System.out.println(cp1.getNow("商品B"));
//join方法测试
CompletableFuture<Integer> cp2 = CompletableFuture.supplyAsync((() -> 1 / 0));
// join 方法获取结果方法里不会抛异常,但是执行结果会抛异常,抛出的异常为 CompletionException
System.out.println(cp2.join());
System.out.println("-----------------------------------------------------");
//get方法测试
CompletableFuture<Integer> cp3 = CompletableFuture.supplyAsync((() -> 1 / 0));
// get 方法获取结果方法里将抛出异常,执行结果抛出的异常为 ExecutionException
System.out.println(cp3.get());
}
异步回调:
| 方法 | 作用 | 返回值 |
|---|---|---|
thenApply |
对任务结果进行转换,有传参,有返回值 | CompletableFuture<U> |
thenAccept |
消费任务结果,有传参,不返回新值 | CompletableFuture<Void> |
thenRun |
在任务完成后执行操作,无传参,无返回值 | CompletableFuture<Void> |
thenCompose |
将两个任务串联。 | CompletableFuture<U> |
thenCombine |
将两个任务的结果合并。 | CompletableFuture<U> |
allOf |
等待所有任务完成。 | CompletableFuture<Void> |
anyOf |
等待任意一个任务完成。 | CompletableFuture<Object> |
exceptionally** |
处理异常,返回默认值。 | CompletableFuture<T> |
handle |
处理结果和异常,返回新结果。 | CompletableFuture<U> |
whenComplete |
在任务完成后执行操作,可访问结果和异常。 | CompletableFuture<T> |
supplyAsync |
异步执行有返回值的任务。 | CompletableFuture<T> |
runAsync |
异步执行无返回值的任务。 | CompletableFuture<Void> |
complete |
手动完成任务并设置结果。 | boolean |
completeExceptionally |
手动完成任务并设置异常。 | boolean |
getNow |
获取任务结果,未完成则返回默认值。 | T |
「thenRun 和 thenRunAsync 有什么区别呢?」
如果你执行第一个任务的时候,传入了一个自定义线程池:
- 调用 thenRun 方法执行第二个任务时,则第二个任务和第一个任务是共用同一个线程池。
- 调用 thenRunAsync 执行第二个任务时,则第一个任务使用的是你自己传入的线程池,第二个任务使用的是 ForkJoin 线程池。
说明: thenAccept 和 thenAcceptAsync,thenApply 和 thenApplyAsync 等,它们之间的区别也是这个。
thenCompose
作用:将两个任务串联起来,前一个任务的结果作为后一个任务的输入。
返回值:返回一个新的
CompletableFuture。示例:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Hello") .thenCompose(s -> CompletableFuture.supplyAsync(() -> s + " World")); System.out.println(future.get()); // 输出 "Hello World"
thenCombine
作用:将两个任务的结果合并。
返回值:返回一个新的
CompletableFuture。示例:
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> "Hello"); CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> "World"); CompletableFuture<String> result = future1.thenCombine(future2, (s1, s2) -> s1 + " " + s2); System.out.println(result.get()); // 输出 "Hello World"
allOf
作用:等待所有任务完成。
返回值:返回一个
CompletableFuture<Void>。示例:
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> "Hello"); CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> "World"); CompletableFuture<Void> allFutures = CompletableFuture.allOf(future1, future2); allFutures.join(); // 等待所有任务完成
注意事项:Future 需要获取返回值,才能获取到异常信息。如果不加 get()/join()方法,看不到异常信息。
自定义线程池
CompletableFuture 默认使用全局共享的 ForkJoinPool.commonPool() 作为执行器,所有未指定执行器的异步任务都会使用该线程池。这意味着应用程序、多个库或框架(如 Spring、第三方库)若都依赖 CompletableFuture,默认情况下它们都会共享同一个线程池。
虽然 ForkJoinPool 效率很高,但当同时提交大量任务时,可能会导致资源竞争和线程饥饿,进而影响系统性能。
为避免这些问题,建议为 CompletableFuture 提供自定义线程池,带来以下优势:
- 隔离性:为不同任务分配独立的线程池,避免全局线程池资源争夺。
- 资源控制:根据任务特性调整线程池大小和队列类型,优化性能表现。
- 异常处理:通过自定义
ThreadFactory更好地处理线程中的异常情况。
JVM
内存模型
字节码文件(.class)会通过类加载器加载到JVM虚拟机中,接下来JVM虚拟机就会执行其中的字节码指令。我们把JVM虚拟机被分配的内存叫做运行时数据区域。而内存模型就是指运行时数据区域中被划分的不同区域。
- JDK1.6:字符串常量池存放在方法区中,方法区存放在堆中;此时方法区的实现叫永久代。
- JDK1.7:字符串常量池离开方法区,直接存放在堆内存;
- JDK1.8:方法区发生移动,从JVM虚拟机内存中,移动到本地内存中。此时方法区的实现叫元空间。
- 运行时常量池在元空间,元空间和直接内存都属于本地内存。
- 元空间属于JVM 运行时数据区域,而直接内存不属于。
Java虚拟机(JVM)的内存模型是Java程序运行时内存管理的基础。它定义了Java程序如何在内存中分配、使用和回收资源。
线程私有程序计数器,虚拟机栈,本地方法栈(在并发–线程里面也记录了)
JVM内存结构如下:
- 元空间:元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。
- 从Java 8开始,永久代(Permanent Generation)被元空间取代,用于存储类的元数据信息,如类的结构信息(如字段、方法信息等)。元空间并不在Java堆中,而是使用本地内存,这解决了永久代容易出现的内存溢出问题。
- 方法区存储内容:类信息(结构信息、访问修饰符、父类与接口信息)、类中常量、类和方法的符号引用及运行时常量池、常量池缓存、静态变量、方法字节码
- Java虚拟机栈:每个线程有一个私有的栈,随着线程的创建而创建。栈里面存着的是一种叫“栈帧”的东西,每个方法会创建一个栈帧,栈帧中存放了局部变量表(基本数据类型和对象引用)、操作数栈、方法出口等信息。栈的大小可以固定也可以动态扩展。
- 本地方法栈:与虚拟机栈类似,区别是虚拟机栈执行Java方法,本地方法栈执行native方法。在虚拟机规范中对本地方法栈中方法使用的语言、使用方法与数据结构没有强制规定,因此虚拟机可以自由实现它。
- 程序计数器:程序计数器可以看成是当前线程所执行的字节码的行号指示器。在任何一个确定的时刻,一个处理器(对于多内核来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,我们称这类内存区域为“线程私有”内存。
- 堆内存:堆内存是JVM所有线程共享的部分,在虚拟机启动的时候就已经创建。所有的对象实例和数组都在堆上分配,这部分空间可通过GC进行回收。当申请不到空间时会抛出OutOfMemoryError。堆是JVM内存占用最大、管理最复杂的一个区域。
- 直接内存:直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。在JDK1.4中新加入了NIO类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引l用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
为啥要把方法区从JVM内存(永久代)移到本地内存(元空间)?主要有两个原因:
- 本地内存属于本地系统的IO操作,具有更高的一个IO操作性能,而JVM的堆内存这种,如果有IO操作,也是先复制到直接内存,然后再去进行本地IO操作。经过了一系列的中间流程,性能就会差一些。非直接内存操作:
本地IO操作——>直接内存操作——>非直接内存操作——>直接内存操作——>本地IO操作,而直接内存操作:本地IO操作——>直接内存操作——>本地IO操作。 - 永久代有一个无法调整更改的JVM固定大小上限,回收不完全时,会出现
OutOfMemoryError问题;而本地内存(元空间)是受到本地机器内存的限制,不会有这种问题。
堆和栈
JVM内存模型里堆vs栈:
- 用途:栈存储局部变量、方法调用参数、方法返回地址和临时数据,每个线程有独立的栈,用于支持方法执行。每当一个方法被调用,一个栈帧就会在栈中创建,用于存储该方法的信息,方法执行完毕栈帧就被移除。堆存储所有对象实例和数组,是JVM中最大的一块内存区域。所有线程共享堆内存。
- 可见性:堆-所有线程共享,需处理并发安全问题(如通过锁或CAS)。栈-线程私有,生命周期与线程一致,无需考虑多线程同步。
- 生命周期:堆-对象的生命周期由垃圾回收器(GC)管理,对象不再被引用时会被回收。栈-内存自动分配和释放。方法执行时创建栈帧,方法结束后栈帧弹出,内存立即回收。
- 存储内容:堆-存储对象实例(如 new Object())和静态变量(在方法区,Java 8后移至元空间)。栈-存储基本数据类型(如 int、boolean)和对象引用(如 Object obj = new MyObject()中的 obj)。
- 存取速度:栈-访问速度更快(直接操作栈顶,后进先出,无碎片问题)。堆-访问较慢(对象在堆上的分配和回收耗时长,需通过引用寻址,可能涉及内存碎片和GC开销)。
堆内存管理
在Java虚拟机(JVM)中,堆(Heap) 是内存管理的核心区域,用于存储对象实例和数组。为了提高垃圾回收(GC)效率,堆被划分为不同的代(Generations),每个代针对对象的生命周期特点采用不同的管理策略。
堆的分代基于分代收集理论(Generational Collection Theory),核心思想是:
- 大部分对象是“朝生夕死”的(如临时对象、局部变量)。
- 存活较久的对象(如缓存、全局配置)会逐渐晋升到老年代。
- 不同代的GC频率和策略不同,以优化性能。
- 新生代(Young Generation):新生代分为Eden Space和Survivor Space。在Eden Space中,大多数新创建的对象首先存放在这里。当Eden区满时,会触发一次MinorGC(新生代垃圾回收)。在Survivor Spaces中,通常分为两个相等大小的区域,称为S0(Survivor 0)和S1(Survivor 1)。在每次MinorGC后,存活下来的对象会被移动到其中一个Survivor空间,以继续它们的生命周期。这两个区域轮流充当对象的中转站,帮助区分短暂存活的对象和长期存活的对象。
- Eden区
- 作用:大多数新创建的对象首先分配在Eden区。
- 特点:
- 占新生代的绝大部分空间(默认比例:
Eden : Survivor(s0和s1) = 8:1:1,可通过-XX:SurvivorRatio调整)。 - 频繁触发Minor GC(针对新生代的垃圾回收)。
- 占新生代的绝大部分空间(默认比例:
- 对象分配:
- 当Eden区满时,触发Minor GC,存活对象被复制到Survivor区。
- 若对象过大(如大数组),可能直接进入老年代(避免复制开销)。
- Survivor区(From & To)
- 作用:存放从Eden区或另一个Survivor区晋升的存活对象。
- 特点:
- 两个Survivor区(From和To)大小相等,始终有一个是空的。
- 采用复制算法:Minor GC时,存活对象从Eden和From区复制到To区,并清空原区域。
- 对象每经历一次Minor GC,年龄(Age)加1。
- 晋升老年代:
- 对象年龄达到阈值(默认15,通过
-XX:MaxTenuringThreshold配置)。 - Survivor区空间不足时,存活对象直接进入老年代。
- 对象年龄达到阈值(默认15,通过
- 老年代(Old Generation/TenuredGeneration):经过一次或多次MinorGC仍存活的对象会被移动到老年代。老年代中的对象生命周期较长,因此MajorGC(也称为Full GC,涉及老年代的垃圾回收)发生的频率相对较低,但其执行时间通常比MinorGC长。老年代的空间通常比新生代大,以存储更多的长期存活对象。
- 大对象区(Large Object Space/Humongous Objects):在某些JVM实现中(如G1垃圾收集器),为大对象分配了专门的区域,称为大对象区或HumongousObjects区域。大对象是指需要大量连续内存空间的对象,如大数组。这类对象直接分配在老年代,以避免因频繁的年轻代晋升而导致的内存碎片化问题。
堆的工作流程示例
- 对象分配:新对象进入Eden区。
- Minor GC:Eden满时触发,存活对象复制到Survivor区(To)。
- Survivor区轮换:From和To区角色交换,清空旧的From区。
- 晋升老年代:对象年龄达标或Survivor区不足时晋升。
- Full GC:老年代不足时触发,回收整个堆,可能导致应用暂停。
堆分代的优势
- 降低GC开销:高频Minor GC仅处理新生代(小区域),减少停顿时间。
- 适应对象生命周期:区分短命和长命对象,针对性优化回收策略。
- 提升内存利用率:避免频繁扫描老年代对象。
方法执行过程
当程序中通过对象或类直接调用某个方法时,主要包括以下几个步骤:
- 解析方法调用:JVM会根据方法的符号引用找到实际的方法地址(如果之前没有解析过的话)。
- 栈帧创建:在调用一个方法前,JVM会在当前线程的Java虚拟机栈中为该方法分配一个新的栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
- 局部变量表(用于存储局部变量和参数)。
- 操作数栈(用于存储计算过程中的临时数据)。
- 动态链接(指向方法所属类的运行时常量池的引用)。
- 方法返回地址(记录方法执行完毕后返回的位置)。
- 执行方法:执行方法内的字节码指令,涉及的操作可能包括局部变量的读写、操作数栈的操作、跳转控制、对象创建、方法调用等
- 返回处理:方法执行完毕后,可能会返回一个结果给调用者,并清理当前栈帧,恢复调用者的执行环境。
引用类型
从JDK 1.2版本开始,对象的引用被划分为4种级别,从而使程序能更加灵活地控制对象的生命周期,这4种级别由高到低依次为:强引用、软引用、弱引用和虚引用。
- 强引用指的就是代码中普遍存在的赋值方式,比如
A a=new A()这种。强引用关联的对象,永远不会被GC回收。当内存空间不足时,JVM 宁愿抛出 OutOfMemoryError异常。如果强引用对象不使用时,需要弱化从而使GC能够回收(如对象赋值null)- 显式地设置强引用对象为null,或让其超出对象的生命周期范围,则GC认为该对象不存在引用,这时就可以回收这个对象,具体什么时候收集这要取决于GC算法。
- 软引用可以用SoftReference来描述,指的是那些有用但是不是必须要的对象。系统在发生内存溢出前会对这类引用的对象进行回收(在OOM前触发)。
- 软引用通常用在对内存敏感的程序中,比如高速缓存就有用到软引用,内存够用的时候就保留,不够用就回收。
- 弱引用可以用WeakReference来描述,他的强度比软引用更低一点,弱引I用的对象下一次GC的时候一定会被回收,而不管内存是否足够。
- 虚引用也被称作幻影引用,是最弱的引用关系,可以用PhantomReference来描述,他必须和ReferenceQueue一起使用,同样的当发生GC的时候,虚引用也会被回收。可以用虚引用来管理堆外内存。
- 如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。
- 无法通过虚引用获取对象(
get()始终返回null)。 - 虚引用仅用于跟踪对象被回收的状态。
- 对象被回收时,虚引用会被加入关联的
ReferenceQueue。
软引用
我们看下 Mybatis 缓存类 SoftCache 用到的软引用:
public Object getObject(Object key) {
Object result = null;
SoftReference<Object> softReference = (SoftReference)this.delegate.getObject(key);
if (softReference != null) {
result = softReference.get();
if (result == null) {
this.delegate.removeObject(key);
} else {
synchronized(this.hardLinksToAvoidGarbageCollection) {
this.hardLinksToAvoidGarbageCollection.addFirst(result);
if (this.hardLinksToAvoidGarbageCollection.size() > this.numberOfHardLinks) {
this.hardLinksToAvoidGarbageCollection.removeLast();
}
}
}
}
return result;}
注意:软引用对象是在jvm内存不够的时候才会被回收,我们调用System.gc()方法只是起通知作用,JVM什么时候扫描回收对象是JVM自己的状态决定的,就算扫描到软引用对象也不一定会回收它,只有内存不够的时候才会回收。
弱引用
WeakHashMap
public class WeakHashMapDemo {
public static void main(String[] args) throws InterruptedException {
myHashMap();
myWeakHashMap();
}
public static void myHashMap() {
HashMap<String, String> map = new HashMap<String, String>();
String key = new String("k1");
String value = "v1";
map.put(key, value);
System.out.println(map);
key = null;
System.gc();
System.out.println(map);
}
public static void myWeakHashMap() throws InterruptedException {
WeakHashMap<String, String> map = new WeakHashMap<String, String>();
//String key = "weak";
// 刚开始写成了上边的代码
//思考一下,写成上边那样会怎么样? 那可不是引用了
String key = new String("weak");
String value = "map";
map.put(key, value);
System.out.println(map);
//去掉强引用
key = null;
System.gc();
Thread.sleep(1000);
System.out.println(map);
}}
当key只有弱引用时,GC发现后会自动清理键和值,作为简单的缓存表解决方案。
ThreadLocal
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
//......}
ThreadLocal.ThreadLocalMap.Entry 继承了弱引用,key为当前线程实例,和WeakHashMap基本相同。
内存泄漏和内存溢出
| 特性 | 内存泄漏(Memory Leak) | 内存溢出(Out of Memory) |
|---|---|---|
| 定义 | 对象不再使用,但无法被回收,导致内存占用增加。 | 内存不足,无法分配新对象,导致程序崩溃。 |
| 原因 | 长生命周期对象持有短生命周期对象的引用:如静态数据结构存储对象,事件监听,未停止的线程、数据库连接 | 内存泄漏、内存设置过小、大对象或频繁创建对象(深度递归导致栈溢出)。 |
| 表现 | 内存逐渐耗尽,程序性能下降。 | 直接抛出 OutOfMemoryError,程序崩溃。 |
| 解决方法 | 清理无用对象、使用弱引用、分析内存泄漏。 | 增加内存、优化代码、分析内存泄漏。 |
| 关系 | 内存泄漏可能导致内存溢出。 | 内存溢出可能是内存泄漏的结果。 |
内存溢出:
ThreadLocal内存泄漏问题
ThreadLocal 为每个线程维护一个独立的变量副本。
每个线程内部有一个 ThreadLocalMap,用于存储该线程的 ThreadLocal 变量。
ThreadLocalMap 的键是 ThreadLocal 对象,值是该线程的变量副本。
键的弱引用
ThreadLocalMap的键(即ThreadLocal对象)是 弱引用(WeakReference)。- 弱引用的特点是:如果只有弱引用指向某个对象,则下一次GC时该对象会被垃圾回收器回收。
- 当
ThreadLocal对象没有强引用时(例如设置为null),它会被垃圾回收,导致ThreadLocalMap中的键为null。
值的强引用
ThreadLocalMap的值(即线程的变量副本)是 强引用。- 即使
ThreadLocal对象被回收,**ThreadLocalMap中的值仍然存在,**因为值是被强引用的。
线程的生命周期
- 如果线程是线程池中的线程(如
ThreadPoolExecutor),线程不会被销毁,而是会被复用。 - 如果
ThreadLocal对象被回收,但ThreadLocalMap中的值没有被清理,这些值会一直占用内存,导致内存泄漏。
ThreadLocal内存泄漏示例:
public class ThreadLocalLeakExample {
private static ThreadLocal<byte[]> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(1);
executor.execute(() -> {
threadLocal.set(new byte[1024 * 1024]); // 设置一个大对象
// 使用完后未清理
});
// 模拟长时间运行
while (true) {
// ...
}
}
}
在上述代码中:
- 线程池中的线程执行任务时,
threadLocal设置了一个大对象。 - 任务执行完后,
threadLocal没有被清理。 - 由于线程池中的线程不会被销毁,
ThreadLocalMap中的值会一直存在,导致内存泄漏。
如何解决此问题?
- 第一,使用ThreadLocal提供的remove方法,可对当前线程中的value值进行移除;
- 第二,不要使用ThreadLocal.set(null)的方式清除value,它实际上并没有清除值,而是查找与当前线程关联的Map并将键值对分别设置为当前线程和null。
- 第三,最好将ThreadLocal视为需要在finally块中关闭的资源,以确保即使在发生异常的情况下也始终关闭该资源。
类初始化和加载
对象创建过程
Step1:类加载检查
虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
Step2:分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
内存分配的两种方式 :
- 指针碰撞:
- 适用场合:堆内存规整(即没有内存碎片)的情况下。
- 原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
- 使用该分配方式的 GC 收集器:Serial, ParNew
- 空闲列表:
- 适用场合:堆内存不规整的情况下。
- 原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。
- 使用该分配方式的 GC 收集器:CMS
Step3:初始化零值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
Step4:进行必要设置如对象头
初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
Step5:执行 init 方法
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,构造函数,即class文件中的方法还没有执行(<init> 方法还没有执行),所有的字段都还为零,对象需要的其他资源和状态信息还没有按照预定的意图构造好。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
对象内存布局
在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头:对象头是对象内存布局的第一部分,主要用于存储对象的元数据和运行时信息。它包括以下两部分:
- 标记字段(Mark Word):用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。
- 类型指针(Klass pointer):指向对象的类元数据(即类的 Class 对象),用于确定对象属于哪个类的实例。
实例数据:实例数据部分是对象真正存储的有效信息,包括程序中定义的各种类型的字段内容。
对齐填充(Padding):对齐填充是对象内存布局的最后一部分,用于确保对象的大小是 8 字节的整数倍。
- 对象填充不是必然存在,只是用于占位。
- HotSpot 虚拟机要求对象的起始地址必须是 8 字节的整数倍。
- 对象头部分正好是 8 字节的倍数(1 倍或 2 倍)。如果实例数据部分未对齐,则通过对齐填充来补全。
类加载过程
在 Java 中,类的生命周期是指从类被加载到虚拟机内存中,到类被卸载出内存的整个过程。类的生命周期包括以下几个阶段。其中验证、准备和解析可以统称为连接。
加载:将字节码文件加载到内存。触发条件:创建类的实例。访问类的静态字段或静态方法。使用反射加载类。初始化类的子类时,父类会被加载。
- 通过全类名获取定义此类的二进制字节流。
- 将字节流所代表的静态存储结构转换为方法区的运行时数据结构。
- 在内存中生成一个代表该类的
Class对象,作为方法区这些数据的访问入口。
验证:确保字节码文件合法。确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
- 文件格式验证(如魔数、版本号)。
- 元数据验证(如类是否有父类、字段和方法是否合法)。
- 字节码验证(如操作数栈类型是否匹配)。
- 符号引用验证(如引用的类、字段和方法是否存在)。
准备:为类中的静态字段分配内存并设置默认的初始值。final修饰的static字段不设置,因为编译时候已经分配过了。
解析:将常量池的符号引用替换为直接引用。
- 符号引用:一组符号描述所引用的目标(如类、字段、方法)。
- 直接引用:指向目标的指针、偏移量或句柄。
- 目的:将符号引用转换为可以直接使用的内存地址或偏移量。
初始化:类加载过程的最后一个阶段,执行类的静态初始化代码(如静态代码块和静态变量赋值)。
- 静态代码块和静态变量赋值按代码顺序执行。
- 初始化是线程安全的,JVM 会加锁确保只有一个线程执行初始化。
使用:类的实例化和方法调用。
卸载:当类不再被使用时,从内存中移除。
类加载器
在 JVM 中,类加载器(ClassLoader)负责将类的字节码文件(.class 文件)加载到内存中,并生成对应的 java.lang.Class 对象。JVM 提供了以下几种类加载器,它们按照层次结构组织,共同完成类的加载任务。
- 类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。
- 每个 Java 类都有一个引用指向加载它的
ClassLoader。 - 数组类不是通过
ClassLoader创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。 - 除了加载类之外,类加载器还可以加载 Java 应用所需的资源如文本、图像、配置文件、视频等等文件资源。
JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。对于已经加载的类会被放在 ClassLoader 中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。
JVM 中内置了三个重要的 ClassLoader:
BootstrapClassLoader(启动类加载器):最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库如java.lang.*、java.util.*等(%JAVA_HOME%/lib目录下的rt.jar、resources.jar、charsets.jar等 jar 包和类)以及被-Xbootclasspath参数指定的路径下的所有类。特点:
- 由 C/C++ 实现,是 JVM 的一部分。
- 是最高层次的类加载器,没有父类加载器。
- 加载的类在
java.lang.ClassLoader中返回null。
示例:
System.out.println(String.class.getClassLoader()); // 输出 null
**
ExtensionClassLoader(扩展类加载器)**:加载 JVM 扩展类库(如javax.*等),这些类库位于JAVA_HOME/lib/ext目录下,或者通过java.ext.dirs系统属性指定的路径。特点:
- 由 Java 实现,是
sun.misc.Launcher$ExtClassLoader的实例。 - 父类加载器是启动类加载器。
- 由 Java 实现,是
示例:
System.out.println(javax.xml.parsers.DocumentBuilderFactory.class.getClassLoader()); // 输出 sun.misc.Launcher$ExtClassLoader
**
AppClassLoader(应用程序类加载器)**:面向我们用户的加载器,负责加载当前应用 classpath(用户类路径) 下的所有 jar 包和类。通常是程序的入口类(如包含main()方法的类)。- 特点:
- 由 Java 实现,是
sun.misc.Launcher$AppClassLoader的实例。 - 父类加载器是扩展类加载器。
- 是默认的类加载器,如果没有自定义类加载器,JVM 会使用它来加载类。
- 由 Java 实现,是
示例:
System.out.println(ClassLoader.getSystemClassLoader()); // 输出 sun.misc.Launcher$AppClassLoader
- 特点:
4. 自定义类加载器(Custom ClassLoader)
作用:用户可以通过继承
java.lang.ClassLoader类,实现自定义的类加载器,用于加载特定路径或来源的类。特点:
- 可以打破双亲委派模型,实现类的动态加载。
- 常用于热部署、模块化加载、加密类加载等场景。
class MyClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 自定义加载逻辑
byte[] data = loadClassData(name);
return defineClass(name, data, 0, data.length);
}
private byte[] loadClassData(String name) {
// 从文件或网络加载字节码
return ...;
}
}
双亲委派模型
双亲委派模型(Parent Delegation Model) 是 Java 类加载器(ClassLoader)的一种工作机制,它定义了类加载器在加载类时的协作方式。双亲委派模型是 Java 类加载机制的核心设计原则之一,确保了类的唯一性、安全性和一致性。
这些类加载器之间的关系形成了双亲委派模型,其核心思想是当一个类加载器收到类加载的请求时,首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中。只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
ClassLoader类使用委托模型来搜索类和资源。- 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。
ClassLoader实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。
流程:
- 当前类加载器检查是否已加载过该类。
- 如果没有,将加载请求委派给父类加载器。
- 如果父类加载器无法加载,当前类加载器尝试加载。
双亲委派模型是 Java 类加载机制的重要组成部分,它通过委派父加载器优先加载类的方式,实现了两个关键的安全目标:避免类的重复加载和防止核心 API 被篡改。
- 保证类的唯一性:通过委托机制,确保了所有加载请求都会传递到启动类加载器,避免了不同类加载器重复加载相同类的情况,保证了Java核心类库的统一性,也防止了用户自定义类覆盖核心类库的可能。
- 保证安全性:由于Java核心库被启动类加载器加载,而启动类加载器只加载信任的类路径中的类,这样可以防止不可信的类假冒核心类,增强了系统的安全性。例如,恶意代码无法自定义一个Java.lang.System类并加载到JVM中,因为这个请求会被委托给启动类加载器,而启动类加载器只会加载标准的Java库中的类。
- 支持隔离和层次划分:双亲委派模型支持不同层次的类加载器服务于不同的类加载需求,如应用程序类加载器加载用户代码,扩展类加载器加载扩展框架,启动类加载器加载核心库。这种层次化的划分有助于实现沙箱安全机制,保证了各个层级类加载器的职责清晰,也便于维护和扩展。
- 简化了加载流程:通过委派,大部分类能够被正确的类加载器加载,减少了每个加载器需要处理的类的数量,简化了类的加载过程,提高了加载效率。
打破双亲委派模型
在某些场景下,可能需要打破双亲委派模型。例如:
- 热部署:动态加载类而不受父类加载器的限制。
- 模块化加载:如 OSGi 框架,每个模块有自己的类加载器。
如何打破双亲委派模型:自定义类加载器时,重写 loadClass() 方法,直接加载类而不委派给父类加载器。
class MyClassLoader extends ClassLoader {
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 直接加载类,不委派给父类加载器
Class<?> c = findClass(name);
if (resolve) {
resolveClass(c);
}
return c;
}
}
垃圾回收
Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java 自动内存管理最核心的功能是 堆 内存中对象的分配与回收。Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。
Java 的垃圾回收(Garbage Collection, GC)是 JVM 自动管理内存的机制,用于回收不再使用的对象,释放内存空间。Java 开发者不需要手动释放内存,垃圾回收器会自动检测并回收无用的对象。用于避免内存泄漏和内存管理错误,减少手动管理内存的复杂性。
回收对象:堆内存–垃圾回收主要针对堆内存中的对象。方法区–方法区(元空间)中的类元数据和常量池也可能被回收。
垃圾回收的触发:
- 内存不足时:当JVM检测到堆内存不足,无法为新的对象分配内存时,会自动触发垃圾回收。
- 手动请求:虽然垃圾回收是自动的,开发者可以通过调用System.gc(或Runtime.getRuntime().gc()建议JVM进行垃圾回收。不过这只是一个建议,并不能保证立即执行。
- JVM参数:启动Java应用时可以通过JVM参数来调整垃圾回收的行为,比如:-Xmx(最大堆大小)、-Xms(初始堆大小)等。
- 对象数量或内存使用达到阈值:垃圾收集器内部实现了一些策略,以监控对象的创建和内存使用,达到某个阀值时触发垃圾回收。
判断垃圾
垃圾回收器通过以下算法判断对象是否可回收:
引用计数法
- 原理:每个对象维护一个引用计数器,记录有多少引用指向它。当引用计数为 0 时,对象可被回收。
- 缺点:无法解决循环引用问题(如两个对象互相引用,但无外部引用)。
可达性分析法
- 原理:从根对象(如栈中的局部变量、静态变量等)出发,遍历所有可达对象。不可达的对象可被回收。
- 根对象(GC Roots):栈中的局部变量。静态变量。JNI 引用(Native 方法引用的对象)。活跃线程。
- 优点:解决了循环引用问题。
运行时常量池主要回收的是废弃的常量。那么,我们如何判断一个常量是废弃常量呢?
假如在字符串常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,”abc” 就会被系统清理出常量池了。
方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?
判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是 “无用的类”:
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的
ClassLoader已经被回收。 - 该类对应的
java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。
垃圾回收算法
标记-清除算法(Mark-Sweep):分为“标记(Mark)”和“清除(Sweep)”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。它是最基础的收集算法,后续的算法都是对其不足进行改进得到。
缺点:产生内存碎片。效率较低。在申请大块内存的时候可能因为没有足够的内连续空间导致再次GC。
复制算法(Copying):将内存分为两块,每次申请内存时只使用一块。当内存不够时,将这一块内存中所有存活的对象复制到另一块内存。然后再把已使用的内存整个清理掉。优点:无内存碎片。缺点:内存利用率低(只能使用一半内存)。不适合老年代–如果存活对象数量比较大,复制性能会变得很差。
标记-整理算法(Mark-Compact):标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。优点:无内存碎片。内存利用率高。缺点:效率较低。
分代收集算法(Generational Collection):根据对象的生命周期将堆内存分为新生代(Young Generation)和老年代(Old Generation),对不同代采用不同的回收算法。
- 新生代:使用复制算法。分为 Eden 区和两个 Survivor 区(From 和 To)。
- 老年代:使用标记-清除或标记-整理算法。
垃圾收集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
JDK 默认垃圾收集器(使用 java -XX:+PrintCommandLineFlags -version 命令查看):
- JDK 8: Parallel Scavenge(新生代)+ Parallel Old(老年代)
- JDK 9 ~ JDK22: G1
1. Serial 收集器
特点:单线程收集(只使用一条垃圾收集线程,在进行垃圾收集时必须暂停其他所有工作线程,直到它收集结束)。适用于单核 CPU 或小型应用。新生代使用复制算法,老年代使用标记-整理算法。
适用场景:客户端应用或单核服务器。 没有线程交互的开销,自然可以获得很高的单线程收集效率。
启用参数:
-XX:+UseSerialGC
2. Parallel 收集器(吞吐量优先收集器)
特点:多线程收集。新生代使用复制算法,老年代使用标记-整理算法。注重吞吐量(Throughput)(高效率地利用cpu)。
适用场景:多核 CPU 和吞吐量优先的应用(如批处理任务)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。
启用参数:
-XX:+UseParallelGC
3. Parallel Old 收集器
特点:Parallel 收集器的老年代版本。多线程收集。使用标记-整理算法。
适用场景:需要高吞吐量的多核服务器。
启用参数:
-XX:+UseParallelOldGC
4. CMS 收集器(Concurrent Mark-Sweep)
特点:并发收集,减少停顿时间。新生代使用复制算法,老年代使用标记-清除算法。注重低延迟。
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
缺点:产生内存碎片。对 CPU 资源敏感。
适用场景:响应时间优先的应用(如 Web 服务)。
启用参数:
-XX:+UseConcMarkSweepGC
5. G1 收集器(Garbage-First)
特点:将堆内存划分为多个区域(Region)。并发收集,兼顾吞吐量和响应时间。使用标记-整理算法。可预测的停顿时间(通过设置最大停顿时间)。另外,G1回收的范围是整个java堆,而之前的仅限于新生代或老年代。
适用场景:大内存和多核 CPU 的应用。
启用参数:
-XX:+UseG1GC
6. ZGC 收集器(Z Garbage Collector)
特点:低延迟(停顿时间不超过 10ms)。支持超大堆内存(TB 级别)。并发收集,使用染色指针(Colored Pointers)和读屏障(Load Barrier)。
ZGC 可以将暂停时间控制在几毫秒以内,且暂停时间不受堆内存大小的影响,出现 Stop The World 的情况会更少,但代价是牺牲了一些吞吐量。
适用场景:大内存和低延迟场景(如实时系统)。
启用参数:
-XX:+UseZGC
7. Shenandoah 收集器
特点:低延迟(停顿时间与堆大小无关)。并发收集,使用 Brooks 指针和读屏障。支持大内存。
适用场景:大内存和低延迟场景。
启用参数:
-XX:+UseShenandoahGC
8. Epsilon 收集器
特点:不进行垃圾回收,仅分配内存。适用于性能测试或极短生命周期的应用。
适用场景:测试环境或不需要垃圾回收的场景。
启用参数:
-XX:+UseEpsilonGC
参考
泛型:Java 中的泛型(两万字超全详解)_java 泛型-CSDN博客
注解:Spring注解是如何实现的?万字详解 - 架构师技术栈 - SegmentFault 思否
hashset源码:Java集合系列 HashSet底层源码 细致解读(超通俗易懂)_hashset 初始化长度的源代码-CSDN博客
ArrayDeque源码:详解 Java 中的双端队列(ArrayDeque附源码分析) | 二哥的Java进阶之路
线程创建:大家都说Java有三种创建线程的方式!并发编程中的惊天骗局!
多线程:Java知识整理和总结10-多线程 | Nan-ying’s Blog
volatile:Java中的volatile_java volatile-CSDN博客