这里是 Java 整个基础概念大厦的下半部分。把这里和上篇的概念全部掌握,那你就有了开启后端框架大门的钥匙!
10. Java 字节流
-
Java IO 流简介
- IO 流其实就是(Java)系统与外界环境之间的通信通道。
- 流是代表数据源和数据目标的对象。Java 中的流是长度不确定的有序字节序列,它是一连串流动的字符,是以 FIFO 的方式发送信息的通道。
- 较旧的 I/O 包 java.io 不支持符号链接;较新的 I/O 包 java.nio 支持符号链接,它对 java.nio.file 的异常处理进行了改进。
-
Java File
- File 类提供了一个静态变量
File.separator
来表示当前平台的系统分隔符(Windows 下是 "\",Linux 和 MacOS 下是 "/")。 - 实例化 File 对象时,既可以传入绝对路径,也可以传入相对路径。
- File 对象有 3 个实例方法用于表示路径:
getPath()
—— 将抽象路径名转换为路径名字符串。getAbsolute()
—— 返回此抽象路径名的绝对路径名字符串。getCanonicalPath()
—— 返回此抽象路径名的规范路径名字符串,即把创建 File 对象的路径中的..
或.
给解析了。
- File 对象有 2 个实例方法用于判断对象是文件还是目录:
isFile()
—— 测试此抽象路径名表示的文件是否为普通文件(为 true 的前提是文件存在磁盘中)。isDirectory()
—— 测试此抽象路径名表示的文件是否为目录(为 true 的前提是目录存在磁盘中)。
- File 对象要判断文件是否存在,可以使用 2 个方法:
exists()
—— 测试此抽象路径名表示的文件或目录是否存在。canRead()
—— 测试此抽象路径名表示的文件或目录是否可读(通常不会用这个方法)。
- File 类提供了一个静态变量
-
Java InputStream
- InputStream 抽象类是所有 Java 输入流的基类。
- InputStream 的最常用方法:
read()
—— 阻塞式地从流中读取数据,返回一个字节。返回的 int 如果为 -1,则表示已经读取到文件末尾。read(byte[] b)
—— 从流中读取数据,将数据写入字节数组 b 中,返回实际读取的字节数。返回值如果为 -1,则表示没有更多数据了。read(byte[] b, int off, int len)
—— 从流中读取数据,将数据写入字节数组 b 的 off 开始,写入 len 个字节,返回实际读取的字节数。返回值如果为 -1,则表示没有更多数据了。
- InputStream 的其他方法:
- available() —— 返回此输入流中最多可以读取的字节数。
- mark() —— 标记当前输入流中数据所在的位置。
- reset() —— 将此输入流重置到上次调用 mark() 方法时的位置。
- markSupported() —— 测试此输入流是否支持标记。
- skips() —— 跳过和丢弃输入流中的指定字节数。
- InputStream 的子类结构:
- FileInputStream —— 文件输入流
- PipeInputStream —— 管道输入流
- FilterInputStream —— 过滤器输入流
- PushBackInputStream —— 回压输入流
- BufferedInputStream —— 缓冲输入流
- DataInputStream —— 数据输入流
- ObjectInputStream —— 对象输入流
- SequenceInputStream —— 顺序输入流
- ByteArrayInputStream —— 字节数组输入流
- StringBufferInputStream —— 字符数组输入流
- 我们可以用 try(resource) 来保证 InputStream 在无论是否发生 IO 错误的时候都能够正确地关闭,这样就不用手动调用 close() 方法了。
-
Java OutputStream
- OutputStream 抽象类是与 InputStream 对应的最基本的输出流。
- OutputStream 的最常用方法:
write()
—— 阻塞式地向输出流中写入数据,参数为一个 int。write(byte[] b)
—— 向输出流中写入数据,参数为一个字节数组。write(byte[] b, int off, int len)
—— 向输出流中写入数据,参数为一个字节数组,从 off 开始,写入 len 个字节。len 通常是read(byte[] b, int off, int len)
实际读取的字节数。
- OutputStream 的其他方法:
- flush() —— 立即刷新此输出流,将任何缓冲的字符强制写入到目标中。
- OutputStream 的子类结构可以参考 InputStream 的子类结构,相应的输入改为输出即可。
-
Java FileInputStream
- 用于从文件流中读取字节数据。
- 如果我们打开了一个文件并进行操作,不要忘记使用 close() 方法来及时关闭,以便系统释放资源。
-
Java FileOutputStream
- 用于向文件流中写入字节数据。
- 如果我们打开了一个文件并进行操作,不要忘记使用 close() 方法来及时关闭,以便系统释放资源。
- 一个复制文件的功能案例:
public class CopyFile { public static void main(String[] args) { String src = args[0]; String dst = args[1]; try( InputStream fis = new FileInputStream(src); OutputStream fos = new FileOutputStream(dst); ) { copy(fis, fos, 1024); } catch (IOException e) { e.printStackTrace(); } } private static void copy(InputStream in, OutputStream out, int len) throws IOException { byte[] buffer = new byte[len]; int n; while ((n = in.read(buffer, 0, len)) != -1) { out.write(buffer, 0, n); } } }
-
Java ByteArrayInputStream
- ByteArrayInputStream 就是将字节数组当作流输入来源的类。
- ByteArrayInputStream 包含一个内部缓冲区,该缓冲区包含从流中读取的字节。内部计数器跟踪 read 方法要提供的下一个字节。
- 关闭 ByteArrayInputStream 无效。此类中的方法在关闭此流后仍可被调用,而不会产生任何 IOException。
- ByteArrayInputStream 与 ByteArrayOutputStream 集合,用于以 IO 流的方式来完成对字节数组内容的读写,来支持类似内存虚拟文件或者内存映射文件的功能。
- ByteArrayInputStream 的常用方法:
- getEncoding() —— 获取此流使用的字符编码的名称
- read() —— 读取单个字符
- read(char[] cbuf, int off, int len) —— 将字符读入数组的某一部分
- ready() —— 判断此流是否已经准备好用于读取
-
Java ByteArrayOutputStream
- ByteArrayOutputStream 可将一个字节数组当作流输出目的地。即 ByteArrayOutputStream 将数据写入的是内存中,而非写入文件中。
- ByteArrayOutputStream 如果要将数据写入文件中,则需要使用到其他的类和方法:
String data = "Hello world!"; try( ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); FileOutputStream fileOutputStream = new FileOutputStream("output.txt") ) { // 将数据写入 ByteArrayOutputStream byteArrayOutputStream.write(data.getBytes()); // 将 ByteArrayOutputStream 的数据写入文件 byteArrayOutputStream.writeTo(fileOutputStream); } catch (IOException e) { e.printStackTrace(); }
- 此类实现了一个输出流,其中的数据被写入一个 byte 数组。缓冲区会随着数据的不断写入而自动增长。可使用 toByteArray() 和 toString() 获取数据。
- 关闭 ByteArrayOutputStream 无效。此类中的方法在关闭此流后仍可被调用,而不会产生任何 IOException。
- 当向 ByteArrayOutputStream 中不断写入大量数据时,如果没有及时清理已经写入的数据,可能会导致内存溢出错误。解决方案:
- 及时清理 ByteArrayOutputStream 中的数据,防止内存占用过高。可以通过调用其 reset() 方法或者重新创建一个新的实例来达到清理缓冲区的目的。
- 在创建 ByteArrayOutputStream 对象时指定一个合适的初始容量,避免在向缓冲区中添加大量数据时重复扩展缓冲区大小而导致内存溢出。可以根据具体情况合理选择初始容量大小,避免容量过小或过大。
- ByteArrayOutputStream 的常用方法:
- reset() —— 用于将底层数组的计数器(count)设置为 0,并不影响底层数组的大小和内容。具体而言,reset 方法会将 count 字段设置为 0,使得后续写入的数据可以覆盖已有的数据,避免浪费内存。
- write(int b) —— 向缓冲区写入一个字符
- write(byte[] b, int off, int len) —— 向缓冲区写入字符数组的某一部分
- writeTo(OutputStream out) —— 把缓冲区的内容写入到另一个 OutputStream 流中
- toByteArray() —— 把缓冲区的全部内容转换成字符数组并返回
-
Java ObjectInputStream
- ObjectInputStream 以"对象"为数据源,代表对象输入流。
- 对象的反序列化创建对象的时候并不会调用到构造方法的。
- ObjectInputStream 常用方法:
- readObject() —— 从源输入流中读取字节系列,再把它们反序列化成一个对象,并将其返回,必须对其进行强转换再赋值。读取的顺序必须与写入的顺序一致,其他读取方法也如此。
- readInt() —— 从源输入流中读取一个整数。
- readUTF() —— 从源输入流中读取一个无符号的 UTF-16 字符。
-
Java ObjectOutputStream
- ObjectOutputStream 以"对象"为数据源,代表对象输出流。
- 只有实现了 Serializable 接口(Serializable 接口没有任何的方法,只是一个标识接口而已)的对象才能被序列化。
- 序列化以后的对象可以保存到磁盘上,也可以在网络上传输,使得不同的计算机可以共享对象(序列化的字节序列是平台无关的)。
- 如果对象的属性是对象,属性对应类也必须实现 Serializable 接口。
- 如果序列化与反序列化的时候可能会修改类的成员,那么最好一开始就给这个类指定一个 serialVersionUID (serialVersionUID 是用于记录 class 文件的版本信息的,serialVersionUID 这个数字是通过一个类的类名、成员、包名、工程名算出的一个数字)。这样在序列化与反序列化的时候,JVM 都不会再自己算这个 class 的 serialVersionUID 了。
- 如果一个对象某个字段不想被序列化到硬盘上,可以使用关键字 transient 修饰。
- ObjectOutputStream 常用方法:
- writeInt(int v) —— 将指定的 int 值写入到输出流中。
- writeUTF(String str) —— 将指定的字符串写入输出流。
- writeObject(Object obj) —— 将指定的对象经过序列化后,写入输出流对象。
- 实现 Java 中的深克隆:
class Student implements Serializable { private static final long serialVersionUID = 43L; private String name; public Student(String name) { this.name = name; } public String getName() { return name; } public void setName(String name) { this.name = name; } @Override public String toString() { return "Student{" + "name='" + name + '\'' + '}'; } } public class DeepCloneTest { public static void main(String[] args) throws IOException { Student stu1 = new Student("Tom"); Student stu2 = null; try ( ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos); ) { oos.writeObject(stu1); try ( ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()); ObjectInputStream ois = new ObjectInputStream(bais) ) { stu2 = (Student) ois.readObject(); } } catch (ClassNotFoundException e) { e.printStackTrace(); } System.out.println(stu1); System.out.println(stu2); } }
-
Java BufferedInputStream
- BufferedInputStream 继承自 FilterInputStream 类,可以提供缓冲和流的级联两个功能,提供了一定的性能优化。
- BufferedInputStream 类底层最主要的实现是通过缓冲区来提升读取效率的,通过读取尽可能多的数据到缓冲区中,减少 I/O 操作次数。
- 使用缓冲区可能会导致数据不及时更新。
- 缓冲区过大会占用过多内存,而缓冲区过小则不能充分发挥 BufferedInputStream 的优势。因此,需要根据实际情况设置合适的缓冲区大小。
- BufferedInputStream 常用方法:
- fill() —— 从输入流中读取数据到缓冲区,以便能够进行读取操作。
- read() —— 读取缓冲区中的数据(一个字节)。在读取时,如果缓冲区中的数据已经全部被读取,那么就需要再次调用 fill() 方法来填充缓冲区。这样就达到了高效读取的目的。
- read(byte[] b, int off, int len) —— 从缓冲区中读取 len 个字节的数据到 b 数组中的 off 位置。
- read1(byte[] b, int off, int len) —— 从缓冲区中读取 len 个字节的数据到 b 数组中的 off 位置。如果缓冲区中没有数据可供读取,就从输入流中读取数据到缓冲区。
- available() —— 返回在不受阻塞的情况下从输入流中能够读取的数据量。
- 读取文件案例:
public class BufferedInputStreamTest { public static void main(String[] args) throws IOException { // 方案一 try ( FileInputStream fis = new FileInputStream(new File("D:\\test.txt")); BufferedInputStream bis = new BufferedInputStream(fis); ) { int data = bis.read(); while (data != -1) { System.out.print((char) data); data = bis.read(); } } // 方案二: 设置缓冲区为 8 个字节大小,即每次读取的数据量为 8 字节 try ( FileInputStream fis = new FileInputStream(new File("D:\\test.txt")); BufferedInputStream bis = new BufferedInputStream(fis, 8); ) { byte[] b = new byte[1024]; int len = 0; while ((len = bis.read(b)) != -1) { System.out.println(new String(b, 0, len)); } } } }
-
Java BufferedOutputStream
- BufferedOutputStream 的作用是为另一个输出流提供"缓冲功能"。
- BufferedOutputStream 通过字节数组来缓冲数据,当缓冲区满或者用户调用 flush() 函数时,它就会将缓冲区的数据写入到输出流中。
- BufferedOutputStream 常用方法:
- flush() —— 将缓冲区中的数据写入到输出流中。
- write(int b) —— 将一个字节写入到输出流中。如果缓冲区已满,则先将缓冲区中的数据写入到输出流中,再把 b 写入缓冲区数组中。
- write(byte[] b, int off, int len) —— 将 b 中的 len 个字节的数据写入到缓冲区数组中。如果缓冲区已满,则先将缓冲区中的数据写入到输出流中,再把那些字节数据写入缓冲区数组中。
- close() —— 把缓冲区剩余的数据写入输出流,然后关闭输出流以及本缓冲输出流。
-
Java PrintStream
- PrintStream 是打印输出流,继承于 FilterOutputStream。与其他输出流不同,PrintStream 将原始数据(整数、字符)转换为文本格式而不是字节。
- PrintStream 的目的是为其它输出流提供打印各种数据值表示形式,使其它输出流能方便的通过 print()、println() 或 printf() 等输出各种格式的数据。
- PrintStream 的作用是装饰其他输出流。它能为其他输出流添加了功能,使它们能够方便地打印各种数据值表示形式。
- 与其他输出流不同,PrintStream 永远不会抛出 IOException。
- 用户可以通过 checkError() 返回错误标记,从而查看 PrintStream 内部是否产生了 IOException。
- PrintStream 的构造函数:
PrintStream(OutputStream out[, boolean autoFlush, String charsetName])
,允许设置自动 flush 以及指定字符集。 - PrintStream 常用方法:
- print(Object obj) —— 将对象 obj 对应的字符串写入到输出流中,所有 print 方法实际调用的是 write 方法。print() 和 println() 都是将其中参数转换成字符串之后,再写入到输出流。
- println(Object obj) —— 将对象 obj 对应的字符串 + 换行符写入到输出流中,所有 println 方法实际调用的是 write 方法 + newLine 方法。
- flush() —— 将缓冲区中的数据写入到被修饰的输出流中。
- append(CharSequence csq) —— 将字符序列的全部字符追加到输出流中。
- checkError() —— 检查流中是否有错误,并返回布尔结果。
11. Java 字符流
-
Java 字符流简述
- 字符流出现的目的是为了更方便地处理例如中文这样的字符,即处理纯文本文件。说白了,就是在字节流的基础上,加上编码,形成的数据流。
- 字节流本身没有缓冲区,缓冲字节流相对于字节流,效率提升非常高。而字符流本身就带有缓冲区,缓冲字符流相对于字符流效率提升就不是那么大了。
- 字符流写入要刷新或关流才能写入(关流也是调用了刷新方法)。
-
Java Reader
- Reader 是字符输入流的抽象基类。
- InputStream 是一个字节流,即以 byte 为单位读取;而 Reader 是一个字符流,即以 char 为单位读取。
- Reader 本质上是一个基于 InputStream 的 byte 到 char 的转换器(InputStreamReader 就是这样一个转换器)。
- Reader 常用方法:
- read() —— 读取字符流的下一个字符,并返回字符表示的 int (范围是 0~65535)。如果已读到末尾,返回 -1。
- read(char[] cbuf) —— 读取字符流的若干个字符并将写入到字符数组 cbuf 中,然后返回字符个数。如果已读到末尾,返回 -1。
- 和 InputStream 类似,Reader 也是一种资源,需要保证出错的时候也能正确关闭,所以我们需要用
try (resource)
来保证 Reader 在无论有没有 IO 错误的时候都能够正确地关闭。
-
Java Writer
- Writer 是字符输出流的抽象基类。
- Writer 就是带编码转换器的 OutputStream,它把 char 转换为 byte 并输出。
- Writer 常用方法:
- write(int c) —— 写入一个字符。
- write(char[] c) —— 写入一个字符数组的所有字符。
- write(Sttring s) —— 写入一个字符串。
-
Java InputStreamReader
- InputStreamReader 是从字节流到字符流的桥梁:它读取字节,并使用指定的 charset 将其解码为字符。
- 它使用的字符集可以由名称指定,也可以被明确指定,或者可以接受平台的默认字符集。
- InputStreamReader 用于把任何 InputStream 转换为 Reader。
- InputStreamReader 常用方法:
- read() —— 读取一个字符。
- ready() —— 返回布尔值表示这个流是否准备好被读取。
- getEncoding() —— 返回当前流使用的字符集名称。
-
Java OutputStreamWriter
- OutputStreamWriter 是从字符流到字节流的桥梁:使用指定的 charset 将写入的字符编码为字节。
- 它使用的字符集可以由名称指定,也可以被明确指定,或者可以接受平台的默认字符集。
- OutputStreamWriter 常用方法:
- write(int c) —— 将单个字符写入输出流。
- append(CharSequence csq) —— 将字符序列 csq 写入此输出流。
- flush() —— 刷新此流,将任何缓冲的字符强制写出。
- getEncoding() —— 返回当前流使用的字符集名称。
-
Java FileReader
- FileReader 实现了文件字符流输入,使用时需要指定编码。
- FileReader 默认的编码与系统相关,例如,Windows 系统的默认编码可能是 GBK,打开一个 UTF-8 编码的文本文件就会出现乱码。
-
Java FileWriter
- FileWriter 就是向文件中写入字符流的 Writer。
new FileWriter(file)
相当于new OutputStreamWriter(new FileOutputStream(file, true))
new FileWriter(String s)
创建的对象与之相关联的文件如果不存在,则自动创建- FileWriter 是 Decorator 模式的典型用法
-
Java BufferedReader
- 用于从字符输入流读取文本,缓冲字符,以提供字符,数组和行的高效读取。
- 带缓冲区的字符输入流。
- BufferedReader 常用方法:
- readLine() —— 读取一行字符,如果为文本末尾,则返回 null。
- 为了最大的效率,请考虑在BufferedReader中包装一个InputStreamReader。
-
Java BufferedWriter
- 用于将文本写入字符输出流,缓冲字符,以提供单个字符,数组和字符串的高效写入。
- 带缓冲区的字符输出流。可以指定缓冲区大小,或者接受默认的大小。
- BufferedWriter 常用方法:
- write(String s) —— 将字符串 s 写入到输出流中。
- newLine() —— 写入换行符(换行符由当前操作系统决定)。
- 为了最大的效率,请考虑在 BufferedWriter 中包装一个 OutputStreamWriter,以避免频繁的转换器调用。
- 在应用中我们一般不使用 BufferedWriter 而是使用 printWriter。
-
Java CharArrayReader
- CharArrayReader 可以在内存中模拟一个 Reader,它的作用实际上是把一个 char[] 数组变成一个 Reader,这和 ByteArrayInputStream 非常类似。
- CharArrayReader 案例
try ( Reader reader = new CharArrayReader("hello world".toCharArray()); ) { // TODO }
-
Java StringReader
- StringReader 可以直接把 String 作为数据源。它和 CharArrayReader 几乎一样。
- StringReader 案例
try ( Reader reader = new StringReader("hello world"); ) { // TODO }
-
Java StringWriter
- 用于将数据(以字符为单位)写入字符串缓冲区。
- 在 Java 中,字符串缓冲区被认为是可变的字符串(也就是说,我们可以修改字符串缓冲区)。
- StringWriter 常用方法:
- getBuffer() —— 返回字符串缓冲区。
- toString() —— 将字符串缓冲区的数据作为字符串返回。
- write()、flush()、append() 与 Writer 一致。
-
Java PrintWriter
- PrintStream 最终输出的总是 byte 数据,而 PrintWriter 则是扩展了 Writer 接口,它的 print()/println() 方法最终输出的是 char 数据。
- PrintWriter 不但能接收字符流,也能接收字节流。
- PrintWriter 的 print、println 方法可以接受任意类型的参数(两者的使用方法几乎是一模一样的)。
- PrintWriter 的方法不会抛异常,若关心异常,则需要调用 checkError() 方法查看是否有异常发生。
- PrintWriter 构造方法可指定参数,实现自动刷新缓存。
- OutputStream 可以直接传给 printWriter (BufferedWriter 不能接收)。
- 由于 BufferedWriter 没有 PrintWriter 灵活,所以在实际操作中,我们往往会使用 PrintWriter/BufferedReader 这种组合。
12. Java 并发编程
-
进程与线程
- 基本概念:
- 进程是操作系统分配资源的基本单位。
- 进程是程序的执行过程,是动态的,有一定的生命周期。
- 程序和进程并不是一一对应的关系。
- 线程从属于进程,只能在进程的内部活动,多个线程共享进程所拥有的资源。线程只能在一个进程的地址空间内活动。
- 目前主流的操作系统都只将进程作为资源的拥有者,而把 CPU 调度和运行的属性赋予线程。
- CPU 被分配给的是线程,即真正在 CPU 上调度执行的是线程。
- 进程间通信较为复杂,同一台计算机的进程通信称为 IPC(Inter-process communication)。而不同计算机之间的进程通信,则需要通过网络,并遵守共同的协议,例如 HTTP 等。
- 线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量。
- Java 程序与进程:
- Java 程序包括 JVM 和我们编写的 Java 代码。
- 我们所写的启动类的 main 方法,就是 JVM 进程的主线程所在。
- 一个 Java 进程即为一个 JVM 实例。
- Java 线程:
- 两个或以上的线程在同一时刻发生就称为并行。
- 两个或以上的线程在同一时间段内发生则称为并发。
- 一个操作系统中的线程数远远多于 CPU 核心数,所以线程之间大部分情况下是属于并发状态的。
- 不过 Java 的并发依然是依赖于多线程,即多线程是 Java 实现并发的一种方式。
- Java 线程的六种状态:
- 新建 (New):线程刚被创建,尚未启动。
- 就绪 (Runnable):线程已经准备好运行,但尚未运行。
- 无限等待 (Waiting):线程处于无限等待状态,例如:等待某个线程执行完毕。
- 限期等待 (Timed Waiting):在一定时间后会由系统自动唤醒。
- 阻塞 (Blocked):线程在等待获取锁时,就处于阻塞状态。
- 死亡 (Terminated):线程已经终止。
- Thread 中的 start 和 run 方法的区别:
- start() —— 创建一个新的子线程并启动。
- run() —— Thread 的一个普通方法的调用,不会创建新的线程。
- sleep 和 wait 的区别:
- sleep 是 Thread 类的方法,wait 是 Object 类的方法。
- sleep 方法可以在任何地方使用;wait 方法只能在同步(synchronized)方法或同步(synchronized)块中调用。
- Thread.sleep() 只会让出 CPU,不会导致锁行为的改变;Object.wait() 不仅会让出 CPU,还会释放已经占有的同步资源锁。
- 基本概念:
-
多线程的入门类和接口
- Thread 类:
- 继承该类,并重写 run() 方法,该方法代表线程要执行的任务。
- Thread 类的常用方法:
- currentThread() —— 静态方法,返回当前正在执行的线程对象的引用。
- start() —— 启动线程,JVM 会调用线程内的 run() 方法。
- yield() —— 当前线程愿意让出对当前处理器的占用。
- sleep() —— 静态方法,让当前线程睡眠一段时间。
- join() —— 使当前线程等待另一个线程执行完毕之后再继续执行,内部调用的是 Object 类的 wait 方法实现。
- Runnable 接口:
- 实现该接口,并重写 run() 方法,该方法代表线程要执行的任务。
- 如果使用线程时不需要使用 Thread 类的诸多方法,显然使用 Runnable 接口更为轻量。
- Callable 接口与 Future 接口:
- 实现 call() 方法,该方法作为线程的执行体。
- 具有返回值,并且可以对异常进行声明和抛出。这点是 Thread 类和 Runnable 接口所没有的。
- 一般我们利用线程池工具 ExecutorService 来配合使用 Callable:
class Task implements Callable<Integer> { @Override public Integer call() throws Exception { Thread.sleep(1000); return 123; } public static void main(String[] args) { ExecutorService executor = Executors.newCachedThreadPool(); Task task = new Task(); Future<Integer> result = executor.submit(task); // 注意调用 get 方法会阻塞当前线程,直到得到结果 // 所以实际编码中建议使用可以设置超时时间的重载 get 方法 System.out.println("Result is: " + result.get()); } }
- Future 接口的常用方法:
- cancel(boolean bool) —— 试图取消某个线程的执行。参数 bool 表示是否采用中断的方式取消线程执行。返回值表示是否成功取消。
- isCancelled() —— 判断某个线程是否被取消。
- isDone() —— 判断某个线程是否执行结束。
- get() —— 以阻塞当前线程的方式获取某个线程的执行结果。
- get(long timeout, TimeUnit unit) —— 获取某个线程的执行结果,并且可以设置超时时间。
- Future 接口有一个实现类 FutureTask,该类实现了 RunnableFuture 接口(该接口同时继承了 Runnable 和 Future 接口):
class Task implements Callable<Integer> { @Override public Integer call() throws Exception { Thread.sleep(1000); return 123; } public static void main(String[] args) { ExecutorService executor = Executors.newCachedThreadPool(); FutureTask<Integer> futureTask = new FutureTask<>(new Task()); executor.execute(futureTask); System.out.println("Result is: " + futureTask.get()); } }
- 在很多高并发的环境下,有可能 Callable 和 FutureTask 会创建多次,FutureTask 能够在高并发环境下确保任务只执行一次(在高并发场景下,如果有多个线程同时尝试启动一个 FutureTask,它会保证仅有一个线程实际执行任务,其余线程等待结果)。
- FutureTask 在生命周期中的不同状态:
- NEW: 初始状态,表示 FutureTask 被创建,但尚未执行。
- COMPLETING: 瞬态状态,表示任务正在完成,即 call() 方法正在运行或结果已经设置,等待后续的完成处理过程。
- NORMAL: 正常结束,任务已成功执行并设置了结果。
- EXCEPTIONAL: 异常结束,任务在执行过程中抛出了未捕获的异常,结果被设置为该异常对象。
- CANCELLED: 取消状态,通过调用 cancel() 方法且成功取消了任务,此时任务不会继续执行。
- INTERRUPTING: 中断中状态,也是瞬态状态,表明正在进行取消操作,并尝试中断底层的任务执行线程。
- INTERRUPTED: 已中断状态,意味着任务在取消过程中已被成功中断。
- Thread 类:
-
线程组和线程优先级
- 线程组(ThreadGroup)是一个容器,在 Java 多线程体系中扮演着组织者和管理者的角色,它允许开发者以树状结构的形式批量控制一组相关的线程。
- 每个线程在 Java 中必然隶属于一个线程组。
- 线程组可以实现对一组线程的批量操作和统一管理:
- 通过重写 ThreadGroup 类的 uncaughtException(Thread t, Throwable e) 方法,可以在一个线程组中的任意线程抛出未捕获异常时,由该线程组统一进行异常处理。
- 线程优先级是 Java 提供的一种影响调度策略的手段,虽然范围从 1 到 10(其中 1 代表最低优先级,10 代表最高优先级),但实际执行顺序并不严格遵循优先级数值大小,而是由操作系统依据自身的线程调度算法来决定。
- 在实际场景中,过度依赖线程优先级来精确控制线程执行顺序并非最佳实践,因为操作系统可能不会严格按照 Java 中设定的优先级进行调度。
- 优先级较高的线程更有可能获得 CPU 资源,但在同一优先级下,线程的执行遵循“先到先服务”原则。
- Java 中的线程组涉及到权限控制,在创建或修改线程组时需要检查调用线程是否有足够的权限。
- 新建的线程默认会继承父线程所在线程组,但也可以通过构造函数显式指定线程组。
- ThreadGroup 的常用方法:
- setMaxPriority(int newMaxPriority) —— 设置线程组的最大优先级。该组的所有线程的优先级都会被限制在此值及之下(即使线程尝试设置高于此值的优先级)。
- Java 多线程编程中,守护线程(Daemon Thread)是一种特殊类型的线程。当所有非守护线程结束运行后,即使守护线程还在执行,JVM 也会停止运行并退出程序,这意味着守护线程不会阻止 JVM 的关闭。
- 守护线程通常用于执行那些不直接影响应用程序主要任务、且不需要等待其完成的任务,比如后台清理工作、监控服务或资源回收等辅助性功能。
-
线程的状态及主要转化方法
- Thread.Starte 枚举类中定义了 6 种不同的线程状态:
- NEW: 新建状态,尚未启动的线程处于此状态。
- RUNNABLE: 可运行状态,Java 虚拟机中执行的线程处于此状态。
- BLOCKED: 阻塞状态,等待监视器锁定而被阻塞的线程处于此状态。
- WAITING: 等待状态,无限期等待另一线程执行特定操作的线程处于此状态。
- TIMED_WAITING: 定时等待状态,在指定等待时间内等待另一线程执行操作的线程处于此状态。
- TERMINATED: 结束状态,已退出的线程处于此状态。
- 这些状态是不反映任何操作系统线程状态的虚拟机状态。
- 各种状态的转化方法:
- NEW -> RUNNABLE:
- start() —— 线程对象调用此方法,即可启动线程,线程进入可运行状态。
- RUNNABLE -> BLOCKED:
- 运行状态: 线程对象调用 wait() 后,线程进入等待阻塞状态。
- 同步阻塞: 线程在获取 synchronized(lock) 同步锁失败时,会进入同步阻塞状态。
- 其他阻塞:通过调用线程的 sleep() 或 join() 或发出了 I/O 请求时,线程会进入到阻塞状态。当 sleep() 状态超时、join() 等待线程终止或者超时或者 I/O 处理完毕时,线程重新转入就绪状态。
- NEW -> RUNNABLE:
- Thread.Starte 枚举类中定义了 6 种不同的线程状态:
-
线程间的通信
- 同一进程的线程共享地址空间,没有通信的必要,但要做好同步/互斥,保护共享的全局变量。
- 线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。
- 锁机制: 包括互斥锁、条件变量、读写锁
- 信号量机制: 包括无名线程信号量和命名线程信号量
- 信号机制: 类似进程间的信号处理
- 管道通信:
- 在 Java 多线程编程中,管道(Pipes)是一种特殊的通信机制,它允许线程之间通过内存流进行数据传输。
- 字符流之间的通信:
- PipedWriter —— 输出流,用于向 PipedReader 写入数据
- PipedReader —— 输入流,用于从 PipedWriter 读取数据
- 字节流之间的通信:
- PipedOutputStream —— 输出流,用于向 PipedInputStream 写入数据
- PipedInputStream —— 输入流,用于从 PipedOutputStream 读取数据
-
重排序和 happens-before
- JVM 的指令具有重排的特性。
- 指令重排在单线程环境下不会出现问题,但在多线程环境下会造成一个线程获取一个未被初始化的实例,就会引发一系列问题。
- 存在数据依赖关系的不允许重排序,即无法通过 happens-before 原则推导出来的,才能进行指令的重排序。
- happens-before 八大原则:
- 程序次序规则 —— 一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 锁定规则 —— 一个 unlock 操作先行发生于后面对同一个锁的 lock 操作
- volatile 变量规则 —— 对一个变量的写操作先行发生于后面对这个变量的读操作
- 传递规则 —— 如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以得出操作 A 先行发生于操作 C
- 线程启动规则 —— Thread 对象的 start() 方法先行发生于此线程的每一个动作
- 线程中断规则 —— 对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 线程终结规则 —— 线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行
- 对象终结规则 —— 一个对象的初始化完成先行发生于它的 finalize() 方法的开始
- 若操作不满足 happens-before 原则中的任意一条规则,那么这个操作就没有顺序的保障,JVM 可以对其进行重排序。
- 若操作 A happens-before 操作 B,那么操作 A 在内存上所做的操作对操作 B 都是可见的。
-
volatile
- volatile 是 JVM 提供的轻量级同步机制。
- 被 volatile 修饰的(共享)变量对所有线程总是立即可见的。
- 对 volatile 修饰的变量的所有写操作总是能立即反映到其他线程中,但 volatile 运算操作在多线程环境中不保证线程安全性。
- volatile 可防止 JVM 的指令重排。
- volatile 是采用内存屏障(Memory Barrier)来实现。
- volatile 本质是在告诉 JVM 当前变量在寄存器(本地内存)中的值是不确定的,需要从主存中读取。
- volatile 仅能使用在变量级别。
- volatile 仅能实现变量的修改可见性,不能保证原子性,因此不适合复合操作(例如 变量++)。复合操作应使用 synchronized 或 Lock 或原子操作类来保证原子性。
- volatile 不会造成线程的阻塞。
- volatile 标记的变量不会被编译器优化。
-
synchronized
- synchronized 是用来保证线程同步的,可以满足互斥锁的特性:
- 可见性 —— 必须确保在锁被释放之前,对共享变量所做的修改对于随后获得该锁的另一个线程是可见的(即在获得锁时应获得最新共享变量的值)。
- 互斥性 —— 在同一时间只允许一个线程持有某个对象锁。互斥性也称为操作的原子性。
- synchronized 锁的不是代码,锁的都是对象:
- 对象锁 之 同步代码块,如
synchronized(this)
、synchronized(obj)
,锁的是实例的对象。 - 对象锁 之 同步非静态方法,如
synchronized method()
,锁的是使用当前方法的实例对象。 - 类锁 之 同步代码块,如
synchronized(类名.class)
,锁的是类的对象。 - 类锁 之 同步静态方法,如
synchronized static method()
,锁的是使用该方法的类对象。
- 对象锁 之 同步代码块,如
- synchronized 锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住直到该线程完成变量操作为止。
- synchronized 可以使用在变量、方法和类级别。
- synchronized 可以保证变量修改的可见性和原子性。
- synchronized 可能会造成线程的阻塞。
- synchronized 标记的变量可以被编译器优化。
- synchronized 可实现的锁类型:
- 悲观锁
- 非公平锁
- 可重入锁
- 独占锁或排他锁
- synchronized 内部实现原理就是锁升级的过程:无锁 --> 偏向锁 --> 轻量级锁 --> 重量级锁。
- synchronized 是用来保证线程同步的,可以满足互斥锁的特性:
-
锁
- 锁主要存在四种状态:
- 无锁状态
- 偏向锁状态
- 轻量级锁状态
- 重量级锁状态
- 锁的状态可以升级不可以降级。
- 锁的类型:
- 乐观锁 —— 观锁操作数据时不会上锁,在更新的时候会判断一下在此期间是否有其他线程去更新这个数据。乐观锁可以使用版本号机制和 CAS 算法实现。在 Java 语言中 java.util.concurrent.atomic 包下的原子类就是使用 CAS 乐观锁实现的。
- 悲观锁 —— 一个共享数据加了悲观锁,那线程每次想操作这个数据前都会假设其他线程也可能会操作这个数据,所以每次操作前都会上锁,这样其他线程想操作这个数据拿不到锁只能阻塞了。synchronized 和 ReentrantLock 等就是典型的悲观锁,还有一些使用了 synchronized 关键字的容器类如 HashTable 等也是悲观锁的应用。
- 独占锁 —— 指锁一次只能被一个线程所持有。如果一个线程对数据加上排他锁后,那么其他线程不能再对该数据加任何类型的锁。获得独占锁的线程即能读数据又能修改数据。JDK 中的 synchronized 和 java.util.concurrent(JUC) 包中 Lock 的实现类就是独占锁。
- 共享锁 —— 指锁可被多个线程所持有。一个线程对数据加上共享锁后,那么其他线程只能对数据再加共享锁,不能加独占锁。获得共享锁的线程只能读数据,不能修改数据。JDK 中 ReentrantReadWriteLock 就是一种共享锁。
- 互斥锁 —— 互斥锁是独占锁的一种常规实现,是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。
- 读写锁 —— 读写锁是共享锁的一种具体实现。读写锁管理一组锁,一个是只读的锁,一个是写锁。读锁可以在没有写锁的时候被多个线程同时持有,而写锁是独占的。
- 公平锁 —— 公平锁是指多个线程按照申请锁的顺序来获取锁。
- 非公平锁 —— 非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。java 中 synchronized 关键字是非公平锁,ReentrantLock 默认也是非公平锁。
- 可重入锁 —— 可重入锁又称之为递归锁,是指同一个线程在外层方法获取了锁,在进入内层方法会自动获取锁。synchronized、ReentrantLock 都是可重入锁。可重入锁的一个好处是可一定程度避免死锁。
- 自旋锁 —— 指线程在没有获得锁时不是被直接挂起,而是执行一个忙循环,这个忙循环就是所谓的自旋。用于同享数据的锁定状态持续时间较短的场景。
- 自适应自旋锁 —— 用于自旋的次数不固定,是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的场景。
- 分段锁 —— 分段锁 是一种锁的设计,并不是具体的一种锁。目的是将锁的粒度进一步细化,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。Java 中 CurrentHashMap 底层就用了分段锁,使用 Segment,就可以进行并发使用了。
- 无锁 —— 无锁状态其实就是上面讲的乐观锁。
- 偏向锁 —— 指它会偏向于第一个访问锁的线程,如果在运行过程中,只有一个线程访问加锁的资源,不存在多线程竞争的情况,那么线程是不需要重复获取锁的,这种情况下,就会给线程加一个偏向锁。用于只有一个线程访问同步块或同步方法的场景。
- 轻量级锁 —— 用于线程交替执行同步块或同步方法的场景。当线程竞争变得比较激烈时,偏向锁就会升级为轻量级锁(例如自旋锁)。
- 重量级锁 —— 用于追求吞吐量、同步块或同步方法执行较长的场景。
- 锁粗化 —— 锁粗化就是将多个同步块的数量减少,并将单个同步块的作用范围扩大,本质上就是将多次上锁、解锁的请求合并为一次同步请求。
private static final Object LOCK = new Object(); // 非锁粗化 for(int i = 0;i < 100; i++) { synchronized(LOCK){ // do something } } // 锁粗化 synchronized(LOCK){ for(int i = 0;i < 100; i++) { // do something } }
- 锁消除 —— 锁消除是指虚拟机编译器在运行时检测到了共享数据没有竞争的锁,从而将这些锁进行消除。
- 锁主要存在四种状态:
-
CAS 与原子操作
- CAS 是 Compare And Swap 的缩写,是一种乐观锁,号称 lock-free。
- CAS 是一种高效实现线程安全性的方法,支持原子更新操作,适用于计数器、序列发生器等场景。
- CAS 操作失败时由开发者决定是否继续尝试或执行其他操作。
- CAS 包含三个操作数: 内存位置(V)、预期原值(A)、新值(B)。执行CAS操作时,会将内存位置的值与预期原值进行比较,若匹配,那么处理器会将该位置的值更新为新值,否则处理器不做任何操作。
- CAS 的缺点:
- 若循环时间长,则开销会很大。
- 只能保证一个共享变量的原子操作。
- ABA 问题:即一个变量经历了从被复制为 A,B,A 的过程,但 CAS 机制无法分辨该变量是否中途被改变过,会误认为其处在原来的值。这个漏洞就是 ABA 问题。JUC 为了解决这个问题,提供了 AtomicStampedReference 类,它能够通过控制变量值的版本来保证 CAS 的正确性。
-
AQS
- AQS 是 AbstractQueuedSynchronizer 类的简称,是整个 JUC 包的核心类。JUC 中的ReentrantLock、ReentrantReadWriteLock、CountDownLatch、Semaphore和LimitLatch等同步工具都是基于AQS实现的。
- AQS 就是将通用的关注点封装成了一个个模板方法,让子类可以直接使用。这些关注点都是围绕着资源 —— 同步状态(synchronization state)来展开的。这些关注点包括:
- 资源是可以被同时访问?还是在同一时间只能被一个线程访问?(共享/独占功能)
- 访问资源的线程如何进行并发管理?(等待队列)
- 如果线程等不及资源了,如何从等待队列退出?(超时/中断)
- AQS 留给用户的只有两个问题:
- 什么是资源
- 什么情况下资源是可以被访问的
- AQS 留给用户的两个问题,是通过暴露以下 API (钩子方法)解决的:
- tryAcquire() —— 独占方式。尝试获取资源,成功则返回 true,失败则返回 false。
- tryRelease() —— 独占方式。尝试释放资源,成功则返回 true,失败则返回 false。
- tryAcquireShared() —— 共享方式。尝试获取资源。负数表示失败;0 表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
- tryReleaseShared() —— 共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回 true,否则返回 false。
- isHeldExclusively() —— 该线程是否正在独占资源。只有用到 condition 才需要去实现它。
- AQS 提供的主要模板方法:
- void acquire(int arg) —— 独占模式的获取资源
- boolean release(int arg) —— 独占模式的释放资源
- void acquireShared(int arg) —— 共享模式的获取资源
- boolean releaseShared(int arg) —— 共享模式的释放资源
- 几个常见的同步器对资源的定义:
- ReentrantLock —— 资源表示独占锁。state 为 0 表示锁可用;为 1 表示被占用;为 N 表示重入的次数
- ReentrantReadWriteLock —— 资源表示共享的读锁和独占的写锁。state 逻辑上被分成两个 16 位的 unsigned short,分别记录读锁被多少线程使用和写锁被重入的次数。
- CountDownLatch —— 资源表示倒数计数器。state 为 0 表示计数器归零,所有线程都可以访问资源;为 N 表示计数器未归零,所有线程都需要阻塞。
- Semaphore —— 资源表示信号量或者令牌。state ≤ 0 表示没有令牌可用,所有线程都需要阻塞;大于 0 表示由令牌可用,线程每获取一个令牌,state 减 1,线程每释放一个令牌,state 加 1。
-
计划任务
- ScheduledThreadPoolExecutor 是 Java 并发包中用于执行计划任务的核心类,自 JDK 1.5 版本开始引入,它继承自 ThreadPoolExecutor 并实现了 ScheduledExecutorService 接口。
- ScheduledThreadPoolExecutor 类是基于线程池模型构建的,意味着它能够同时处理多个并发任务。
- ScheduledThreadPoolExecutor 类提供了两种关键的调度能力:
- 延迟执行
- schedule() —— 设置任务在一段时间后运行一次。
- 周期执行:
- scheduleAtFixedRate() —— 该方法用于以固定的速率执行任务,确保每个任务间的间隔是恒定的。
- scheduleWithFixedDelay() —— 该方法在每个任务完成之后等待特定的延时再执行下一个任务,实际执行间隔包含了任务自身的执行时间。
- 延迟执行
- ScheduledFutureTask 是 Java 并发库中实现定时任务调度的核心类,它是 ScheduledThreadPoolExecutor 内部使用的任务封装器。ScheduledFutureTask 直接或间接实现了多个接口,如 Runnable、Future、Delayed 以及 ScheduledFuture,这使得它能够作为可延迟执行的任务实体,同时具备了检查剩余延迟时间、获取任务结果和周期性调度的能力。
-
Stream 并行计算原理
- Stream 计算默认是串行的。
Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) .reduce((a, b) -> a + b) .ifPresent(System.out::println);
- 只需调用 parallel() 方法,就能将原本串行执行的操作转换为并行计算,这背后的机制涉及 Fork/Join 框架,该框架能够智能地分割任务并在多个核心上并发执行,最终合并结果,确保计算的正确性。
Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) .parallel() .reduce((a, b) -> a + b) .ifPresent(System.out::println);
- parallel() 方法通过设置内部标志位来指示当前 Stream 是否处于并行模式。在调用终端操作如 reduce() 时,系统会检查这个标志位并据此决定是使用 evaluateSequential() 执行串行计算,还是调用 evaluateParallel() 执行并行计算。
- 当 Stream 进入并行计算模式时,其内部会创建诸如 ReduceTask 这样的任务对象,这些任务类继承自 ForkJoinTask,能够在 ForkJoinPool 中被调度执行。
- ReduceTask 利用 Fork/Join 框架的核心思想——分治法,将大任务分割成小任务并在不同的工作线程上独立执行,最后通过 join 操作合并结果。
- 如使用 Stream 的并行计算功能,应综合考虑以下因素:
- 数据量大小:对于大规模数据集,尤其是需要复杂运算的任务,采用并行计算可以显著提高执行速度。
- 硬件配置:确保当前运行环境为多核处理器,且有足够的内存和CPU资源来支持并发操作。
- 任务性质:若任务可以轻松拆分为独立的子任务,并且结果合并相对简单,更适合应用并行计算。
- 系统负载:在高负载系统中,要避免过度增加并发,以免引发资源竞争和瓶颈问题。
- Stream 计算默认是串行的。
-
Fork/Join
- Fork/Join 框架适用于那些能够递归分解的任务,充分利用了现代多核处理器的强大算力。该框架的设计初衷是为了最大化利用现代多核处理器的能力,通过合理分配和调度工作负载来提升应用程序性能。
- Fork/Join 框架的核心思想是基于“分而治之”(divide and conquer)策略,它允许将一个复杂的任务拆分为若干个可独立执行的子任务,并通过递归方式进一步细分直至子任务足够小可以直接顺序执行。
- Fork/Join 框架采用了独特的工作窃取算法(Work Stealing)。当某一线程在其本地任务队列中无任务可执行时,会从其他线程的任务队列尾部窃取任务来执行,这样不仅有效避免了线程间的竞争,还保证了即使在负载不均的情况下也能充分使用系统资源。
- ForkJoinTask 抽象类扮演着核心角色,它的实例代表了待处理的任务单元,可以异步地提交到 ForkJoinPool 中进行执行。通过调用其 fork() 方法,任务被非阻塞地提交至线程池中某个空闲的工作线程;而 join() 方法则负责等待该任务完成并获取其结果。在实际开发中通常不会直接继承它,而是选择它的两个具体子类:
- RecursiveAction —— 无返回值任务
- RecursiveTask —— 有返回值任务
- RecursiveAction 类和 RecursiveTask 类都提供了 compute() 方法,用于定义任务的具体计算逻辑。
- 当一个 ForkJoinTask 被提交至 ForkJoinPool 后,它会在合适的时候被执行,而执行的过程则依赖于工作窃取算法以及 ForkJoinPool 的调度机制。
- ForkJoinPool 是 Fork/Join 框架的核心执行器,它负责管理和调度执行 ForkJoinTask 的线程池。
-
通信工具类
- Semaphore —— 信号量。用于限制线程的数量(使用构造函数参数的方式)。主要方法:
- acquire() —— 获取许可(阻塞式)
- release() —— 释放许可
- availablePermits() —— 获取剩余可用资源数
- Exchanger —— 交换器。用于两个线程交换数据,专注于点对点的数据传递,可以看作是线程间的一种同步点。主要方法:
- exchange() —— 每个线程调用该方法时,会将自己的数据传给另一个线程,并接收对方线程的数据。这个过程是原子性的,即保证了数据交换的完整性,不会出现竞态条件。
- CountDownLatch —— 倒计数门闩。让线程等待直到计数器减为 0 时开始工作。CountDownLatch 没有重置机制,一旦计数器归零便不能再使用。主要方法:
- countDown() —— 表示当前线程已完成对应的工作,将 CountDownLatch 计数器减 1。如果减到 0,则唤醒所有等待的线程。
- await() —— 使当前线程进入等待状态,直到 CountDownLatch 计数器为 0。
- CyclicBarrier —— 循环栅栏。作用跟 CountDownLatch 类似,它允许一组线程等待所有成员到达某个屏障点后再一起执行后续操作。但是 CyclicBarrier 可以重复使用,并且在每个屏障点都能执行一个预定义的回调任务。主要方法:
- await() —— 等待其他线程到达屏障再执行后面的代码
- reset() —— 如果需要多次使用,则重置 CyclicBarrier
- Phaser —— 相位器。增强的 CyclicBarrier,它允许一组线程在多个阶段之间进行协作。不同于 CyclicBarrier,Phaser 可以动态地增加或减少参与者数量,这意味着即使在运行过程中也可以改变需要同步的线程数。主要方法:
- arrive() —— 当前线程已到达阶段终点,本线程进入阻塞状态。
- arriveAndAwaitAdvance() —— 当前线程已到达阶段终点,并注销一个 parties 数量,非阻塞方法。
- arriveAndDeregister() —— 当前线程已到达阶段终点,并注销本线程的参与
- register() —— 注册一个新的参与线程
- Semaphore —— 信号量。用于限制线程的数量(使用构造函数参数的方式)。主要方法:
-
CopyOnWrite
- CopyOnWrite 的基本思想是在多个调用者同时访问同一份数据时,如果有某个调用者需要修改该数据,则系统会首先复制当前的数据源到一个新的副本上,让调用者在副本上进行修改操作,从而保证原数据在修改期间不受影响。
- 基于 CopyOnWrite 机制实现的并发容器:
- CopyOnWriteArrayList
- CopyOnWriteArraySet
- 这些并发容器在设计上巧妙地实现了读写分离,当执行添加、删除等修改操作时,并不会直接对容器本身进行修改,而是先创建容器的一个完整拷贝,所有的写操作都在这个新副本上完成,然后将原有容器引用指向新副本,这样就使得读取操作可以完全无锁并发进行,极大地提升了并发环境下的读取性能。CopyOnWrite 容器适用于那些读取操作远超写入操作且能接受一定程度的数据一致性延迟的并发场景。
- 要注意 CopyOnWrite 容器的局限性,尤其是对于大数据量和频繁写入的情况,因其每次写入都会触发整个容器的复制操作,可能会带来较大的内存开销及 GC 压力,且无法保证写入数据立即可见。因此,在选择使用 CopyOnWrite 容器之前,开发者应仔细权衡实际业务需求与容器特性之间的匹配度。
-
并发集合容器
- 并发容器提供了细粒度的锁机制、无锁算法(如 CAS)以及优化的数据结构,使得在面对大量并发访问时,能够实现更优的性能和扩展性。
- 并发容器类案例:
- ConcurrentHashMap —— 通过使用分段锁(Lock Striping)技术实现了细粒度的并发控制。每个ConcurrentHashMap内部划分为多个Segment,每个Segment管理一部分数据并对这部分数据独立加锁。
- ConcurrentLinkedQueue —— 该类(称为 无界并发队列)利用 CAS(Compare And Swap)无锁算法来实现线程安全的 FIFO 和双端队列。
- ConcurrentSkipListMap —— 该类基于跳表(SkipList)数据结构,保持了有序性,能够高效地进行并发插入、删除和搜索操作。
- ConcurrentNavigableMap —— 该接口扩展了 NavigableMap 接口,为并发环境下的有序键值对集合提供了丰富的导航方法,如查找最近的键、范围查询等。该接口的主要实现类是 ConcurrentSkipListMap。
- ConcurrentSkipListSet —— 该类底层数据结构同样采用跳表,是一个线程安全且保持排序的集合,它确保了高并发情况下的高效插入、删除以及遍历操作。
- CopyOnWriteArrayList
- ...
-
锁接口和类
- 锁接口与类案例:
- ReentrantLock —— 可重入锁,是一种能够支持同一个线程对同一资源重复加锁而不导致死锁的机制。ReentrantLock 还提供了 Condition 对象,可以用来实现多个不同条件下的线程间协作。
- Condition —— Condition 接口是 AQS 的一个重要补充,提供了比传统 Object 监视器方法(如 wait/notify)更为灵活和强大的条件等待机制。它允许线程在满足特定条件时被唤醒或挂起。
- ReadWriteLock —— 读写锁接口,允许多个读线程同时访问,但在任何写线程访问期间会排斥所有读线程和写线程。
- ReentrantReadWriteLock —— 读写锁接口的实现类,此读写锁为“读多写少”场景设计,允许多个读线程同时访问资源,而写操作具有排他性。
- StampedLock —— 是一种高性能读写锁,不仅支持读锁(悲观读锁)和写锁,而且增加了乐观读锁的功能。乐观读锁在执行期间假设数据不会改变,仅在读取之后验证是否真的没有发生改变,这样可以避免不必要的阻塞,极大地提高并发性能。
- AbstractQueuedSynchronizer —— 简称 AQS,该抽象类扮演了同步器的基础框架角色。通过维护一个 FIFO 等待队列,并使用一个整型变量来表示共享资源的状态,从而支持开发者实现自定义的互斥和共享锁。
- 使用 ReentrantLock 替代 synchronized 示例:
// 使用 synchronized public class SynchronizedExample { private final Object lock = new Object(); public void doSth() { synchronized (lock) { // 临界区操作 } } } // 使用 ReentrantLock public class ReentrantLockExample { private final ReentrantLock lock = new ReentrantLock(); public void doSth() { lock.lock(); try { // 临界区操作 } finally { lock.unlock(); } } }
- 锁接口与类案例:
-
阻塞队列
- 阻塞队列的作用是:在生产者-消费者模型中,无论是插入还是获取元素的操作,若队列当前状态不允许该操作执行,相应的线程会被自动阻塞,直至条件满足时再被唤醒。
- 阻塞队列通过巧妙地结合 ReentrantLock 及其内部的多个 Condition 对象实现了线程间的协作与同步,确保了生产者线程在队列未满时可以顺利地添加元素,而消费者线程则在队列非空时能及时消费元素。
- 阻塞队列接口的主要方法:
- add(E e) —— 添加元素,如果队列已满,则抛出 IllegalStateException("Queue full") 异常。
- remove() —— 若队列为空则抛出 NoSuchElementException 异常,用于移除并返回队列头部的元素。
- element() —— 返回但不移除队列头部的元素,同样在队列为空时抛出 NoSuchElementException 异常。
- offer(E e) —— 尝试将元素放入队列,如果队列已满则返回 false,否则返回 true 表示成功加入。
- poll() —— 尝试从队列中移除并返回头部元素,若队列为空则返回 null。
- peek() —— 查看队列头部元素而不移除,队列为空时也返回 null。
- put(E e) —— 将指定元素添加到队列中,如果队列已满,则当前线程会被阻塞直到有空间可用。
- take() —— 从队列中移除并返回头部元素,如果队列为空,调用此方法的线程会阻塞等待其他线程存入元素。
- offer(E e, long timeout, TimeUnit unit) —— 试图将元素添加到队列,若在给定超时时间内仍无法加入,则返回 false,否则返回 true。
- poll(long timeout, TimeUnit unit) —— 试图从队列中移除并返回一个元素,若在给定超时时间内队列依然为空,则返回 null。
- 阻塞队列(BlockingQueue)接口的实现类:
- ArrayBlockingQueue —— 基于数组结构,具有固定容量,并且支持公平或非公平锁策略。构造时需要指定容量大小,一旦创建后无法更改。
- LinkedBlockingQueue —— 使用链表数据结构,可设置初始容量(默认值为Integer.MAX_VALUE),意味着如果不指定容量,则它是一个无界队列。
- DelayQueue —— DelayQueue 中的元素必须实现 Delayed 接口,每个元素都有一个可延迟的时间,只有当这个延迟时间过期后,消费者才能从队列中取出该元素。这种特性适用于处理定时任务等场景。
- PriorityBlockingQueue —— 一种无界的优先级队列,元素按照优先级顺序被取出。优先级通过构造函数传入的 Comparator 决定,若不提供则按元素的自然排序。
- SynchronousQueue —— 一种特殊的阻塞队列,它没有内部容量,始终要求生产和消费操作完全匹配:每个 put 操作都需要有对应的 take 操作同时发生,反之亦然。对于希望直接传递对象而不进行存储的场景非常有用。
- 线程池(ThreadPoolExecutor)是一个利用阻塞队列作为核心组件的典型例子。在创建线程池时,可以指定一个 BlockingQueue 作为工作队列,用于存储待执行的任务。当核心线程忙碌或超出其最大容量时,新提交的任务会被放入此队列中等待执行。
-
线程池原理
- 线程池为多个线程提供了一种统一的管理方式,通过线程池我们可以重复地利用线程的资源而无需进行频繁地创建。
- 线程池的好处:
- 降低系统资源消耗
- 控制并发数量以防止服务器过载
- 便于统一管理和维护线程
- 线程池的状态:
- RUNNING —— 线程池创建后默认处于此状态,能接受新提交的任务,并且也能处理阻塞队列中的任务。
- SHUTDOWN —— 调用 shutdown() 方法后进入此状态,不再接受新提交的任务,但可以处理存量任务。
- STOP —— 调用 shutdownNow() 方法后变为此状态,不仅不接收新任务,还会尝试中断正在执行的任务,并且不会处理尚未开始执行的任务。
- TIDYING —— 当所有的任务都已经终止并且 workerCount(活动工作线程数)为 0 时,线程池将转换到此状态,并触发 terminated() 钩子方法。
- TERMINATED —— terminated() 方法执行完毕后,线程池最终进入此状态,表明线程池已经彻底关闭,无法再进行任何操作。
- 线程池的大小如何选定:
- CPU 密集型 —— 线程数 = 按照 CPU 核数或者核数 + 1 设定
- I/O 密集型 —— 线程数 = CPU 核数 * (1 + 平均等待时间 / 平均工作时间)
- ThreadPoolExecutor 构造函数的基本参数:
- corePoolSize: 核心线程数,即使没有任务处理时也会保留在线程池中的线程数量。
- maximumPoolSize: 线程池最大容量,当工作队列满载且仍有新任务到来时,线程池将尝试增加到此值。
- keepAliveTime: 非核心线程空闲超时时长,在指定时间内无新任务分配给非核心线程,则会销毁这些线程。
- unit: keepAliveTime 的时间单位,如秒、毫秒等。
- workQueue: 用于存储待执行任务的阻塞队列,类型可选为 LinkedBlockingQueue、ArrayBlockingQueue、SynchronousQueue 或 DelayQueue 等。
- 核心线程在没有任务可执行时不会被销毁,除非设置了允许核心线程超时的选项。
- 拒绝策略 —— 当线程池无法再接受新任务时(例如超过最大线程数且任务队列已满),则触发拒绝策略。Java 提供了四种预定义的拒绝策略:
- AbortPolicy:默认策略,抛出 RejectedExecutionException 异常。
- DiscardPolicy:默默地丢弃任务,不做任何处理。
- DiscardOldestPolicy:丢弃队列中最旧的任务(即最先加入队列的任务),然后重新尝试执行新任务。
- CallerRunsPolicy:由调用线程执行被拒绝的任务。
- ThreadPoolExecutor 的整个处理流程:
- 当线程数量不足 corePoolSize 时,优先创建核心线程执行任务。
- 线程数量满足 corePoolSize 时,将任务加入 workQueue 等待执行。
- workQueue 已满且线程数量未达 maximumPoolSize 时,创建非核心线程执行任务。
- 若所有条件均无法接纳新任务,则根据设定的拒绝策略处理被拒绝的任务。
- 创建线程池的案例:
public static void main(String[] args) { ExecutorService executor = new ThreadPoolExecutor( 2, // 核心线程数 5, // 最大线程数 60L, // 空闲线程存活时间 TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>() // 使用无界链式阻塞队列 ); for (int i = 0; i < 10; i++) { Runnable task = () -> System.out.println("Task " + Thread.currentThread().getName() + " is running"); executor.execute(task); } executor.shutdown(); // 提交完所有任务后关闭线程池 }
13. Java 虚拟机
-
Java 内存结构
- JVM 的内存结构大致划分为:
- 堆(Heap) —— 线程共享。所有的对象实例以及数组都要在堆上分配。回收器主要管理的对象。
- 方法区(Method Area) —— 线程共享。存储类信息、常量、静态变量、即时编译器编译后的代码。
- 方法栈(JVM Stack) —— 线程私有。存储局部变量表、操作栈、动态链接、方法出口,对象指针。
- 本地方法栈(Native Method Stack) —— 线程私有。为虚拟机使用到的 Native 方法服务。如 Java 使用 c 或者 c++ 编写的接口服务时,代码在此区运行。
- 程序计数器(Program Counter Register) —— 线程私有。当前线程所执行的字节码的行号指示器。指向下一条要执行的指令。
- JVM 的内存结构大致划分为:
-
堆
- 堆的作用是存放对象实例和数组。
- 堆内存分为新生代和老年代。新生代又可以分为 Eden 空间、From Survivor 空间(s0)、To Survivor 空间(s1)。对不同代采用不同的垃圾回收算法。
- 堆的控制参数包含:
- -Xms 设置堆的最小空间大小。
- -Xmx 设置堆的最大空间大小。
- -XX:NewSize 设置新生代最小空间大小。
- -XX:MaxNewSize 设置新生代最大空间大小。
- 如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。
- 堆是 Java 中垃圾收集器管理的主要区域,因此也被称为 GC 堆。
-
栈
- 每个线程会有一个私有的栈。
- 每个线程中方法的调用又会在本栈中创建一个栈帧。
- 栈的控制参数:
- -Xss 控制每个线程栈的大小。
- 栈的可能情况:
- StackOverflowError —— 线程请求的栈深度大于虚拟机所允许的深度时抛出。
- OutOfMemoryError —— 虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存时会抛出。
-
垃圾回收
- JVM 中的垃圾回收器采用可达性分析来探索所有存活的对象。它从一系列 GC Roots 出发,边标记边探索所有被引用的对象。
- JVM 判断对象死亡的方法 —— 可达性分析算法
- GC Roots 包括(但不限于)如下几种:
- Java 方法栈桢中的局部变量;
- 已加载类的静态变量;
- JNI handles;
- 已启动且未停止的 Java 线程。
- 大部分的 Java 对象只存活一小段时间,而存活下来的小部分 Java 对象则会存活很长一段时间。这个假设,或者说被验证普遍存在的现象,造就了 Java 虚拟机的分代回收思想。简单来说,就是将堆空间划分为两代,分别叫做新生代和老年代。新生代用来存储新建的对象。当对象存活时间够长时,则将其移动到老年代。
- 当发生 Minor GC 时,我们应用了“标记 - 复制”算法,将 Survivor 区中的老存活对象晋升到老年代,然后将剩下的存活对象和 Eden 区的存活对象复制到另一个 Survivor 区中。
- 卡表(Card Table)技术将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。
- 针对新生代的垃圾回收器共有三个(它们采用的都是“标记 - 复制”算法):
- Serial —— 用于单线程
- Parallel Scavenge —— 用于多线程
- Parallel New —— 用于高吞吐率多线程
- 针对老年代的垃圾回收器共有三个:
- Serial Old —— 采用“标记 - 压缩”算法,用于单线程
- Parallel Old —— 采用“标记 - 压缩”算法,用于多线程
- Concurrent Mark Sweep(CMS) —— 采用“标记 - 清除”算法,用于多线程,并且清除是并发的。在 Java9 中 CMS 已被废弃
- G1(Garbage First)是一个横跨新生代和老年代的垃圾回收器。它采用“标记 - 压缩”算法,在选择进行垃圾回收的区域时,它会优先回收死亡对象较多的区域。这也是 G1 名字的由来。
- ZGC 是在 Java11 引入,宣称暂停时间不超过 10ms。
-
JVM 内存区域
- 方法区 —— 各个线程共享的内存区域。用于存放 类的信息、静态变量、编译后的代码、常量池(包含 字面量、符号引用 等)
- 堆 —— 各个线程共享的内存区域
- 虚拟机栈 —— 线程私有的内存区域。用于存放方法信息,以栈帧(包括 局部变量表、操作数栈、指向运行时常量池的引用、方法返回地址 等)的方式组织。顶部栈帧存放的是正在执行的方法信息
- 本地方法栈 —— 线程私有的内存区域。服务 native 方法
- 程序计数器 —— 线程私有的内存区域。用于存放虚拟机字节码指令地址
-
Java 虚拟机栈
- Java 虚拟机栈也是线程私有的,它的生命周期与线程相同
- Java 虚拟机栈描述的是 Java 方法执行的内存模型:每个方法执行的同时会创建一个栈帧
- 虚拟机栈与本地方法栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的 Native 方法服务。
-
class 文件
- class 文件格式采用一种类似于 C 语言结构体的伪结构来存储数据,这种伪结构只有 2 种数据类型:
- 无符号数
- 无符号数用 u 表示后面跟 1、2、4、8 代表 1 个字节、2 个字节、4 个字节、8 个字节。
- 无符号数用来描述数字、索引引用、数量值、字符串值。
- 表
- 表是由无符号数或者其他表作为数据复合而成的数据类型。
- 所有表都习惯以 _info 结尾。
- 无符号数
- 无论是无符号数还是表,class 文件都是以 8 位(8 bit),一个字节为单位存储的,各个数据项目紧密无间隔排列的二进制流。当数据项长度超过 8 位时,按照高位在前(Big Endian)的方式分隔成若干个 8 位字节存储。
- 整个 class 文件实质上就是一张表,其中的数据项由各个子表和无符号数构成。
- class 文件的结构如下:
- Magic Number: u4,魔数,用于识别 class 文件。固定为 0xcafebabe。
- Minor Version: u2,表示 Java 的次版本号。
- Major Version: u2,表示 Java 的主版本号。
- Constant Pool Count: u2,表示常量池大小。注意,容量计数是从 1 开始而不是从 0 开始。
- Constant Pool: cp_info,表示常量池(可以理解为 class 文件中的资源仓库),而池中的常量共有 (Constant Pool Count - 1) 项,每一项常量都是一个表。常量池的作用是:存放编译器生成的各种字面量和符号引用。常量项的数据类型包括:
- CONSTANT_Utf8_info
- CONSTANT_Integer_info
- CONSTANT_Float_info
- CONSTANT_Long_info
- CONSTANT_Double_info
- CONSTANT_Class_info
- CONSTANT_String_info
- CONSTANT_Fieldref_info
- CONSTANT_Methodref_info
- CONSTANT_InterfaceMethodref_info
- CONSTANT_NameAndType_info
- CONSTANT_MethodHandle_info
- CONSTANT_MethodType_info
- CONSTANT_InvokeDynamic_info
- Access Flags: u2,用于标志类或接口层次的访问信息。如 这个 Class 文件是类还是接口?是否是 public?是否为抽象的 abstract?是否为 final 的?
- This Class: u2,引用于确定此类的全限定名称。
- Super Class: u2,用于确定这个类父类的全限定名。
- Interfaces Count: u2,表示当前类实现的接口数量。
- Interfaces: u2 * Interfaces Count,用来描述这个类实现了哪些接口。
- Fields Count: u2,表示当前类中定义的变量的数量。
- Fields: field_info * Fields Count,字段表,用于描述接口或类中声明的变量。包括类变量、实例变量,但不包括方法内部声明的局部变量。字段表结构如下:
- Access Flags: u2,用于标志字段的访问信息。包括 public、private、protected、static、final、volatile、transient、enum 和 native。
- Name Index: u2,代表字段或方法的简单名称。
- Descriptor Index: u2,代表字段或方法的描述符。描述符的作用是用来描述字段的数据类型、方法参数列表和返回值。
- Methods Count: u2,表示当前类中定义的方法的数量。
- Methods: method_info * Methods Count,方法表,和字段表集合类似,表示当前类中定义的方法。方法的实际代码存放在属性表 attribute_info 中的 Code 属性中。
- Attributes Count: u2,表示当前类中定义的属性的数量。
- Attributes: attribute_info * Attributes Count,表示当前类中定义的属性。class 文件、字段表、方法表中都可以包含自己的属性表集合用于描述自己特定的属性。各类属性如下:
- 对 Java 虚拟机正确解读 class 文件的关键属性:
- ConstantValue
- Code
- StackMapTable
- Exceptions
- BootstrapMethods
- 对 Java SE 平台的类库正确解读 class 文件的关键属性:
- InnerClasses
- EnclosingMethod
- Synthetic
- Signature
- RuntimeVisibleAnnotations
- RuntimeInvisibleAnnotations
- RuntimeVisibleParameterAnnotations
- RuntimeInvisibleParameterAnnotations
- RuntimeVisibleTypeAnnotations
- RuntimeInvisibleTypeAnnotations
- AnnotationDefault
- MethodParameters
- 作为实用工具使用的属性:
- SourceFile
- SourceDebugExtension
- LineNumberTable
- LocalVariableTable
- LocalVariableTypeTable
- Deprecated
- 对 Java 虚拟机正确解读 class 文件的关键属性:
- class 文件格式采用一种类似于 C 语言结构体的伪结构来存储数据,这种伪结构只有 2 种数据类型:
-
字节码指令
- Java 虚拟机的字节码指令由 1 个字节长度的操作码(Opcode)以及紧随其后的 0~N 个操作数(Operands)构成。这意味着整个指令集中包含的指令总数不超过 256 条。
- 通用指令
- 无操作指令:
nop
- 无操作指令:
- 加载和存储指令
- 将一个局部变量加载到操作数栈:
iload
iload_<n>
lload
lload_<n>
fload
fload_<n>
dload
dload_<n>
aload
—— 从局部变量表加载一个 reference 类型值到操作数栈中aload_<n>
- 将一个数值从操作数栈存储到局部变量表:
istore
istore_<n>
lstore
lstore_<n>
fstore
fstore_<n>
dstore
dstore_<n>
astore
—— 将一个 reference 类型的数据保存到本地变量表中astore_<n>
- 将一个常量加载到操作数栈:
bipush
—— 将一个 byte 类型数据入栈sipush
ldc
—— 从运行时常量池中提取数据并压入操作数栈ldc_w
ldc2_w
aconst_null
iconst_m1
iconst_<i>
lconst_<l>
fconst_<f>
dconst_<d>
- 扩充局部变量表的访问索引:
wide
- 将一个局部变量加载到操作数栈:
- 运算指令(以上 x=i,l,f,d 分别表示 int 型、long 型、float 型、double 型)
- 加法指令:
xadd
- 减法指令:
xsub
- 乘法指令:
xmul
- 除法指令:
xdiv
- 求余指令:
xrem
- 取反指令:
xneg
- 位移指令:
ishl
ishr
iushr
lshl
lshr
lushr
- 按位或指令:
ior
lor
- 按位与指令:
iand
land
- 按位异或指令:
ixor
lxor
- 局部变量自增指令:
iinc
- 比较指令:
dcmpg
dcmpl
fcmpg
fcmpl
lcmp
- 加法指令:
- 类型转换指令
- Java 虚拟机直接支持(无需转换指令)以下的数值类型的宽化类型转换:
- int 类型到 long、float、double
- long 类型到 float、double
- float 类型到 double
- 处理窄化类型转换指令:
i2b
i2c
i2s
l2i
f2i
f2l
d2i
d2l
d2f
- Java 虚拟机直接支持(无需转换指令)以下的数值类型的宽化类型转换:
- 对象创建和访问指令
- 创建类实例指令:
new
- 创建数组指令:
newarray
anewarray
multianewarray
- 访问类变量(static 字段)指令:
getstatic
putstatic
- 访问实例变量的指令:
getfield
putfield
- 将一个数组元素加载到操作数栈指令:
baload
caload
saload
iaload
laload
faload
daload
aaload
- 将一个操作数栈的值存储到数组指令:
bastore
castore
sastore
iastore
fastore
dastore
aastore
- 取数组长度指令:
arraylength
- 检查类实例类型指令:
instanceof
checkcast
- 创建类实例指令:
- 操作数栈管理指令
- 将操作数栈栈顶元素出栈指令:
pop
- 将操作数栈栈顶两个元素出栈指令:
pop2
- 复制栈顶 1 个或 2 个数值,并将复制的值重新压入栈顶指令:
dup
dup_x1
dup_x2
dup2
dup2_x1
dup2_x2
- 将栈顶两个数值互换指令:
swap
- 将操作数栈栈顶元素出栈指令:
- 控制转移指令
- 条件分支指令:
ifeq
iflt
ifle
ifne
ifgt
ifge
ifnull
ifnonnull
if_icmpeq
if_icmpne
if_icmplt
if_icmpgt
if_icmple
if_icmpge
if_acmpeq
if_acmpne
- 复合条件分支指令:
tableswitch
lookupswitch
- 无条件分支指令:
goto
goto_w
jsr
jsr_w
ret
- 条件分支指令:
- 方法调用和返回指令
- 方法调用指令:
invokevirtual
—— 调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派)。invokeinterface
—— 调用接口方法,在运行时搜索一个实现了此接口的对象,找出合适的方法进行调用。invokespecial
—— 调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。invokestatic
—— 调用类(的静态)方法invokedynamic
—— 在运行时动态解析出调用点限定符所引用的方法,并执行该方法。
- 方法返回指令:
return
—— 当方法为 声明为 void 的方法、实例初始化方法、类和接口的类初始化方法 时使用。ireturn
—— 返回 int 类型的数据,当返回值是 boolean、byte、char、short 和 int 时使用。lreturn
freturn
dreturn
areturn
- 方法调用指令:
- 异常处理指令
- 抛出异常指令:
athrow
—— Java 程序中显示抛出异常的操作(throw 语句)都由本指令来实现
- Java 虚拟机中处理异常(catch 语句)不是由字节码指令实现的,而是采用异常处理器(异常表)来完成的。
- 抛出异常指令:
- 同步指令
- 支持 synchronized 关键字指令:
monitorenter
monitorexit
- 支持 synchronized 关键字指令:
-
JVM 参数调优
- 堆内存设置:
- -Xms ——
-Xms256m
设置初始堆大小为 256MB - -Xmx ——
-Xmx512m
设置最大堆大小为 512MB - -Xss1M —— 设置每个线程的堆栈大小为 1MB
- -Xms ——
- 垃圾收集器与日志输出:
- -verbose:gc —— 启用垃圾收集日志
- -XX:+PrintGC —— 开启基本的 GC 信息打印
- -XX:+PrintGCDetails —— 打印详细的垃圾收集信息
- -XX:+PrintGCDateStamps —— 在垃圾收集日志中添加时间戳
- -XX:+UseG1GC —— 启用 G1 垃圾收集器
- -XX:MaxGCPauseMillis ——
-XX:MaxGCPauseMillis=200
设置垃圾收集的最大暂停时间 - -Xloggc:gc.log —— 将 GC 日志输出到指定的文件 gc.log
- -Djava.util.logging.config.file ——
-Djava.util.logging.config.file=logging.properties
设置日志系统属性
- 性能监控:
- -XX:+PrintGCDetails —— 打印垃圾收集细节
- -XX:+PrintGCDateStamps —— 在 GC 日志中添加时间戳
- JVM 启动
- -XX:+PrintCommandLineFlags —— 打印出 JVM 启动时使用的所有参数
- -Xcheck:jni —— 开启对 JNI 函数的检查,这有助于发现 JNI 相关的问题
- 配置元空间
- -XX:MetaspaceSize ——
-XX:MetaspaceSize=64m
设置初始元空间大小为 64MB - -XX:MaxMetaspaceSize ——
-XX:MaxMetaspaceSize=256m
设置最大元空间大小为 256MB
- -XX:MetaspaceSize ——
- Java 飞行记录器
- -XX:+UnlockCommercialFeatures —— 解锁商业特性(在 JVM 8 中需要)
- -XX:+FlightRecorder —— 开启Java飞行记录器
- 类加载信息
- -XX:+TraceClassLoading —— 启用类加载跟踪
- -XX:+TraceClassUnloading —— 启用类卸载跟踪
- 堆内存设置:
-
Java 对象模型
- 以下主要探讨 HotSpot 的 Oop-Klass 对象模型。
- JVM 使用 Oop ,即 Ordinary object pointer(普通对象指针)来表示一个 Java 对象,是 HotSpot 用来表示 Java 对象的实例信息的一个体系。Oop 是一个继承体系,其中 oop 是 Oop 体系中的最高父类。
- oop 的子类有两个,分别是 instanceOop 和 arrayOop。前者表示 Java 中普通的对象,后者则表示数组对象。arrayOop 也有两个子类,objArrayOop 表示普通对象类型的数组,而 typeArrayOopDesc 则表示基础类型的数组。
- oop 的存储结构主要包括:
- 对象头
- _mark —— 存储对象的运行时记录信息(包括 对象hash值、线程ID、分代年龄 等),markOop 类型
- _metadata —— 一个指针,指向当前对象所属的 Klass 对象
- 对象体
- JVM 将 Java 对象的 field 存储在 oop 的对象体中。每个 field 在 oop 中都有一个对应的偏移量(offset),oop 通过该偏移量得到该 field 的地址,再根据地址得到具体数据
- 对象头
- Java 中的普通方法(没有 static 和 final 修饰)是动态绑定的,在 C++ 中,动态绑定通过虚函数来实现,代价是每个 C++ 对象都必须维护一张虚函数表。Java 的特点就是一切皆是对象,如果每个对象都维护一张虚函数表,内存开销将会非常大。JVM 对此做了优化,虚函数表不再由每个对象维护,改成由 Class 类型维护,所有属于该类型的对象共用一张虚函数表。因此我们并没有在 oop 上找到方法调用的相关逻辑,这部分的代码被放在了 klass 里面。
- Klass主要提供了两个功能:
- 用于表示 Java 类。Klass 中保存了一个 Java 对象的类型信息,包括类名、限定符、常量池、方法字典等。一个 class 文件被 JVM 加载之后,就会被解析成一个 Klass 对象存储在内存中。
- 实现对象的虚分派(virtual dispatch)。所谓的虚分派,是JVM用来实现多态的一种机制。
- 跟 Oop 一样,Klass 也有一个继承体系:
- Klass —— Klass 继承体系的最高父类
- InstanceKlass —— 表示一个 Java 普通类,包含了一个类运行时的所有信息
- InstanceMirrorKlass —— 表示 java.lang.Class
- InstanceClassLoaderKlass —— 主要用于遍历 ClassLoader 继承体系
- InstanceRefKlass —— 表示 java.lang.ref.Reference 及其子类
- ArrayKlass —— 表示一个 Java 数组类
- ObjArrayKlass —— 普通对象的数组类
- TypeArrayKlass —— 基础类型的数组类
- InstanceKlass —— 表示一个 Java 普通类,包含了一个类运行时的所有信息
- Klass —— Klass 继承体系的最高父类
- HotSpot 把 Java 中的方法都抽象成了 Method 对象,InstanceKlass 中的成员属性 _methods 就保存了当前类所有方法对应的 Method 实例。
- Oop 表示对象的实例,存储在堆中;Klass 表示对象的类型,存储在方法区中。
-
HotSpot
- HotSpot 就是一种 JVM 的实现。
- HotSpot 使用 Oop-Klass 模型来表示 Java 的对象,其中 Klass 对应着 Java 对象的类型(Class),而 Oop 则对应着 Java 对象的实例(Instance)。
- Java8 中,HotSpot 虚拟机改变了原有方法区的物理实现,将原本由 JVM 管理内存的方法区的内存移到了虚拟机以外的计算机本地内存,并将其称为元空间(Metaspace)。
- 在 HotSpot 虚拟机中,对象在内存中存储的布局分为:
- 对象头。包括两部分信息:
- 第一部分存储对象自身的运行时数据,如 哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳 等
- 第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是一个 Java 数组,对象头中还有一块用于记录数组长度的数据。
- 实例数据。这部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型字段内容,无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。
- 对齐填充。因为 HotSpot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,所以可能有填充的占位符。
- 对象头。包括两部分信息:
- 对于 HotSpot 虚拟机而言,在 JDK 1.8 以前,方法区被实现为“永久代”(Permanent Generation),属于堆的逻辑组成部分,并提供了两个参数调节其大小,-XX:PermSize 用于设定初始容量,-XX:MaxPermSize 用于设定最大容量。JDK 1.8 之后,HotSpot 不再有“永久代”的概念,类的元信息数据迁移到被称为“元空间”(Metaspace)的新区域,而静态变量、常量等则存储于堆中。元空间没有使用堆内存,而是分配在本地内存中,默认情况下其容量只受可用的本地内存大小限制。
-
类加载机制
- Java 在 new 一个对象的时候,会先查看对象所属的类有没有被加载到内存,如果没有的话,就会先通过类的全限定名来加载。加载并初始化类完成后,再进行对象的创建工作。
- Java 使用双亲委派模型来进行类的加载,双亲委托模型的工作过程:
- 如果一个类加载器(ClassLoader)收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委托给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需要加载的类)时,子加载器才会尝试自己去加载。
- 使用双亲委托机制的好处是:能够有效确保一个类的全局唯一性,当程序中出现多个限定名相同的类时,类加载器在执行加载时,始终只会加载其中的某一个类。
- 类的完整加载过程:
- 加载 —— 由类加载器负责根据一个类的全限定名来读取此类的二进制字节流到 JVM 内部,并存储在运行时内存区的方法区,然后将其转换为一个与目标类型对应的 java.lang.Class 对象实例
- 验证:
- 格式验证 —— 验证是否符合 class 文件规范
- 语义验证 —— 检查一个被标记为 final 的类型是否包含子类;检查一个类中的 final 方法是否被子类进行重写;确保父类和子类之间没有不兼容的一些方法声明(比如方法签名相同,但方法的返回值不同)
- 操作验证 —— 在操作数栈中的数据必须进行正确的操作,对常量池中的各种符号引用执行验证
- 准备 —— 为类中的所有静态变量分配内存空间,并为其设置一个初始值(被 final 修饰的 static 变量(常量),会直接赋值;由于还没有产生对象,实例变量不在此操作范围内)
- 解析 —— 将常量池中的符号引用转为直接引用(得到类或者字段、方法在内存中的指针或者偏移量,以便直接调用该方法),这个可以在初始化之后再执行。
- 初始化(先父后子)
- 为静态变量赋值
- 执行 static 代码块(static 代码块只有 JVM 能够调用)
- 创建对象的完整过程:
- 在堆区分配对象需要的内存
- 对所有实例变量赋默认值
- 执行实例初始化代码
- 如果有类似于
Child c = new Child()
形式的 c 引用的话,在栈区定义 Child 类型引用变量 c,然后将堆区对象的地址赋值给它- 每个子类对象持有父类对象的引用,可在内部通过super关键字来调用父类对象,但在外部不可访问
- 通过实例引用调用实例方法的时候,先从方法区中对象的实际类型信息找,找不到的话再去父类类型信息中找
- 如果是多线程需要同时初始化一个类,仅仅只能允许其中一个线程对其执行初始化操作,其余线程必须等待,只有在活动线程执行完对类的初始化操作之后,才会通知正在等待的其他线程。
- 因为子类存在对父类的依赖,所以类的加载顺序是先加载父类后加载子类,初始化也一样。
- 最终,方法区会存储当前类类信息,包括类的静态变量、类初始化代码(定义静态变量时的赋值语句 和 静态初始化代码块)、实例变量定义、实例初始化代码(定义实例变量时的赋值语句实例代码块和构造方法)和实例方法,还有父类的类信息引用。
-
编译和反编译
- 编译就是将源码转换成字节码的过程。源码是给人看的,字节码是给虚拟机看的。
- 反编译就是将字节码转换成源码的过程。源码是字符编码,字节码是二进制字节流。
-
反编译工具
- javap —— JDK 自带的反编译工具,可以对代码反编译,也可以查看 java 编译器生成的字节码。常用参数包括:
- -p —— 显示所有类和成员
- -c —— 对代码进行反汇编
- JD-GUI —— 下载后将类文件或者 jar 包直接拖动到界面即可
- arthas —— 可以使用 jad 命令将 JVM 中运行的 class 的 byte code 反编译成 java 代码,便于理解业务
- CFR —— 它将反编译现代 Java 特性,支持 Java 9、12 和 14 的大部分。它完全是用 Java 6 编写的,所以在任何地方都可以工作
- IDEA —— 没错,就是 IDEA 这个 IDE 本身,它已经对反编译工作做得很好了
- decompiler.com —— 在线反编译工具
- javap —— JDK 自带的反编译工具,可以对代码反编译,也可以查看 java 编译器生成的字节码。常用参数包括:
-
JIT
- JIE 的全称是 Just-In-Time 编译。狭义来说是当某段代码即将第一次被执行时进行编译,因而叫“即时编译”。JIT 编译是动态编译的一种特例。JIT 编译一词后来被泛化,时常与动态编译等价;但要注意广义与狭义的JIT编译所指的区别。
- 当 JVM 发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,在运行时,JVM 将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器就是 JIT。
- 只有对频繁执行的代码,JIT 编译才能保证有正面的收益。
- 程序中的代码只有是热点代码时,才会编译为本地代码,其中热点代码主要有两类:
- 被多次调用的方法
- 被多次执行的循环体
- 判断热点代码的探测方式主要有两种:
- 基于采样的热点探测 —— 采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,那这个方法就是“热点方法”。
- 基于计数器的热点探测 —— 采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阀值,就认为它是“热点方法”。在 HotSpot 虚拟机中使用的是第二种。
-
虚拟机性能监控和故障处理工具
- jps
- jps 全称为 JVM Process Status Tool,它可以列出正在运行的虚拟机进程, 并显示虚拟机执行主类(Main Class, main()函数所在的类)名称以及这些进程的本地虚拟机唯一 ID(LVMID,Local Virtual Machine Identifier)。
- 它是使用频率最高的 JDK 命令行工具, 因为其他的 JDK 工具大多需要输入它查询到的 LVMID 来确定要监控的是哪一个虚拟机进程。
- 对于本地虚拟机进程来说, LVMID 与操作系统的进程 ID(PID, Process Identifier)是一致的, 使用 Windows 的任务管理器或者 UNIX 的 ps 命令也可以查询到虚拟机进程的 LVMID,但如果同时启动了多个虚拟机进程,无法根据进程名称定位时,那就必须依赖 jps 命令显示主类的功能才能区分了。
- jsp 的命令格式为
jps [option] [hostid]
,其中[option]
包括如下:- -q —— 只输出 LVMID,省略主类的名称
- -m —— 输出虚拟机进程启动时传递给主类 main() 函数的参数
- -l —— 输出主类的全名,如果进程执行的 Jar 包,则输出 Jar 路径
- -v —— 输出虚拟机进程启动时的 JVM 参数
- 我们常用的就是 -l 快速显示出我们想要查看的系统运行进程所在的 ID,后续配合我们的 jstat 工具使用。
- jstack
- jstack 全称为 JVM Stack Trace,用于生成虚拟机当前时刻的线程快照。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的目的主要是定位线程长时间出现停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的原因。
- jstack 的命令格式为
jstack [option] vmid
,其中[option]
包括如下:- -F —— 当正常输出的请求不被响应时,强制输出线程堆栈
- -l —— 除堆栈外,显示关于锁的附加信息
- -m —— 调用到本地方法的时候,可以显示 C/C++ 的堆栈
- jmap
- jmap 全称为 JVM Memory Map,用于生成堆转储快照(一般称为 heapdump 或 dump 文件)。它还可以查询 finalize 执行队列、Java 堆和方法区的详细信息,如空间使用率、当前用的是哪种收集器等。
- jmap 的命令格式为
jmap [option] vmid
,其中[option]
包括如下:- -dump —— 生成 Java 堆转储快照。其格式为
-dump:[live,]format=b,file=<filename>
,其中 live 子参数说明是否只 dump 出存活的对象 - -finalizer —— 显示在 F-Queue 中等待 Finalizer 线程执行 finalize 方法的对象。只在 Linux/Solaris 平台下有效
- -heap —— 显示 Java 堆详细信息,如使用哪种回收器、参数配置、分代状况等。只在 Linux/Solaris 平台下有效
- -histo —— 显示 Java 堆中对象的统计信息,如类、对象数量、合计容量等
- -permstat —— 以 ClassLoader 为统计口径显示永久代内存状态。只在 Linux/Solaris 平台下有效
- -F —— 当虚拟机进程对 -dump 选项没有响应时,可使用该选项强制获取 -dump 快照。只在 Linux/Solaris 平台下有效
- -dump —— 生成 Java 堆转储快照。其格式为
- jstat
- jstat 全称为 JVM Statistics Monitoring Tool,它用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类加载、 内存、 垃圾收集、 即时编译等运行时数据, 在没有 GUI 图形界面、 只提供了纯文本控制台环境的服务器上, 它将是运行期定位虚拟机性能问题的常用工具。
- jstat 的命令格式为
jstat [ option vmid [interval[s|ms] [count]] ]
,解读如下:- 如果是本地虚拟机进程, VMID 与 LVMID 是一致的;如果是远程虚拟机进程, 那 VMID 的格式应当是:
[protocol:][//]lvmid[@hostname[:port]/servername]
参数 interval 和 count 代表查询间隔和次数, 如果省略这 2 个参数,说明只查询一次。 - option 代表用户希望查询的虚拟机信息, 主要分为三类:类加载、 垃圾收集、 运行期编译状况,具体如下:
- -class —— 监视类加载、卸载数量、总空间、类装载所耗费的时间
- -gc —— 监视 Java 堆状况,包括 Eden 区、2 个 Survivor 区、老年代、永久代等的容量以及已用空间还有垃圾收集时间合计等信息。
- gcutil —— 监视内容与 -gc 基本相同,但输出主要关注 Java 堆各个区域使用到的最大、最小空间。
- ...
- 如果是本地虚拟机进程, VMID 与 LVMID 是一致的;如果是远程虚拟机进程, 那 VMID 的格式应当是:
- jconsole
- jconsole 是 Java 平台自带的监控工具,用于监控 Java 应用程序的 JVM 性能。它提供了一个简单的图形用户界面(GUI),可以连接并监控本地或远程 JVM 实例。
- 启动 jconsole 后,可以选择本地进程或输入远程 JVM 的 IP 地址和端口号进行连接。连接成功后,可以看到 JVM 的概述信息,包括内存使用情况、线程数、类加载情况等。
- 在 jconsole 的内存监控选项卡中,可以实时查看堆内存和非堆内存的使用情况,包括各个代(如新生代、老年代)的内存使用情况。当内存使用率达到阈值时,jconsole 会发出警告,帮助开发者及时发现内存泄漏等问题。
- jconsole 的线程监控选项卡可以显示当前 JVM 中的线程列表,包括线程状态、堆栈跟踪等信息。通过线程监控,可以及时发现死锁、线程泄漏等问题。
- jps
14. Spring 注解
-
@Configuration
- 标注该类等价于 XML 配置中的 beans,相当于 IoC 容器。
- 它的某个方法头上如果注册了 @Bean,就会作为这个 Spring 容器中的 Bean,与 XML 中配置的 bean 意思一样。
- 在程序中使用 @Autowired 或 @Resource 注解,就可取得 @Bean 注解的 bean。
-
@Value
- 简化从 properties 文件中获取值。
- 使用本注解,可以让变量即使被赋了初值仍以配置文件的值为准。
-
@Controller
-
@Service
-
@Repository
-
@Component
- 声明该类是一个 Bean。
- 前三个只是后面一个的别名,作用是一样的。
-
@PreDestroy
- 在销毁 Bean 之前调用该方法。
- 只能有一个方法可以使用本注解。
- 方法不能有参数,返回值必须是 void,方法本身必须是非静态的。
- 本方法在 destroy() 方法调用后得到执行。
-
@PostConstruct
- 在创建 Bean 之后调用该方法。
- 只能有一个方法可以使用本注解。
- 方法不能有参数,返回值必须是 void,方法本身必须是非静态的。
- 在构造方法和 init 方法(如果有的话)之间得到调用,只会执行一次。
-
@Primary
- 自动装配时当出现多个 Bean 候选者时,被注解为 @Primary 的 Bean 将作为首选者,否则将抛出异常。
-
@Lazy
- 指定该 Bean (就是类啦)是否延迟初始化
-
@Autowired
- 自动装配。
- 默认先按 byType,再按 byName 的方式进行装配。如果最后还有多个 bean 被找到,则报出异常。
- 可以使用 @Qualifier 注解手动指定需要装配的 bean 的名字。
- 如果要允许 null 值,则可以设置它的 required 属性为 false。
-
@Resource
- 与 @Autowired 类似,但是它默认先按 byName 再按 byType 的方式进行装配。如果找不到或找到多个,则抛出异常。
- 可以手动指定 bean。它有两个属性 name 和 type。使用 name 属性则用 byName 的自动注入,而使用 tyype 属性则用 byType 的自动注入。
-
@Async
- 标记该方法为异步方法或标记某个类的所有方法为异步方法。
- 被注解的方法被调用的时候,会在新线程中执行,而调用它的方法会在原来的线程中执行。
- 可以为 value 属性指定一个字符串作为执行器 ID。可选。
-
@Named
- 功能和 @Component 一样。
- 可以有值,如果没有则生成的 Bean 名称默认和类名一致。
-
@Singleton
- 标记该类为一个单例类,Spring 会自动将其作为单例类处理,不需要程序员手动编写。
-
@Valid
- @Valid 是在 @RequestBody 接收参数的情况下才会生效。
- @Valid 是应用在 javabean 上的校验。
- 使用 hibernateValidation.jar 做校验。
-
@Validated
- @Validated 是应用在 Controller 上的校验。
- 使用 springValidator 做校验。
-
@RequestBody
- 有个默认属性 required,默认是 true,当 body 里没内容时抛异常。
-
@CrossOrigin
- 作用为 Controller 或其方法上,解决跨域访问的问题。
- 如果失效则可能方法没解决是 GET 还是 POST 方式,指定即可解决问题。
-
@RequestParam
- 作用是提取和解析请求中的参数。
-
@Scope
- 配置 bean 的作用域(可能值包括 singleton、prototype、request)。
-
@RestController
- @RestController = @Controller + @ResponseBody。
-
@RequestMapping
- 处理映射请求。
- 用于类上时,表示类中的所有响应请求的方法都是以该地址作为父路径。
-
@GetMapping
-
@PostMapping
@GetMapping(value = "page")
等价于@RequestMapping(value = "page", method = RequestMethod.GET)
@PostMapping(value = "page")
等价于@RequestMapping(value = "page", method = RequestMethod.POST)