2025Java 面试八股②(含 121 道面试题和答案)

前面发了 60 个,这篇把剩下的 61 个面试题也补上,如果对你有帮忙,收藏不迷路!
61. 装箱和拆箱的原理和作用⭐⭐⭐⭐
装箱和拆箱是指基本类型与其对应的包装类之间的相互转换。装箱和拆箱的引入简化了基本类型与对象类型之间的转换操作。
装箱
装箱是将基本类型转换为其对应的包装类对象的过程。例如,将 int 转换为 integer,将 double 转换为 Double 等。
自动装箱
Java 5 引入了自动装箱功能,使得在需要对象的地方可以自动将基本类型转换为其对应的包装类对象。编译器会自动插入必要的转换代码。
手动装箱
在没有自动装箱的情况下,可以手动进行装箱操作:
拆箱
拆箱是将包装类对象转换为其对应的基本类型的过程。例如,将 Integer 转换为 int,将 Double 转换为 double 等。
自动拆箱
自动拆箱与自动装箱类似,编译器会在需要基本类型的地方自动将包装类对象转换为基本类型。
手动拆箱
在没有自动拆箱的情况下,可以手动进行拆箱操作:
装箱和拆箱的作用
1、 自动装箱和拆箱可以减少显式转换的代码,使代码更简洁易读。
2、Java 的集合框架只能存储对象,不能直接存储基本类型。装箱和拆箱使得在集合中使用基本类型变得更加方便。
3、 装箱和拆箱允许基本类型和对象类型之间的无缝转换,使得在方法参数、返回值等场景中可以更灵活地处理数据。
性能影响
尽管装箱和拆箱简化了代码,但它们也带来了一些性能上的开销:装箱会创建新的对象,这会带来内存分配和垃圾回收的开销。在拆箱过程中,如果包装类对象为 null,会抛出空指针异常。
62. Enumeration 和 Iterator 接口的区别?⭐⭐
Enumeration 和 Iterator 是 Java 中用于遍历集合的两个接口。虽然它们有相似的功能,但它们有不同的设计和使用方式。
Enumeration 接口
Enumeration 是一个较老的接口,存在于 Java 1.0 中。它主要用于遍历旧的集合类,如 Vector 和 Hashtable。
常用方法
boolean hasMoreElements(): 如果枚举中仍有更多元素,则返回 true。
Object nextElement(): 返回枚举中的下一个元素。
代码 Demo
Iterator 接口
Iterator 是在 Java 2 (JDK 1.2) 中引入的。它是集合框架的一部分,适用于所有集合类(如 ArrayList、HashSet、HashMap 等)。Iterator 提供了更灵活的遍历方法,并允许在遍历过程中安全地移除元素。
方法
boolean hasNext(): 如果迭代器中仍有更多元素,则返回 true。
E next(): 返回迭代器中的下一个元素。
void remove(): 从集合中移除迭代器返回的最后一个元素(可选操作)。
代码 Demo
主要区别
接口引入时间:
Enumeration:引入于 Java 1.0。
Iterator:引入于 Java 2 (JDK 1.2)。
方法名称和功能:
Enumeration:使用 hasMoreElements()和 nextElement()方法。
Iterator:使用 hasNext()和 next()方法,并增加了 remove()方法。
元素移除:
Enumeration:不支持在遍历过程中移除元素。
Iterator:支持在遍历过程中安全地移除元素(通过 remove()方法)。
适用范围:
Enumeration:主要用于旧的集合类,如 Vector 和 Hashtable。
Iterator:适用于所有集合类,是集合框架的一部分。
63. java 函数参数是值拷贝还是引用拷贝?⭐
在 Java 中,函数参数传递的机制是基于值传递的。
基本类型参数传递
对于基本类型,传递的是值的拷贝。在函数内部对参数的任何修改都不会影响到原始变量。
在这个例子中,modifyValue 函数内部修改了 x 的值,但这不会影响到 main 函数中的 a。
引用类型参数传递
对于引用类型,传递的也是值的拷贝,但这个值是对象的引用。这意味着在函数内部可以通过引用修改对象的内容,但不能改变引用本身指向的对象。
在这个例子中,modifyObject 函数可以通过引用 o 修改 obj 的内容,因此 main 函数中的 obj.value 被修改为 20。
但是,如果试图在函数内部改变引用本身指向另一个对象,这种修改不会影响到原始引用。
在这个例子中,changeReference 函数试图改变引用 o 指向一个新的对象,但这不会影响到 main 函数中的 obj。
传递的是引用的拷贝,函数内部可以通过引用修改对象的内容,但不能改变引用本身指向的对象。
这种传递机制有时会被误解为“引用传递”,但实际上 Java 中所有参数传递都是值传递。只不过对于引用类型,传递的是引用的值。
64. 基本数据类型和包装类的区别⭐⭐⭐⭐⭐
基本数据类型
Java 中的基本数据类型(也称为原始数据类型)有 8 种:
byte: 8 位,有符号整数,范围 -128 到 127。
short: 16 位,有符号整数,范围 -32,768 到 32,767。
int: 32 位,有符号整数,范围 -2^31 到 2^31-1。
long: 64 位,有符号整数,范围 -2^63 到 2^63-1。
float: 32 位,单精度浮点数。
double: 64 位,双精度浮点数。
char: 16 位,Unicode 字符,范围 0 到 65,535。
boolean: 表示 true 或 false。
包装类
Java 为每种基本数据类型提供了对应的包装类,这些包装类位于 java.lang 包中:
Byte 对应 byte
Short 对应 short
Integer 对应 int
Long 对应 long
Float 对应 float
Double 对应 double
Character 对应 char
Boolean 对应 boolean
65. 什么是 fail-fast 机制?⭐
在 Java 集合框架中,fail-fast 是一种机制,用于检测在遍历集合时的结构性修改,并立即抛出异常以防止不一致状态。fail-fast 迭代器在检测到集合在迭代过程中被修改后,会抛出 ConcurrentModificationException 异常。
工作原理
fail-fast 迭代器通过在遍历集合时维护一个修改计数器(modification count)来工作。每当集合结构发生变化(如添加或删除元素)时,这个计数器就会增加。当创建迭代器时,它会保存当前的修改计数器值。在每次调用 next()方法时,迭代器会检查当前的修改计数器值是否与保存的值一致。如果不一致,说明集合在迭代过程中被修改了,迭代器会立即抛出 ConcurrentModificationException。
代码 Demo
在上面的代码中,当迭代器遍历到元素 "B" 时,集合被修改(添加了新元素 "D"),因此迭代器将抛出 ConcurrentModificationException。
注意事项
快速失败并不保证:fail-fast 机制并不能保证在所有情况下都能检测到并发修改。它是尽力而为的检测机制,不能依赖于它来实现并发安全。如果需要并发安全的集合,可以使用 java.util.concurrent 包中的并发集合类。
避免并发修改:在遍历集合时,避免在外部修改集合。可以使用迭代器的 remove 方法来安全地移除元素。
使用 remove 方法
为了避免 ConcurrentModificationException,可以使用迭代器的 remove 方法来移除元素:
在这个示例中,使用 iterator.remove()方法安全地移除了元素 "B"。
66. 什么是 fail-safe 机制?⭐
fail-safe 机制是与 fail-fast 机制相对的一种并发处理机制。在 Java 集合框架中,fail-safe 迭代器在检测到集合在遍历过程中被修改时,不会抛出异常,而是允许这种修改继续进行。fail-safe 迭代器通常是通过在遍历时使用集合的副本来实现的,这样即使原集合被修改,迭代器也不会受到影响。
工作原理
fail-safe 迭代器在遍历集合时,实际上是遍历集合的一个副本。因此,任何对原集合的修改都不会影响到迭代器正在遍历的副本。这种机制保证了遍历操作的安全性,但也意味着迭代器不能反映集合的实时变化。
代码 Demo
在上面的代码中,使用 CopyOnWriteArrayList 作为集合。CopyOnWriteArrayList 是一个典型的 fail-safe 集合类,它在每次修改时都会创建集合的一个副本,因此迭代器不会检测到并发修改,不会抛出 ConcurrentModificationException。
主要特点
常见的 fail-safe 集合类
CopyOnWriteArrayList,ConcurrentHashMap,ConcurrentLinkedQueue,ConcurrentSkipListMap,ConcurrentSkipListSet
注意事项
67. BlockingQueue 是什么?⭐
BlockingQueue 是 Java 中定义在 java.util.concurrent 包下的一个接口,它扩展了 Queue 接口,并添加了阻塞操作。BlockingQueue 提供了一种线程安全的机制,用于在多线程环境中处理生产者-消费者问题。
特点和功能
阻塞操作:BlockingQueue 提供了阻塞的 put 和 take 方法:
put(E e):如果队列已满,则阻塞直到有空间可插入元素。
take():如果队列为空,则阻塞直到有元素可取。
线程安全:所有方法都使用内部锁或其他同步机制来确保线程安全。
多种实现:BlockingQueue 有多种实现方式,适用于不同的场景:
ArrayBlockingQueue:基于数组的有界阻塞队列。
LinkedBlockingQueue:基于链表的可选有界阻塞队列。
PriorityBlockingQueue:支持优先级排序的无界阻塞队列。
DelayQueue:支持延迟元素的无界阻塞队列。
SynchronousQueue:不存储元素的阻塞队列,每个插入操作必须等待一个对应的移除操作。
LinkedTransferQueue:基于链表的无界阻塞队列,支持传输操作。
代码 Demo
如何使用 BlockingQueue 实现生产者-消费者模式:
LinkedBlockingQueue 被用作 BlockingQueue 的实现。生产者线程不断地向队列中添加元素,而消费者线程不断地从队列中取出元素。如果队列已满,生产者线程会阻塞,直到有空间可插入元素;如果队列为空,消费者线程会阻塞,直到有元素可取。
主要方法
BlockingQueue 提供了一些常用的方法,这些方法分为四类:
抛出异常:
add(E e):如果队列已满,抛出 IllegalStateException。
remove():如果队列为空,抛出 NoSuchElementException。
element():如果队列为空,抛出 NoSuchElementException。
返回特殊值:
offer(E e):如果队列已满,返回 false。
poll():如果队列为空,返回 null。
peek():如果队列为空,返回 null。
阻塞操作:
put(E e):如果队列已满,阻塞直到有空间可插入元素。
take():如果队列为空,阻塞直到有元素可取。
超时操作:
offer(E e, long timeout, TimeUnit unit):在指定的时间内插入元素,如果队列已满,等待直到超时或插入成功。
poll(long timeout, TimeUnit unit):在指定的时间内取出元素,如果队列为空,等待直到超时或取出成功。
68. Java 提供了哪些队列?⭐
LinkedList
基于链表实现的双向链表,实现了 List、Deque 和 Queue 接口,支持在头部和尾部进行快速插入和删除操作。
使用场景:
需要频繁插入和删除元素的场景。
需要双端队列(Deque)功能的场景,如在头部和尾部进行操作。
PriorityQueue
基于优先级堆(Priority Heap)实现的无界队列。元素按照自然顺序或指定的比较器顺序排列。不允许插入 null 元素。
使用场景:
需要按优先级处理元素的场景,如任务调度、事件处理等。
需要动态调整元素顺序的场景。
ArrayDeque
基于数组实现的双端队列(Deque),没有容量限制,可以动态扩展,比 LinkedList 更高效,尤其是在栈和队列操作方面。
使用场景:
需要高效的栈或队列操作的场景。
需要双端队列功能,但不需要线程安全的场景。
ConcurrentLinkedQueue
基于链表实现的无界非阻塞队列。使用无锁算法,提供高效的并发性能。线程安全,适用于高并发环境。
使用场景:
高并发环境下的无界队列。
需要高效的非阻塞并发操作的场景。
LinkedBlockingQueue
基于链表实现的可选有界阻塞队列,支持阻塞的 put 和 take 操作,线程安全,适用于生产者-消费者模式。
使用场景:
生产者-消费者模式,特别是在需要限制队列大小的场景。需要线程安全的阻塞队列。
ArrayBlockingQueue
基于数组实现的有界阻塞队列,必须指定容量,支持阻塞的 put 和 take 操作。线程安全,适用于生产者-消费者模式。
使用场景:
生产者-消费者模式,特别是在需要固定大小的队列时。需要线程安全的有界阻塞队列。
DelayQueue
支持延迟元素的无界阻塞队列,元素只有在其延迟时间到期后才能被取出。线程安全,适用于并发环境。
使用场景:
需要延迟处理元素的场景,如任务调度、缓存过期处理等。
定时任务执行场景。
LinkedBlockingDeque
基于链表实现的可选有界阻塞双端队列,支持阻塞的 put 和 take 操作。线程安全,适用于生产者-消费者模式。
使用场景:生产者-消费者模式,特别是在需要限制队列大小的双端队列场景。需要线程安全的阻塞双端队列。
69. 阻塞队列原理?⭐
阻塞队列是一种线程安全的队列,它在插入和删除操作上可以阻塞线程,以实现生产者-消费者模式等并发编程需求。阻塞队列的核心原理包括锁机制和条件变量。
基本原理
锁机制
阻塞队列使用锁(如 ReentrantLock)来确保线程安全。锁保证了同一时间只有一个线程可以执行插入或删除操作,从而避免并发问题。
条件变量
阻塞队列使用条件变量(Condition)来管理线程的等待和通知。条件变量是与锁关联的,可以在特定条件下阻塞线程并在条件满足时唤醒线程。例如,notEmpty 和 notFull 是常见的条件变量,分别用于表示队列是否为空和是否已满。
等待和通知机制:
当线程试图执行插入操作而队列已满时,它会在 notFull 条件变量上等待,直到队列中有空闲空间。
当线程试图执行删除操作而队列为空时,它会在 notEmpty 条件变量上等待,直到队列中有可用的元素。
当插入或删除操作成功后,相应的条件变量会被通知(唤醒),以便其他等待的线程可以继续执行。
具体实现 Demo
LinkedBlockingQueue 是一个基于链表实现的可选有界阻塞队列。它的基本原理如下:
内部结构:使用一个链表来存储元素。使用两个锁:takeLock 和 putLock,分别用于控制删除和插入操作。使用两个条件变量:notEmpty 和 notFull,分别用于表示队列是否为空和是否已满。
插入操作(put):
删除操作(take):
等待和通知:
在插入操作中,如果队列已满,线程会在 notFull 条件变量上等待。
在删除操作中,如果队列为空,线程会在 notEmpty 条件变量上等待。
插入或删除操作成功后,会相应地通知等待的线程。
70. 重载和重写的区别⭐⭐⭐⭐⭐
重载(Overloading)和重写(Overriding)是面向对象编程中两个重要的概念,它们在方法定义和调用时有不同的用途和规则。
重载
在同一个类中,方法名称相同,但参数列表(参数的类型、数量或顺序)不同的多个方法。
方法名称:相同。
参数列表:必须不同(参数的类型、数量、或顺序)。
返回类型:可以相同也可以不同。
访问修饰符:可以相同也可以不同。
静态/实例方法:都可以重载。
编译时决定:方法的选择在编译时由编译器根据参数列表决定。
重写(Overriding)
在子类中定义一个方法,该方法与父类中的某个方法具有相同的方法名称、参数列表和返回类型,以便在子类中提供该方法的具体实现。
方法名称:相同。
参数列表:必须相同。
返回类型:必须相同(Java 5 及以后可以是协变返回类型,即返回类型可以是父类方法返回类型的子类型)。
访问修饰符:访问级别不能比父类方法更严格(可以更宽松)。
静态/实例方法:只能重写实例方法,不能重写静态方法。
运行时决定:方法的选择在运行时由 JVM 根据对象的实际类型决定(动态绑定)。
总结
重载:发生在同一个类中。方法名称相同,参数列表不同。编译时决定调用哪个方法(静态绑定)。
重写:发生在子类和父类之间。方法名称、参数列表和返回类型必须相同(或协变返回类型)。运行时决定调用哪个方法(动态绑定)。
71. 为什么要使用扰动函数?⭐
扰动函数的目的是为了提高哈希码的质量,使其在哈希表中更均匀地分布。具体来说:
减少哈希冲突:通过将高位和低位混合,扰动函数减少了哈希码的模式性,降低了哈希冲突的概率。
均匀分布:扰动后的哈希码更加均匀地分布在哈希表的桶中,从而提高了哈希表的性能。
示例 Demo
假设我们有一个键对象,其 hashCode()返回值为 123456。我们可以通过哈希函数计算其哈希值:
调用 hashCode()方法:
Plain Text int h = 123456;
扰动函数计算:
Plain Text int hash = h ^ (h >>> 16);
具体计算步骤:
○ h >>> 16 = 123456 >>> 16 = 1(右移 16 位)
○ hash = 123456 ^ 1 = 123457(异或运算)
最终,哈希值为 123457。
72. 进程、线程、管程、协程区别?⭐⭐⭐
进程 (Process)
进程是操作系统分配资源的基本单位。每个进程都有自己的内存空间、文件描述符、堆栈等资源。
进程的特点
独立性:进程之间是独立的,互不干扰。一个进程的崩溃不会影响其他进程。
资源丰富:每个进程拥有独立的资源,包括内存、文件句柄等。
开销大:创建和销毁进程的开销较大,进程间通信(IPC)也相对复杂。
上下文切换:进程的上下文切换开销较大,因为需要切换内存空间和资源。
使用场景
适用于需要强隔离和独立资源的场景,如独立的服务、应用程序等。
线程 (Thread)
线程是进程内的执行单元,一个进程可以包含多个线程。线程共享进程的资源(如内存空间、文件描述符)。
线程的特点
共享资源:同一进程内的线程共享内存和资源,通信方便。
轻量级:线程的创建和销毁开销较小,上下文切换较快。
并发执行:多线程可以并发执行,提高程序的响应速度和资源利用率。
同步问题:由于共享资源,线程间需要同步机制(如锁)来避免资源竞争和数据不一致。
使用场景
适用于需要并发执行的任务,如多任务处理、并行计算等。
管程 (Monitor)
管程是一种高级的同步机制,用于管理共享资源的并发访问。它将共享资源和访问资源的代码封装在一起,通过条件变量和互斥锁来实现同步。
管程特点
封装性:将共享资源和同步代码封装在一起,提供更高层次的抽象。
互斥访问:通过互斥锁确保同一时刻只有一个线程可以访问共享资源。
条件同步:使用条件变量来协调线程间的执行顺序。
使用场景
适用于需要对共享资源进行复杂同步操作的场景,如操作系统内核、并发数据结构等。
协程 (Coroutine)
协程是一种比线程更轻量级的并发执行单元。协程由程序自身调度,而不是由操作系统内核调度。协程可以在执行过程中主动让出控制权,以便其他协程运行。
协程特点
• 轻量级:协程的创建和切换开销极小,通常在用户态完成。
• 主动让出:协程通过显式的调用(如 yield)让出控制权,实现合作式多任务。
• 非抢占式:协程之间的切换是合作式的,不存在抢占问题。
• 栈独立:每个协程有自己的栈,避免了线程间共享栈带来的同步问题。
使用场景
适用于需要大量并发任务且切换频繁的场景,如高并发网络服务器、异步编程等。
虚拟线程 (Virtual Thread)
虚拟线程是一个新概念,特别是在 Java 的 Project Loom 中引入。虚拟线程是一种轻量级线程,由 JVM 管理,旨在简化并发编程并提高并发性能。
特点
轻量级:虚拟线程的创建和销毁开销极小,可以高效地管理数百万个线程。
自动管理:由 JVM 自动调度和管理,不需要开发者显式地管理线程池。
兼容性:与传统的 Java 线程 API 兼容,开发者可以用熟悉的线程模型编写高并发程序。
阻塞操作:虚拟线程可以在阻塞操作(如 I/O 操作)时高效地让出 CPU,而不会浪费资源。
使用场景
适用于高并发应用程序,如高性能服务器、Web 应用等。
73. 用户线程与守护线程区别⭐⭐⭐
用户线程
用户线程是应用程序创建的普通线程,也称为非守护线程。当所有用户线程都结束时,Java 虚拟机 (JVM) 也会退出。
特点
生命周期:用户线程的生命周期由应用程序控制。只要有一个用户线程在运行,JVM 就会继续运行。
重要性:用户线程通常用于执行应用程序的主要任务,例如处理业务逻辑、执行计算等。
关闭 JVM:JVM 只有在所有用户线程都结束后才会退出,即使还有守护线程在运行。
使用场景
适用于需要执行重要任务且不能中途被终止的线程。例如:处理用户请求的线程,执行关键业务逻辑的线程
守护线程 (Daemon Thread)
守护线程是为其他线程提供服务和支持的线程。当所有非守护线程(用户线程)都结束时,JVM 会自动退出,即使守护线程还在运行。
特点
生命周期:守护线程的生命周期依赖于用户线程。当所有用户线程结束时,守护线程也会自动终止。
后台任务:守护线程通常用于执行后台任务,如垃圾回收、日志记录等。
低优先级:守护线程通常优先级较低,因为它们主要为用户线程提供支持。
使用场景
适用于执行后台任务或辅助任务的线程,这些任务不需要在 JVM 退出时完成。例如:JVM 的垃圾回收线程,日志记录线程,监控和统计线程
代码 Demo
在这个例子中:
userThread 是一个用户线程,它会运行 5 秒钟。daemonThread 是一个守护线程,它会每秒钟打印一次消息。
当 userThread 结束后,JVM 会退出,即使 daemonThread 还在运行。
74. Java 线程的创建方式?⭐⭐⭐⭐⭐
继承 Thread 类
通过继承 java.lang.Thread 类并重写其 run 方法来创建线程。
实现 Runnable 接口
通过实现 java.lang.Runnable 接口并将其传递给 Thread 对象来创建线程。
实现 Callable 接口和使用 FutureTask
通过实现 java.util.concurrent.Callable 接口来创建线程,并使用 FutureTask 来管理返回结果。
使用线程池
通过 java.util.concurrent.ExecutorService 创建和管理线程池,避免手动创建和管理线程。
使用 Lambda 表达式 (Java 8 及以上)
通过 Lambda 表达式简化 Runnable 接口的实现。
75. Java 线程的几种创建方式有什么区别?⭐⭐⭐⭐⭐
继承 Thread 类
通过继承 Thread 类并重写 run 方法。适合快速创建简单的线程任务。缺点就是 Java 不支持多继承,如果你的类已经继承了另一个类,就不能再继承 Thread。同时不适合复杂的线程管理和资源共享场景。
实现 Runnable 接口
通过实现 Runnable 接口并将其传递给 Thread 对象。适合需要共享资源或任务的场景。可以实现多个接口,增加了灵活性。多个线程可以共享同一个 Runnable 实例,方便资源共享和任务分配。
实现 Callable 接口和使用 FutureTask
实现 Callable 接口来创建线程,并使用 FutureTask 来管理返回结果。适合需要返回结果的并发任务,可以返回任务执行结果。同时可以抛出异常,便于异常处理。相比 Runnable,实现和使用稍微复杂一些。
使用线程池
通过 ExecutorService 来创建和管理线程池,适合需要管理大量线程的场景。减少了频繁创建和销毁线程的开销。更好地管理系统资源,防止资源耗尽。可以根据任务量动态调整线程池大小。
76. Java 多线程优先级是什么?⭐⭐
在 Java 中,每个线程都有一个优先级,优先级决定了线程调度器对线程的调度顺序。线程的优先级是一个整数值,范围在 1 到 10 之间。
最低优先级:Thread.MIN_PRIORITY(值为 1)
默认优先级:Thread.NORM_PRIORITY(值为 5)
最高优先级:Thread.MAX_PRIORITY(值为 10)
线程优先级的作用
线程优先级是对线程调度器的一种建议,调度器会根据优先级来决定哪个线程应该优先执行。然而,线程优先级并不能保证线程一定会按照优先级顺序执行,具体的调度行为依赖于操作系统的线程调度策略。
设置线程优先级
可以通过 setPriority(int newPriority)方法来设置线程的优先级。需要注意的是,设置的优先级必须在 1 到 10 之间,否则会抛出 IllegalArgumentException。
我们创建了两个线程,一个设置为最低优先级,一个设置为最高优先级。通常情况下,系统会优先调度高优先级的线程执行,但这并不是绝对的,具体行为依赖于操作系统的调度策略。
注意事项
77. Java 多线程的生命周期是什么⭐⭐⭐⭐
在 Java 中,线程的生命周期包括多个状态,每个状态表示线程在其生命周期中的不同阶段。线程的生命周期状态主要包括:
新建(New)
就绪(Runnable)
运行(Running)
阻塞(Blocked)
等待(Waiting)
超时等待(Timed Waiting)
终止(Terminated)
线程状态详解
新建(New)
当一个线程对象被创建时(例如,通过 new Thread()),线程处于新建状态。此时,线程还没有开始运行。
就绪(Runnable)
当调用 start()方法后,线程进入就绪状态。线程在就绪状态下等待操作系统的线程调度器将其调度到 CPU 上执行。注意:在 Java 中,Runnable 状态包括了运行状态(Running),即线程可以运行,也可能正在运行。
运行(Running)
当线程获得 CPU 时间片并开始执行其 run()方法时,线程进入运行状态。线程在这个状态下实际执行任务。
阻塞(Blocked)
线程在等待一个监视器锁(monitor lock)时进入阻塞状态。例如,线程试图进入一个 synchronized 方法或块,但其他线程已经持有了该对象的锁。
等待(Waiting)
线程无限期地等待另一个线程显式地唤醒它时进入等待状态。例如,调用 Object.wait()方法,或者 Thread.join()方法(不带超时时间),或者 LockSupport.park()方法。
超时等待(Timed Waiting)
线程在等待另一个线程显式地唤醒它,或者等待特定的时间段后自动唤醒时进入超时等待状态。例如,调用 Thread.sleep(long millis)方法,Object.wait(long timeout)方法,Thread.join(long millis)方法,或者 LockSupport.parkNanos(long nanos)方法。
终止(Terminated)
当线程的 run()方法执行完毕或者抛出未捕获的异常时,线程进入终止状态。线程在这个状态下不再执行任何任务。
78. 为什么 java 多线程调用的是 start 方法不是 run 方法?⭐⭐⭐⭐
start()方法
start()方法的作用是启动一个新线程,并且使该线程进入就绪状态,等待操作系统的线程调度器来调度它执行。当你调用 start()方法时,Java 虚拟机会创建一个新的执行线程。在这个新的线程中,Java 虚拟机会自动调用 run()方法。调用 start()方法后,原来的线程和新创建的线程可以并发执行。
run()方法
run()方法包含了线程执行的代码,是你需要在新线程中执行的任务。如果直接调用 run()方法,run()方法会在当前线程中执行,而不会启动一个新线程。直接调用 run()方法不会创建新的线程,所有代码在调用 run()方法的线程中顺序执行。
代码 Demo
为什么不能直接调用 run()方法
79. 线程的基本方法⭐⭐⭐⭐⭐
start()
start()方法用于启动线程。线程创建以后,并不会自动运行,需要我们调用 start(),将线程的状态设为就绪状态,但不一定马上就被运行,得等到 CPU 分配时间片以后,才会运行
注意:直接调用 run()方法不会启动新线程,而是在当前线程中执行 run()方法。
run()
run()方法包含线程执行的代码。它是 Thread 类和 Runnable 接口的核心方法。
sleep(long millis)
sleep(long millis)方法使当前线程休眠指定的毫秒数。它会抛出 InterruptedException,因此需要处理该异常。
join()
join()方法等待线程终止。调用该方法的线程会等待被调用线程执行完毕后再继续执行。
interrupt()
interrupt()方法用于中断线程。被中断的线程会抛出 InterruptedException。
isInterrupted()
isInterrupted()方法用于检查线程是否被中断。它返回一个布尔值。
setPriority(int newPriority)
setPriority(int newPriority)方法用于设置线程的优先级。优先级范围从 Thread.MIN_PRIORITY(1) 到 Thread.MAX_PRIORITY(10),默认优先级为 Thread.NORM_PRIORITY(5)。
getPriority()
getPriority()方法用于获取线程的优先级。
setName(String name)
setName(String name)方法用于设置线程的名称。
getName()
getName()方法用于获取线程的名称。
currentThread()
currentThread()方法用于获取当前正在执行的线程。
80. sleep 和 wait 方法的区别⭐⭐⭐⭐
Thread.sleep()和 Object.wait()是 Java 中用于控制线程行为的两种方法,但它们有着显著的区别。
区别
Thread.sleep()示例
Object.wait()示例
81. 介绍一下常用的 java 的线程池?⭐⭐
FixedThreadPool(固定大小线程池)
线程池中有固定数量的线程。无论有多少任务提交,线程池中的线程数量始终不变。当所有线程都处于忙碌状态时,新的任务将会在队列中等待。适用于负载较均衡的场景,任务数量相对稳定。
Plain Text ExecutorService fixedThreadPool = Executors.newFixedThreadPool(int nThreads);
CachedThreadPool(缓存线程池)
线程池中线程数量不固定,可以根据需要自动创建新线程。如果线程池中的线程在 60 秒内没有被使用,则会被终止并从池中移除。当提交新任务时,如果没有空闲线程,则会创建新线程。适用于执行很多短期异步任务的小程序,或者负载较轻的服务器。
Plain Text ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
SingleThreadExecutor(单线程池)
线程池中只有一个线程,所有任务按照提交的顺序执行。确保所有任务在同一个线程中按顺序执行。适用于需要保证顺序执行任务的场景。
Plain Text ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor();
ScheduledThreadPool(定时线程池)
线程池可以在给定延迟后运行任务,或者定期执行任务。类似于 Timer 类,但更灵活且功能更强大。适用于需要周期性执行任务的场景。
Plain Text ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(int corePoolSize);
WorkStealingPool(工作窃取线程池)
使用多个工作队列减少竞争,适用于并行计算。线程池中的线程数量是 Runtime.getRuntime().availableProcessors()的返回值。适用于需要大量并行任务的场景。
Plain Text ExecutorService workStealingPool = Executors.newWorkStealingPool();
82. Java 线程池的原理⭐⭐⭐⭐
Java 线程池是一种管理和复用线程的机制,旨在提高应用程序的性能和资源利用效率。线程池通过减少线程创建和销毁的开销来提高系统的响应速度和吞吐量,并且可以有效管理和控制线程的数量,防止过多的线程导致系统资源耗尽。
线程池的基本原理
线程复用:线程池在初始化时创建一定数量的线程,这些线程在任务执行完毕后不会被销毁,而是被回收并重新用于执行新的任务。
任务队列:当所有线程都在忙碌时,新提交的任务会被放入任务队列中,等待空闲线程来执行。
线程管理:线程池可以根据需要动态调整线程的数量,创建新线程或销毁空闲线程,以应对任务量的变化。
资源控制:通过限制线程的数量,线程池可以防止系统资源(如 CPU、内存)被过度消耗。
83. 使用线程池的好处⭐⭐⭐⭐⭐
提高性能和响应速度
减少线程创建和销毁的开销:每次创建和销毁线程都需要消耗系统资源。线程池通过复用线程,减少了这些开销,从而提高了系统的性能和响应速度。
快速响应任务:线程池中已有的线程可以立即执行新任务,而不需要等待新的线程创建完成。
更好的资源管理
控制并发线程的数量:线程池可以限制并发执行的线程数量,防止系统资源(如 CPU、内存)被过度消耗,避免由于过多线程导致的资源耗尽。
任务排队:线程池内部维护一个任务队列,当所有线程都在忙碌时,新任务会被放入队列中等待执行,这样可以平滑地处理任务高峰。
简化并发编程
简化线程管理:开发者不需要手动创建、管理和销毁线程,减少了并发编程的复杂性和出错的可能性。
统一的任务提交接口:通过统一的接口(如 execute 和 submit 方法)提交任务,简化了任务管理和执行。
提高系统稳定性
避免资源枯竭:通过限制线程数量和任务队列长度,线程池可以防止系统资源被耗尽,从而提高系统的稳定性和可靠性。
拒绝策略:线程池提供了多种拒绝策略(如 AbortPolicy、CallerRunsPolicy 等),可以灵活处理任务队列已满时的新任务,避免系统崩溃。
更好的性能监控和调优
线程池监控:通过 ThreadPoolExecutor 提供的监控方法(如 getPoolSize、getActiveCount、getCompletedTaskCount 等),可以方便地监控线程池的状态和性能。
调优参数:线程池提供了多个参数(如核心线程数、最大线程数、空闲线程存活时间等),可以根据具体应用场景进行调优,以达到最佳性能。
84. 线程池的核心构造参数有哪些?⭐⭐⭐⭐⭐
构造参数
corePoolSize(核心线程数)
线程池中始终保持运行的最小线程数,即使这些线程处于空闲状态也不会被销毁。当提交一个新任务时,如果当前运行的线程数少于 corePoolSize,即使有空闲线程,也会创建一个新线程来处理任务。
maximumPoolSize(最大线程数)
线程池允许创建的最大线程数。当任务队列已满且当前运行的线程数小于 maximumPoolSize 时,会创建新线程来执行任务。
keepAliveTime(线程空闲时间)
当线程池中的线程数超过 corePoolSize 时,多余的空闲线程在等待新任务的最大时间。超过这个时间后,这些空闲线程将被终止。
unit(时间单位)
keepAliveTime 参数的时间单位。可以是 TimeUnit 枚举中的任意值,如 TimeUnit.SECONDS、TimeUnit.MILLISECONDS 等。
workQueue(任务队列)
用于保存等待执行的任务的队列。常用的队列实现包括:
LinkedBlockingQueue:一个基于链表的无界队列。
ArrayBlockingQueue:一个基于数组的有界队列。
SynchronousQueue:一个不存储元素的队列,每个插入操作必须等待一个对应的移除操作。
PriorityBlockingQueue:一个支持优先级排序的无界队列。
threadFactory(线程工厂)
用于创建新线程的工厂。可以通过自定义线程工厂来设置线程的名称、优先级等属性。默认实现是 Executors.defaultThreadFactory()。
handler(拒绝策略)
当任务队列已满且线程数量达到最大线程数时,新的任务会被拒绝执行。拒绝策略定义了这种情况下的处理方式。
常用的拒绝策略包括:
AbortPolicy:抛出 RejectedExecutionException,默认策略。
CallerRunsPolicy:由调用线程执行任务。
DiscardPolicy:丢弃任务,不抛出异常。
DiscardOldestPolicy:丢弃队列中最旧的任务,然后重新尝试提交新任务。
代码 Demo
暂时无法在飞书文档外展示此内容
参数调优
85. Java 线程池工作过程?⭐⭐
线程池刚创建时,里面没有一个线程。任务队列是作为参数传进来的。不过,就算队列里面有任务,线程池也不会马上执行它们。
当调用 execute() 方法添加一个任务时,线程池会做如下判断:
a) 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
b) 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列;
c) 如果这时候队列满了,而且正在运行的线程数量小于 maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务;
d) 如果队列满了,而且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会抛出异常 RejectExecutionException。
当一个线程完成任务时,它会从队列中取下一个任务来执行。
当一个线程无事可做,超过一定的时间(keepAliveTime)时,线程池会判断,如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。
86. 如何重构一个线程工厂⭐⭐
为什么要使用线程工厂
统一管理线程创建:通过集中管理线程的创建过程,可以确保所有线程具有一致的属性设置,如名称、优先级和是否为守护线程。
增强可维护性:将线程创建逻辑从业务代码中分离出来,使代码更清晰、更易维护。
提高可扩展性:通过自定义线程工厂,可以轻松添加新的功能,例如日志记录、异常处理和线程组管理。
基本的线程工厂实现
默认的线程工厂实现(Executors.defaultThreadFactory())创建的线程没有特别的属性设置。可以通过实现 ThreadFactory 接口来定制线程的创建过程。
实现 ThreadFactory 接口:定义一个类实现 ThreadFactory 接口,并重写 newThread 方法。
设置线程属性:在 newThread 方法中,创建新的线程并设置其属性,如名称、优先级和是否为守护线程。
扩展和优化线程工厂
为了使线程工厂更加强大和灵活,我们可以添加更多功能。例如:
日志记录
记录每个线程的创建过程,便于调试和监控。
线程组管理
将线程归类到特定的线程组中,便于管理和控制。
使用自定义线程工厂
通过自定义的线程工厂创建线程池,确保线程具有一致的属性设置。
87. 线程池的拒绝策略有哪些?⭐⭐
当线程池的任务缓存队列已满并且线程池中的线程数目达到 maximumPoolSize 时,如果还有任务到来就会采取任务拒绝策略,线程池中的线程已经用完了,无法继续为新任务服务,同时,等待队列也已经排满了,再也塞不下新任务了。这时候我们就需要拒绝策略机制合理的处理这个问题。
Java 的 java.util.concurrent 包中提供了几种内置的拒绝策略,通过实现 RejectedExecutionHandler 接口来定义它们。以下是几种常见的拒绝策略:
AbortPolicy(默认策略)
抛出 RejectedExecutionException 异常,通知调用者任务被拒绝。
CallerRunsPolicy
由调用线程(提交任务的线程)直接运行被拒绝的任务。这种策略提供了一种简单的反馈机制,减缓提交任务的速度。
DiscardPolicy
直接丢弃被拒绝的任务,不做任何处理,也不抛出异常。
DiscardOldestPolicy
丢弃最早提交的未处理任务,然后重新尝试执行当前被拒绝的任务。
自定义拒绝策略
除了内置的拒绝策略,还可以通过实现 RejectedExecutionHandler 接口来定义自己的拒绝策略。
使用拒绝策略
在创建 ThreadPoolExecutor 时,可以将拒绝策略作为参数传入:
代码 Demo
线程池使用 AbortPolicy 作为拒绝策略。当任务数量超过线程池的处理能力时,将抛出 RejectedExecutionException。默认的拒绝策略是 AbortPolicy。
88. 线程池的 shutDown 和 shutDownNow 的区别⭐⭐
ExecutorService 接口中,shutdown()和 shutdownNow()是用于关闭线程池的方法。
shutdown()
shutdown()方法会启动线程池的关闭过程。它会停止接受新的任务提交,但会继续执行已经提交的任务(包括正在执行的和已提交但尚未开始执行的任务)。调用 shutdown()后,线程池会进入一个平滑的关闭过程,等待所有已提交的任务完成后才会完全终止。
shutdownNow()
shutdownNow()方法会尝试停止所有正在执行的任务,并返回一个包含尚未开始执行的任务的列表。它会立即停止接收新的任务,并试图中断正在执行的任务。调用 shutdownNow()后,线程池会尽快停止所有正在执行的任务,并返回尚未开始执行的任务列表。需要注意的是,无法保证所有正在执行的任务都能被中断。
89. Java 的后台进程是什么?⭐⭐
在 Java 中,后台进程通常指的是“守护线程”(Daemon Thread)。守护线程是一种特殊类型的线程,它在后台运行,用于执行一些辅助任务。当所有的非守护线程(即用户线程)都结束时,JVM 会自动退出,不管守护线程是否还在运行。
守护线程的特点
辅助角色:守护线程通常用于执行一些后台辅助任务,例如垃圾回收、监控等。
自动结束:当所有的非守护线程都结束时,JVM 会自动退出,即使还有守护线程在运行。
设置方法:可以通过调用 Thread 对象的 setDaemon(true)方法将线程设置为守护线程。
创建守护线程
创建线程:创建一个普通的线程。
设置为守护线程:在启动线程之前调用 setDaemon(true)方法将其设置为守护线程。
在这个例子中:
1、 创建了一个 Runnable 对象,并将其传递给一个新的 Thread 对象。
2、 通过调用 daemonThread.setDaemon(true)将线程设置为守护线程。
3、 启动守护线程后,主线程睡眠 2 秒,然后结束。
4、 当主线程结束时,JVM 会自动退出,即使守护线程还在运行。
注意事项
必须在启动前设置:必须在调用 start()方法之前调用 setDaemon(true),否则会抛出 IllegalThreadStateException。
守护线程的生命周期:守护线程的生命周期依赖于 JVM 中其他非守护线程的生命周期。一旦所有非守护线程结束,JVM 就会退出,无论守护线程是否还在运行。
不适合重要任务:由于守护线程在 JVM 退出时不会确保完成其任务,因此不适合用于需要确保完成的关键任务。
90. 多线程的 join 方法是什么?⭐⭐⭐⭐
join 是 Thread 类中的一个方法,它允许一个线程等待另一个线程的完成。调用 join 方法的线程将暂停执行,直到被调用 join 方法的线程完成其执行。使用 join 方法,可以管理多线程程序的执行流程。
工作原理
当一个线程调用另一个线程的 join 方法时,当前线程会进入等待状态,直到目标线程完成或指定的等待时间到期。join 方法内部是通过 wait 机制实现的,当目标线程完成时,会调用 notifyAll 方法唤醒所有等待的线程。
适用场景
线程同步:确保一个线程在另一个线程完成之后再执行。例如,在多线程计算中,主线程需要等待所有子线程完成计算后再汇总结果。
顺序执行:强制线程按特定顺序执行。例如,必须确保某些初始化任务在线程执行之前完成。
代码 Demo
主线程中等待多个子线程完成
91. 什么是乐观锁?⭐⭐⭐⭐⭐
乐观锁是一种并发控制机制,主要用于解决并发修改问题。与悲观锁不同,乐观锁假设并发冲突的概率较低,因此在操作之前不加锁,而是在操作提交时进行冲突检测。
乐观锁的工作原理
乐观锁通常通过以下两种方式实现:
版本号
1、 每条记录增加一个版本号字段。
2、 在读取记录时,读取其版本号。
3、 在更新记录时,检查当前版本号是否与读取时的版本号一致。
4、 如果一致,则更新记录并将版本号加一。
5、 如果不一致,则说明有其他事务已经更新了该记录,此时需要重新读取并尝试更新。
时间戳
1、 每条记录增加一个时间戳字段。
2、 在读取记录时,读取其时间戳。
3、 在更新记录时,检查当前时间戳是否与读取时的时间戳一致。
4、 如果一致,则更新记录并更新时间戳。
5、 如果不一致,则说明有其他事务已经更新了该记录,此时需要重新读取并尝试更新。
适用场景
读多写少:系统中读操作频繁,但写操作较少。例如,电商系统中的商品查询操作。
低冲突:并发冲突概率较低的场景。例如,用户个人信息修改,每个用户只会修改自己的信息。
优缺点
优点
无锁开销:乐观锁不需要在读取时加锁,避免了锁的开销和潜在的死锁问题。
高并发性能:适用于读多写少的场景,能提高系统的并发性能。
缺点
重试机制:当并发冲突发生时,需要重新读取数据并重试更新,可能会增加系统的复杂度。
不适用高冲突场景:在并发冲突频繁的场景下,重试次数可能较多,反而降低系统性能。
92. 乐观锁的 ABA 问题⭐⭐⭐⭐⭐
乐观锁的 ABA 问题是指在并发环境中,一个变量在某个线程检查和更新之间可能会被其他线程多次修改,但最终值看起来没有变化,导致原线程无法检测到这些修改。这种情况会导致数据不一致和潜在的并发问题。
ABA 问题的具体场景
假设有一个变量 X,其初始值为 A。以下是一个可能的 ABA 问题场景:
线程 T1 读取变量 X,值为 A。
线程 T1 准备更新变量 X,但在此之前,线程 T2 将变量 X 的值从 A 改为 B,然后又改回 A。
线程 T1 再次检查变量 X,发现其值仍然是 A,于是认为变量 X 没有被修改,继续进行更新操作。
在这种情况下,线程 T1 无法检测到变量 X 已经被其他线程修改过,导致数据不一致。
解决 ABA 问题的方法
增加版本号
通过引入版本号,每次更新变量时同时更新版本号。即使变量值恢复原值,版本号也会变化(版本号只会增加不会减少),从而检测到修改
使用 Java 的 AtomicStampedReference 类
这是 Java 并发包中的一个类,它不仅存储了对象的引用,还存储了一个“戳”(stamp),通常是一个版本号或时间戳。每次更新时同时更新戳,从而检测到 ABA 问题
93. 什么是 CAS?⭐⭐⭐⭐⭐
CAS(Compare-And-Swap)是一种原子操作,用于实现无锁并发数据结构和算法。它允许一个变量在检查和更新之间不会被其他线程修改,从而确保操作的原子性。
CAS 的工作原理
CAS 操作涉及三个操作数:
内存位置(V):需要操作的变量的内存地址。
预期值(E):期望变量的当前值。
新值(N):希望将变量更新为的新值。
CAS 操作的步骤如下:
读取变量的当前值。
比较变量的当前值与预期值(E)。
如果当前值等于预期值,则将变量更新为新值(N),并返回 true,表示更新成功。
如果当前值不等于预期值,则不进行更新,并返回 false,表示更新失败。
CAS 的优点
无锁操作:CAS 是无锁操作,不需要加锁,从而避免了锁带来的开销和潜在的死锁问题。
高性能:在高并发环境中,CAS 操作的性能通常优于加锁机制,因为它减少了线程的阻塞和上下文切换。
原子性:CAS 操作是原子的,即使在多线程环境中,也能确保操作的正确性。
CAS 的缺点
ABA 问题:如前所述,CAS 操作可能会遇到 ABA 问题,即变量在检查和更新之间被其他线程多次修改,但最终值看起来没有变化。可以通过增加版本号或使用 AtomicStampedReference 来解决。
自旋等待:CAS 操作在失败时通常会自旋重试,这可能会导致 CPU 资源的浪费,尤其是在高冲突场景下。
CAS 在 Java 中的应用
Java 提供了一些基于 CAS 操作的并发类,例如 AtomicInteger、AtomicBoolean、AtomicReference 等。它们使用 CAS 操作来实现原子性更新,避免了显式加锁。
94. 什么是 AQS(抽象的队列同步器)⭐
AQS(Abstract Queued Synchronizer,抽象的队列同步器)是 Java 并发包(java.util.concurrent)中的一个框架,用于构建锁和其他同步器(如信号量、读写锁等)。AQS 通过一个 FIFO(先进先出)的等待队列来管理线程的排队和唤醒,简化了同步器的实现。
AQS 的核心概念
状态(state):AQS 通过一个整型变量 state 来表示同步状态。不同的同步器可以根据自己的需求定义 state 的含义,例如对于独占锁,state 可以表示锁的持有状态;对于共享锁,state 可以表示可用资源的数量。
独占模式(Exclusive Mode):独占模式下,只有一个线程能获取同步状态,其他线程必须等待。例如,ReentrantLock 就是基于独占模式实现的。
共享模式(Shared Mode):共享模式下,多个线程可以同时获取同步状态。例如,Semaphore 和 CountDownLatch 就是基于共享模式实现的。
等待队列(Wait Queue):AQS 内部维护一个 FIFO 等待队列,用于管理被阻塞的线程。当线程获取同步状态失败时,会被加入到等待队列中,等待其他线程释放同步状态后被唤醒。
AQS 的工作原理
AQS 通过以下几个核心方法来实现同步器的功能:
acquire(int arg):以独占模式获取同步状态,如果获取失败,则将当前线程加入等待队列,并阻塞直到同步状态可用。
release(int arg):以独占模式释放同步状态,唤醒等待队列中的下一个线程(如果有)。
acquireShared(int arg):以共享模式获取同步状态,如果获取失败,则将当前线程加入等待队列,并阻塞直到同步状态可用。
releaseShared(int arg):以共享模式释放同步状态,唤醒等待队列中的所有线程(如果有)。
tryAcquire(int arg):尝试以独占模式获取同步状态,返回 true 表示获取成功,返回 false 表示获取失败。需要由具体的同步器实现。
tryRelease(int arg):尝试以独占模式释放同步状态,返回 true 表示释放成功,返回 false 表示释放失败。需要由具体的同步器实现。
tryAcquireShared(int arg):尝试以共享模式获取同步状态,返回大于等于 0 的值表示获取成功,返回负值表示获取失败。需要由具体的同步器实现。
tryReleaseShared(int arg):尝试以共享模式释放同步状态,返回 true 表示释放成功,返回 false 表示释放失败。需要由具体的同步器实现。
AQS 实现简单独占锁
tryAcquire(int arg)方法尝试获取锁,通过 CAS 操作将 state 从 0 设置为 1。
tryRelease(int arg)方法释放锁,通过将 state 设置为 0 并清除当前线程的持有状态。
lock()方法通过调用 acquire(1)获取锁。
unlock()方法通过调用 release(1)释放锁。
isLocked()方法检查当前锁是否被持有。
95. 创建线程的底层原理⭐⭐
JVM 中的线程模型
在 JVM 中,线程是由操作系统的原生线程(OS Native Thread)实现的。每个 Java 线程对象都对应一个操作系统级别的线程。JVM 通过调用操作系统的线程 API 来创建和管理这些原生线程。
JVM 创建线程的底层原理是通过调用操作系统的原生线程 API 来实现的。具体步骤包括:
在 Java 中创建 Thread 对象并调用 start()方法。
JVM 通过 JNI 调用本地方法来创建操作系统级别的线程。
操作系统分配线程控制块(TCB)和栈空间,初始化线程上下文。
操作系统将新线程加入调度队列,线程开始执行 run()方法。
线程终止时,JVM 清理资源并释放操作系统分配的资源。
Thread.start()调用 native 的 start0()
JVM 通过 pthread_create()创建一个系统内核线程
在内核线程的运行方法中,利用 JavaCalls 调用 java 线程的 run()方法
Java 线程的创建流程
当在 Java 中创建和启动一个线程时,JVM 会执行以下步骤:
创建 Thread 对象
首先,用户创建一个 Thread 对象或实现了 Runnable 接口的对象。例如:
调用 Thread.start()
调用 start()方法时,JVM 会启动一个新线程。start()方法的实现会调用 JVM 的本地方法来创建线程。
JVM 调用操作系统 API
JNI 和本地方法
JVM 使用 Java 本地接口(JNI)来调用操作系统的线程 API。Thread.start()方法内部会调用一个本地方法,例如 JVM_StartThread。这个本地方法的实现是与平台相关的,它会调用操作系统的线程创建函数。
操作系统线程 API
不同操作系统有不同的线程创建 API,例如:
POSIX 线程(Linux/Unix):使用 pthread_create 函数。
Windows 线程:使用 CreateThread 函数。
线程的初始化和启动
分配线程控制块(TCB)
操作系统分配一个线程控制块(TCB),用于存储线程的状态信息(如寄存器、程序计数器、栈指针等)。
分配栈空间
操作系统为新线程分配独立的栈空间,用于存储局部变量和函数调用信息。
初始化线程上下文
操作系统初始化线程的上下文,包括设置程序计数器(PC)指向线程的起始地址,初始化寄存器和栈指针等。
将线程加入调度队列
操作系统将新线程加入调度队列,以便线程调度器可以调度该线程执行。
线程的执行
操作系统的线程调度器负责将 CPU 时间分配给各个线程。新创建的线程开始执行时,JVM 调用线程对象的 run()方法。对于上面的示例,run()方法会输出"Thread is running"。
线程的终止
当线程的 run()方法执行完毕或线程被中断时,JVM 会清理线程的资源,并通知操作系统释放线程控制块和栈空间。
96. 终止线程的四种方式⭐⭐
正常终止
线程的 run()方法执行完毕后,线程会自动终止。这是最自然和安全的终止方式。
使用标志位终止
通过设置一个共享的标志位来通知线程终止。这种方式需要线程在合适的地方检查标志位,并自行决定何时终止。
使用 interrupt()方法
通过调用线程的 interrupt()方法来请求终止线程。线程需要在合适的地方检查是否被中断,并自行决定如何处理。
用 Future 的取消方法
如果线程是通过 ExecutorService 提交的任务,可以使用 Future 对象的 cancel()方法来请求终止线程。
这四种方式中,正常终止和使用标志位是最推荐的,因为它们最为安全和可控。使用 interrupt()方法也是一种常见的方式,但需要注意正确处理 InterruptedException。使用 Future 的取消方法适用于通过 ExecutorService 提交的任务,但仍然需要任务在合适的地方响应中断请求。强制终止线程(例如使用 Thread.stop())是不推荐的,因为它会导致资源泄漏和数据不一致。
97. wait 和 notifiy 的虚假唤醒的产生原因及如何解决⭐⭐⭐⭐
wait()和 notify()/notifyAll()方法用于线程间的协调和通信。虚假唤醒是指线程在没有收到实际通知的情况下从 wait()状态返回。
虚假唤醒的产生原因
虚假唤醒是操作系统和虚拟机层面上的一种现象,可能由于以下原因产生:
操作系统层面:某些操作系统可能会由于内部调度机制、信号处理或其他原因导致线程被唤醒。
虚拟机实现:Java 虚拟机的具体实现可能会在某些情况下发生虚假唤醒。
虽然虚假唤醒在实际中可能不常见,但 Java 规范明确要求我们在使用 wait()方法时必须考虑到这种可能性。
如何解决虚假唤醒
为了处理虚假唤醒,建议在调用 wait()方法时使用循环来反复检查条件。具体来说,应该在一个 while 循环中调用 wait()方法,而不是直接在 if 语句中调用。这确保了即使发生虚假唤醒,线程也会重新检查条件,只有在条件满足时才继续执行。
正确使用 wait()和 notify()方法来处理虚假唤醒:
waitForCondition 方法:在这个方法中,线程会在条件不满足时进入 wait()状态。为了防止虚假唤醒,使用 while 循环反复检查条件。即使线程被虚假唤醒,它也会重新检查条件,只有在条件满足时才会继续执行。
setCondition 方法:这个方法用于设置条件并通知所有等待的线程。通过调用 notifyAll()方法,所有在 wait()状态的线程都会被唤醒。
99. 引起 CPU 进行上下文切换的原因⭐
CPU 上下文切换是指 CPU 从一个进程或线程切换到另一个进程或线程的过程。上下文切换涉及保存当前进程或线程的状态,并加载即将运行的进程或线程的状态。上下文切换是多任务操作系统中实现并发的重要机制,但频繁的上下文切换会带来性能开销。
时间片耗尽
在抢占式多任务操作系统中,每个进程或线程都被分配一个固定长度的时间片。当时间片耗尽时,操作系统会进行上下文切换,将 CPU 分配给下一个进程或线程。
阻塞操作
当一个进程或线程执行阻塞操作(如 I/O 操作、等待锁、等待资源等)时,它会进入阻塞状态,操作系统会进行上下文切换,将 CPU 分配给其他可以运行的进程或线程。
进程或线程的优先级变化
操作系统调度程序会根据进程或线程的优先级进行调度。如果一个高优先级的进程或线程进入就绪状态,操作系统可能会进行上下文切换,将 CPU 分配给这个高优先级的进程或线程。
中断
硬件中断(如定时器中断、I/O 中断等)也会触发上下文切换。当中断发生时,操作系统会暂停当前进程或线程的执行,处理中断请求,然后可能会切换到另一个进程或线程。
系统调用
当进程或线程执行系统调用时,可能会引发上下文切换。例如,当进程请求操作系统服务(如文件操作、网络操作等)时,操作系统可能会切换到内核态进行处理,然后再切换回用户态。
多处理器环境中的负载均衡
在多处理器或多核系统中,操作系统可能会进行上下文切换以实现负载均衡。操作系统会将进程或线程分配到不同的 CPU 核心,以优化资源利用率和性能。
线程调度策略
不同的线程调度策略(如时间片轮转、优先级调度等)会导致上下文切换。例如,在时间片轮转调度策略中,每个线程按顺序获得 CPU 时间片,当时间片用完时,操作系统会进行上下文切换。
用户态和内核态切换
当进程或线程从用户态切换到内核态(例如执行系统调用)或从内核态切换回用户态时,也会发生上下文切换。这种切换涉及保存和恢复 CPU 寄存器等状态。
上下文切换的开销
上下文切换虽然是多任务操作系统实现并发的必要机制,但它也带来了性能开销,主要包括:
CPU 寄存器保存和恢复:需要保存当前进程或线程的 CPU 寄存器状态,并加载下一个进程或线程的 CPU 寄存器状态。
内存管理:需要切换内存管理单元(MMU)的上下文,例如页表的切换。
缓存失效:上下文切换可能导致 CPU 缓存失效,从而影响性能。
优化上下文切换
减少线程数量:避免创建过多的线程,合理使用线程池。
减少锁竞争:使用无锁数据结构或更细粒度的锁,减少线程间的锁竞争。
优化调度策略:根据应用场景选择合适的调度策略,避免不必要的优先级切换。
使用异步 I/O:尽量使用异步 I/O 操作,减少阻塞操作引起的上下文切换。
100. 线程什么时候主动放弃 CPU⭐
线程主动放弃 CPU 常见的有以下几种方式
调用 Thread.yield()方法
调用 Thread.sleep(long millis)方法
调用 Object.wait()方法
调用 Thread.join()方法
调用 LockSupport.park()方法
Thread.yield()
Thread.yield()是一个静态方法,通知调度器当前线程愿意放弃 CPU 使用权,让其他同优先级或更高优先级的线程有机会运行。它只是一个提示,操作系统可以选择忽略这个提示。
场景
调试和性能优化:在某些情况下,yield()可以用于调试和性能优化,帮助识别线程调度问题。
避免资源独占:在某些高优先级的任务中,使用 yield()可以避免线程长时间独占 CPU,稍微改善系统响应时间。
Thread.sleep(long millis)
Thread.sleep(long millis)使当前线程进入休眠状态,暂停执行指定的毫秒数。休眠期间,线程保持 CPU 使用权,但不执行任何代码。
场景
定时任务:在需要定时执行任务的场景中,sleep()可以用于实现简单的定时等待。
模拟延迟:在测试和模拟场景中,sleep()可以用于模拟网络延迟或其他等待时间。
Object.wait()
Object.wait()使当前线程等待,直到其他线程调用 notify()或 notifyAll()方法唤醒它。wait()必须在同步块或同步方法中调用。
场景
线程间通信:在生产者-消费者模型中,wait()和 notify()用于协调生产者和消费者线程之间的工作。
Thread.join()
Thread.join()使当前线程等待,直到另一个线程执行完毕。可以指定等待时间,也可以无限期等待。
场景
线程协调:在需要确保某些线程在其他线程之前完成时,使用 join()来协调线程的执行顺序。
LockSupport.park()
LockSupport.park()使当前线程阻塞,直到被其他线程通过 LockSupport.unpark(Thread)唤醒。park()不会释放线程持有的锁,但可以响应中断。
场景
线程控制:在需要精细控制线程行为的场景中,park()和 unpark()提供了更底层和灵活的线程控制机制。
总结
Thread.yield():提示调度器当前线程愿意放弃 CPU 使用权,适用于调试和性能优化。
Thread.sleep(long millis):使线程休眠指定时间,适用于定时任务和模拟延迟。
Object.wait():使线程等待,直到被 notify()或 notifyAll()唤醒,适用于线程间通信。
Thread.join():使当前线程等待另一个线程执行完毕,适用于线程协调。
LockSupport.park():使线程阻塞,直到被 unpark()唤醒,适用于高级线程控制。
101. 为什么说线程的上下文切换效率不高⭐
线程的上下文切换效率不高主要是因为它涉及多个复杂的操作,这些操作会消耗 CPU 时间和系统资源。
CPU 寄存器状态保存和恢复
当进行上下文切换时,操作系统需要保存当前线程的 CPU 寄存器状态,包括程序计数器、堆栈指针、通用寄存器等。然后,它需要加载即将运行的线程的寄存器状态。这一过程需要时间,并且在频繁上下文切换时,会带来显著的开销。
内存管理开销
线程上下文切换可能涉及内存管理单元(MMU)的切换,比如页表的切换。虽然线程通常共享同一个进程的地址空间,但在某些情况下(如不同的线程池或不同的任务),仍然可能需要进行复杂的内存管理操作。
缓存失效
上下文切换会导致 CPU 缓存失效。当一个线程被切换出去,另一个线程被切换进来时,新线程访问的数据可能不在缓存中,导致缓存命中率下降。这会增加内存访问延迟,从而影响性能。
调度器开销
线程调度器需要决定哪个线程应该运行,这涉及到复杂的算法和数据结构操作。调度器需要遍历就绪队列、计算优先级、处理时间片等,这些操作都会消耗 CPU 时间。
内核态和用户态切换
线程上下文切换通常涉及从用户态切换到内核态,再从内核态切换回用户态。这种切换本身就有开销,因为需要保存和恢复更多的状态信息,并且可能涉及安全检查和权限验证。
频繁的上下文切换
如果系统中线程数量过多或线程频繁阻塞和唤醒,导致频繁的上下文切换,这种情况会显著影响系统的整体性能。频繁的上下文切换会导致 CPU 大量时间花费在保存和恢复状态上,而不是实际执行任务。
102. 线程的安全三大特性⭐
线程安全确保多个线程能够正确、无冲突地访问共享资源。线程安全的三大核心特性是原子性、可见性和有序性。
原子性 (Atomicity)
原子性指的是一个操作或一系列操作要么全部执行,要么全部不执行,中间不会被其他线程干扰。原子操作是不可分割的,任何线程都不能中断它们。
原子操作:如读取和写入单个变量。
非原子操作:如自增操作 i++(它实际上包括读取、更新和写入三个步骤)。
解决方案:
使用同步块或锁(如 ReentrantLock)来确保操作的原子性。
使用原子类(如 AtomicInteger、AtomicLong 等)来处理基本类型的原子操作。
可见性 (Visibility)
可见性指的是一个线程对共享变量的修改,能够及时被其他线程看到。多线程环境下,线程对共享变量的修改可能不会立即被其他线程感知,因为每个线程都有自己的缓存(如 CPU 缓存)。一个线程修改了一个变量,但另一个线程读取到的仍然是旧值。
解决方案:
使用 volatile 关键字:确保变量的修改对所有线程立即可见。
使用同步块或锁:同步块不仅可以保证原子性,还可以保证可见性。
线程间通信机制:如 wait()和 notify()。
有序性 (Ordering)
有序性指的是程序执行的顺序按照代码的顺序来执行。在多线程环境下,由于编译器优化和 CPU 指令重排,代码的执行顺序可能与编写顺序不同。指令重排可能导致线程看到不一致的执行顺序。
解决方案:
使用 volatile 关键字:不仅保证可见性,还禁止指令重排。
使用同步块或锁:同步块不仅可以保证原子性和可见性,还可以保证进入和退出同步块的代码顺序。
内存屏障(Memory Barriers):低级别的控制指令重排的技术。
总结
原子性:确保操作不可分割,使用同步块、锁或原子类。
可见性:确保线程间的修改及时可见,使用 volatile 关键字、同步块或锁。
有序性:确保代码执行顺序符合预期,使用 volatile 关键字、同步块或内存屏障。
103. 介绍一下 volatile⭐⭐⭐⭐⭐
线程安全确保多个线程能够正确、无冲突地访问共享资源。线程安全的三大核心特性是原子性、可见性和有序性。
原子性 (Atomicity)
原子性指的是一个操作或一系列操作要么全部执行,要么全部不执行,中间不会被其他线程干扰。原子操作是不可分割的,任何线程都不能中断它们。
原子操作:如读取和写入单个变量。
非原子操作:如自增操作 i++(它实际上包括读取、更新和写入三个步骤)。
解决方案:
使用同步块或锁(如 ReentrantLock)来确保操作的原子性。
使用原子类(如 AtomicInteger、AtomicLong 等)来处理基本类型的原子操作。
可见性 (Visibility)
可见性指的是一个线程对共享变量的修改,能够及时被其他线程看到。多线程环境下,线程对共享变量的修改可能不会立即被其他线程感知,因为每个线程都有自己的缓存(如 CPU 缓存)。一个线程修改了一个变量,但另一个线程读取到的仍然是旧值。
解决方案:
使用 volatile 关键字:确保变量的修改对所有线程立即可见。
使用同步块或锁:同步块不仅可以保证原子性,还可以保证可见性。
线程间通信机制:如 wait()和 notify()。
有序性 (Ordering)
有序性指的是程序执行的顺序按照代码的顺序来执行。在多线程环境下,由于编译器优化和 CPU 指令重排,代码的执行顺序可能与编写顺序不同。指令重排可能导致线程看到不一致的执行顺序。
解决方案:
使用 volatile 关键字:不仅保证可见性,还禁止指令重排。
使用同步块或锁:同步块不仅可以保证原子性和可见性,还可以保证进入和退出同步块的代码顺序。
内存屏障(Memory Barriers):低级别的控制指令重排的技术。
总结
原子性:确保操作不可分割,使用同步块、锁或原子类。
可见性:确保线程间的修改及时可见,使用 volatile 关键字、同步块或锁。
有序性:确保代码执行顺序符合预期,使用 volatile 关键字、同步块或内存屏障。
104. 什么是指令重排⭐
指令重排是编译器优化中的一种技术,旨在提高程序执行效率。它允许编译器和处理器在不改变程序最终结果的前提下,重新排列指令的执行顺序。指令重排可以利用处理器的并行执行能力和优化内存访问,以提高程序的性能。
为什么需要指令重排?
提高指令级并行性:现代处理器具有多条流水线,可以同时执行多条指令。通过重排指令,可以更好地利用这些流水线,提高指令级并行性。
减少等待时间:某些指令可能会因为数据依赖或内存访问延迟而等待。通过重排指令,可以将这些等待时间隐藏在其他指令的执行过程中,从而提高整体执行效率。
指令重排的类型
编译器重排:编译器在生成目标代码时,重新排列指令的顺序,以优化性能。这种重排通常基于数据流分析和依赖关系分析。
处理器重排:现代处理器在运行时动态地重新排列指令,以提高执行效率。这种重排利用了处理器的乱序执行(Out-of-Order Execution)能力。
指令重排 Demo
假设有以下两条指令:
Plain Text int a = 1; // 指令 1 int b = 2; // 指令 2
在没有数据依赖的情况下,编译器或处理器可以将这两条指令的顺序互换,而不会影响程序的最终结果:
Plain Text int b = 2; // 指令 2 int a = 1; // 指令 1
指令重排对多线程编程的影响
在多线程环境中,指令重排可能会导致意想不到的结果,尤其是在没有适当的同步机制时。考虑以下示例:
Plain Text // 线程 1 x = 1; // 指令 1 y = 2; // 指令 2 // 线程 2 if (y == 2) { // 期望 x == 1 }
在单线程环境中,我们可以合理地认为,如果 y 的值是 2,那么 x 的值应该是 1。然而,由于指令重排,可能会出现以下情况:
Plain Text // 线程 1 y = 2; // 指令 2 x = 1; // 指令 1 // 线程 2 if (y == 2) { // 可能 x != 1 }
在这种情况下,线程 2 可能会在 y 被设置为 2 之后,但在 x 被设置为 1 之前执行,从而导致不一致的状态。
如何防止指令重排
为了防止指令重排导致的多线程问题,可以使用以下方法:
volatile 关键字:在 Java 中,使用 volatile 关键字可以禁止特定类型的指令重排。声明为 volatile 的变量在被写入时会立即被刷新到主内存,在被读取时会从主内存中读取,确保变量的可见性和有序性。
Plain Text private volatile int x = 0;
同步机制:使用同步块(synchronized)或显式锁(如 ReentrantLock)可以确保在同步块内的指令按预期顺序执行,防止指令重排。
内存屏障:在底层,内存屏障(Memory Barrier)或内存栅栏(Memory Fence)是一种指令,用于防止特定类型的指令重排。如 Java 的 Unsafe 类提供了对内存屏障的支持。
105. 为什么指令重排能够提高性能?⭐
指令重排是指编译器或处理器在不改变程序语义的前提下,重新安排指令的执行顺序,以提高程序执行效率的一种优化技术。
为什么指令重排能够提高性能
利用处理器的乱序执行能力
现代处理器通常具有乱序执行能力,这意味着处理器可以根据指令的依赖关系和资源可用性,动态地调整指令的执行顺序。这样做的主要目的是最大化处理器资源的利用率,减少等待时间。
假设有以下指令序列:
指令 1 和指令 2 存在数据依赖关系,而指令 3 和指令 4 则与前两条指令无关。处理器可以重排指令,使得指令 3 在指令 1 之后立即执行,而不必等待指令 2 的执行结果:
这样,处理器可以在等待指令 1 的内存读取时,先执行指令 3,从而减少整体等待时间,提高执行效率。
减少数据依赖导致的等待时间
指令重排可以减少由于数据依赖(Data Dependency)导致的等待时间。数据依赖指的是一条指令需要等待前一条指令的结果才能执行。
假设有以下指令序列:
指令 2 依赖于指令 1 的结果,但指令 3 和指令 4 与前两条指令无关。处理器可以重排指令,使得指令 3 和指令 4 在指令 2 之前执行,从而减少等待时间:
这样,指令 3 和指令 4 可以在等待指令 1 的内存读取时执行,从而减少整体等待时间,提高执行效率。
提高指令级并行性
指令重排可以提高指令级并行性(Instruction-Level Parallelism, ILP),即在同一时刻可以并行执行的指令数量。通过重排指令,可以更好地填充处理器的流水线(Pipeline),减少流水线停顿(Pipeline Stalls)。
假设处理器有两个执行单元,可以同时执行两条指令。原始指令序列:
如果处理器重排指令,将指令 3 提前:
这样,指令 1 和指令 3 可以同时执行,提高了指令级并行性。
隐藏内存访问延迟
内存访问通常比处理器执行指令要慢得多。指令重排可以将内存访问的延迟隐藏在其他指令的执行过程中,从而提高整体性能。
假设有以下指令序列:
处理器可以重排指令,将两个加载指令分开,以隐藏内存访问延迟:
这样,在等待加载数据的同时,处理器可以执行其他指令,提高了整体效率。
106. volatile 如何防止了指令重排⭐⭐
在 Java 中,volatile 关键字用于修饰变量,以确保对该变量的读写操作具有可见性和有序性。具体来说,volatile 变量的读写操作会有以下两个主要特性:
可见性:当一个线程修改了 volatile 变量的值,新的值对于其他所有线程立即可见。
有序性:volatile 关键字可以防止指令重排,从而保证代码执行的顺序性。
防止指令重排
volatile 关键字防止指令重排的机制主要依赖于内存屏障(Memory Barriers,也称为内存栅栏)。内存屏障是一种指令,用于限制处理器和编译器对指令的重排序行为。
内存屏障的作用
内存屏障分为四种类型:
LoadLoad Barrier:确保在此屏障之前的所有读操作在屏障之后的读操作之前完成。
StoreStore Barrier:确保在此屏障之前的所有写操作在屏障之后的写操作之前完成。
LoadStore Barrier:确保在此屏障之前的所有读操作在屏障之后的写操作之前完成。
StoreLoad Barrier:确保在此屏障之前的所有写操作在屏障之后的读操作之前完成。
在 Java 中,volatile 变量的读写操作会插入特定的内存屏障,以确保有序性:
读屏障
在每个 volatile 读操作的后面插入一个 loadload 屏障,禁止处理器把上面的 volatile 读与下面的普通读重排序。
在每个 volatile 读操作的后面插入一个 loadstore 屏障,禁止处理器把上面的 volatile 读与下面的普通写重排序。
写屏障
在每个 volatile 写操作的前面插入一个 storestore 屏障,可以保证在 volatile 写之前,其前面的所有普通写操作都已经刷新到主内存中。
在每个 volatile 写操作的后面插入一个 storeload 屏障,作用是避免 volatile 写与后面可能有的 volatile 读/写操作重排)
107. volatile 保证线程的可见性和有序性,不保证原子性是为什么?⭐
保证线程的可见性
可见性原理
在多线程环境中,每个线程都有自己的工作内存(缓存),从主存中读取变量复制到工作内存中进行操作,操作完成后再写回主存。普通变量的修改在一个线程中进行后,其他线程并不一定能立即看到,因为这个修改可能只存在于工作内存中,尚未刷新到主存。
volatile 关键字通过一套内存屏障(Memory Barrier)机制来保证变量的可见性。具体来说,当一个变量被声明为 volatile 时:
写操作:在写入 volatile 变量时,会在写操作之后插入一个写屏障(Store Barrier)。这确保了在写入 volatile 变量之前,对共享变量的修改会被同步到主存中。
读操作:在读取 volatile 变量时,会在读操作之前插入一个读屏障(Load Barrier)。这确保了在读取 volatile 变量之后,能从主存中获取最新的值。
在这个例子中,当一个线程调用 setFlag()方法时,flag 被设置为 true,并且这个修改会立即被刷新到主存中。其他线程在调用 checkFlag()方法时,会从主存中读取 flag 的最新值,从而跳出循环。
保证有序性
有序性原理
在 Java 内存模型中,编译器和处理器为了优化性能,可能会对指令进行重排序。重排序不会影响单线程程序的正确性,但在多线程环境下可能会导致不可预期的问题。
volatile 关键字通过内存屏障来防止指令重排,从而保证有序性。
写操作:在写入 volatile 变量时,会在写操作之前插入一个写屏障。这确保了在写入 volatile 变量之前的所有写操作都不会被重排序到写屏障之后。
读操作:在读取 volatile 变量时,会在读操作之后插入一个读屏障。这确保了在读取 volatile 变量之后的所有读操作都不会被重排序到读屏障之前。
由于 a 是 volatile 变量,写入 a 之前的所有写操作(包括写入 b)在写入 a 之后对其他线程都是可见的。因此,如果 a == 1,那么 b 必然已经被写入 2。
为什么不保证原子性
原子性原理
原子性指的是一个操作是不可分割的,即操作要么全部执行完毕,要么完全不执行。volatile 保证了对变量的单次读/写操作是原子的,但无法保证复合操作(如自增、自减)的原子性。
在这个例子中,count++实际上包含了三个步骤:
读取 count 的值
将 count 的值加 1
将新值写回 count
这些步骤并不是一个原子操作,可能会被其他线程打断。例如,两个线程同时执行 increment()方法时,可能会发生竞态条件,导致 count 的值不如预期。例如:
线程 A 读取 count 的值为 0
线程 B 读取 count 的值为 0
线程 A 将 count 的值加 1 并写回(count 变为 1)
线程 B 将 count 的值加 1 并写回(count 变为 1)
最终 count 的值是 1 而不是 2。
108. 什么是内存屏障?⭐
内存屏障也称为内存栅栏,是一种用于控制处理器和编译器对内存操作进行重排序的指令。内存屏障确保在特定点之前的内存操作完成后,才会进行该点之后的内存操作。因为它们可以确保内存操作的可见性和有序性,从而避免数据竞争和其他并发问题。
内存屏障的类型
内存屏障主要有以下几种类型,每种类型的屏障对内存操作的重排序有不同的限制:
LoadLoad Barrier:
确保在此屏障之前的所有读操作在屏障之后的读操作之前完成。
示例:Load1; LoadLoad; Load2 保证 Load1 在 Load2 之前完成。
StoreStore Barrier:
确保在此屏障之前的所有写操作在屏障之后的写操作之前完成。
示例:Store1; StoreStore; Store2 保证 Store1 在 Store2 之前完成。
LoadStore Barrier:
确保在此屏障之前的所有读操作在屏障之后的写操作之前完成。
示例:Load1; LoadStore; Store2 保证 Load1 在 Store2 之前完成。
StoreLoad Barrier:
确保在此屏障之前的所有写操作在屏障之后的读操作之前完成。StoreLoad 屏障通常是最昂贵的,因为它会导致处理器流水线的刷新。
示例:Store1; StoreLoad; Load2 保证 Store1 在 Load2 之前完成。
内存屏障的作用
内存屏障在多线程编程中有以下几个主要作用:
防止指令重排:
编译器和处理器可能会对指令进行重排,以优化性能。然而,在多线程环境下,这种重排可能会导致数据不一致。内存屏障通过限制重排,确保特定顺序的内存操作。
确保内存可见性:
内存屏障确保一个线程对内存的修改对其他线程立即可见。这对于实现正确的同步原语(如锁、信号量)至关重要。
内存屏障在 Java 中的实现
在 Java 中,volatile 关键字通过内存屏障来实现其可见性和有序性保证。具体来说,volatile 变量的读写操作会插入相应的内存屏障。
假设有以下代码:
在这段代码中:
Thread 1:
a = 1 是普通写操作。
flag = true 是 volatile 写操作。在这之后插入了 StoreStore Barrier 和 StoreLoad Barrier。
这确保了 a = 1 在 flag = true 之前完成,并且 flag = true 之后的任何读操作不会被重排到 flag = true 之前。
Thread 2:
if (flag)是 volatile 读操作。在这之前插入了 LoadLoad Barrier 和 LoadStore Barrier。
这确保了 if (flag)之前的任何读操作不会被重排到 if (flag)之后,并且 if (flag)之后的任何写操作不会被重排到 if (flag)之前。
109. happen-before 原则⭐
Happens-before 原则是 Java 内存模型中的一个核心概念,用于定义多线程程序中操作的执行顺序和内存可见性。通过 happens-before 关系,我们可以推断出线程之间的内存操作如何相互影响,从而确保线程安全。
Happens-before 原则的定义
如果一个操作 A happens-before 另一个操作 B,那么在多线程环境中,A 的结果对 B 是可见的,并且 A 在时间上先于 B 执行。具体来说,happens-before 关系确保了两个操作之间的可见性和顺序性。
Happens-before 规则
Java 内存模型定义了一些基本的 happens-before 规则,这些规则描述了不同操作之间的顺序关系:
程序次序规则(Program Order Rule):在一个线程中,按照程序顺序,前面的操作 happens-before 后面的操作。
监视器锁规则(Monitor Lock Rule):对一个锁的解锁(unlock)操作 happens-before 对同一个锁的加锁(lock)操作。
传递性(Transitivity):如果操作 A happens-before 操作 B,且操作 B happens-before 操作 C,那么操作 A happens-before 操作 C。
线程启动规则(Thread Start Rule):在一个线程中,对另一个线程的 Thread.start()调用 happens-before 该线程中的任何操作。
线程终止规则(Thread Termination Rule):一个线程中的所有操作 happens-before 另一个线程调用该线程的 Thread.join()并成功返回。
中断规则(Interrupt Rule):对线程的 Thread.interrupt()调用 happens-before 被中断线程检测到中断事件的发生(通过 Thread.isInterrupted()或抛出 InterruptedException)。
对象构造规则(Object Construction Rule):一个对象的构造函数的结束 happens-before 该对象的 finalize()方法的开始。
Happens-before 原则的应用
程序次序规则
Plain Text int a = 1; // 操作 A int b = 2; // 操作 B
在同一个线程中,操作 A happens-before 操作 B,确保了操作 A 的结果对操作 B 可见。
监视器锁规则
Plain Text synchronized(lock) { // 操作 A } synchronized(lock) { // 操作 B }
对 lock 的解锁操作(操作 A)happens-before 对同一个 lock 的加锁操作(操作 B)。
线程启动规则
Plain Text Thread t = new Thread(() -> { // 操作 B }); t.start(); // 操作 A
在主线程中,对 Thread t 的 start()调用(操作 A)happens-before 新线程中的任何操作(操作 B)。
Happens-before 原则的意义
Happens-before 原则为开发者提供了一套明确的规则,用于推断多线程程序中操作的执行顺序和内存可见性。这有助于编写正确和高效的并发程序,避免数据竞争和其他并发问题。通过遵循这些规则,开发者可以确保线程间的正确同步,确保程序的正确性和稳定性。
110. synchronized 的底层实现?⭐⭐⭐⭐⭐
synchronized 关键字在 Java 中用于实现线程同步,确保同一时间只有一个线程可以执行同步代码块或方法。它的底层实现依赖于 JVM 和操作系统提供的锁机制。
Monitor(监视器)机制
synchronized 的底层实现依赖于一个称为“监视器”(Monitor)的机制。每个对象在 Java 中都有一个与之关联的监视器锁(Monitor Lock)。当一个线程进入一个 synchronized 方法或代码块时,它会尝试获取该对象的监视器锁。
JVM 指令
synchronized 关键字会被编译器转换为字节码中的两条指令:monitorenter 和 monitorexit。
monitorenter:当线程进入同步方法或代码块时,尝试获取对象的监视器锁。如果锁已经被其他线程持有,当前线程会被阻塞,直到锁被释放。
monitorexit:当线程退出同步方法或代码块时,释放对象的监视器锁。
对象头(Object Header)
在 HotSpot JVM 中,对象头(Object Header)包含了锁信息。对象头中有一个 Mark Word(标记字段),用于存储锁状态和其他信息。锁可以有多种状态,主要包括以下几种:
无锁(Unlocked):对象没有被任何线程持有锁。
偏向锁(Biased Locking):一个线程偏向于持有锁,减少了加锁和解锁的开销。
轻量级锁(Lightweight Locking):通过 CAS(Compare-And-Swap)操作实现的锁,适用于低竞争的场景。
重量级锁(Heavyweight Locking):通过操作系统的互斥量(Mutex)实现的锁,适用于高竞争的场景。
锁的升级
JVM 会根据竞争情况在不同的锁状态之间进行升级
偏向锁:默认情况下,JVM 会尝试使用偏向锁,当一个线程多次进入同步块时,不需要每次都进行加锁操作。
轻量级锁:如果有多个线程竞争同一个锁,偏向锁会升级为轻量级锁。
重量级锁:如果锁竞争激烈,轻量级锁会升级为重量级锁。
锁消除和锁粗化
JVM 在运行时会进行一些优化来减少锁的开销:
锁消除(Lock Elimination):JVM 在 JIT 编译时会分析代码,如果发现某些锁是多余的(如局部变量锁),会自动消除这些锁。
锁粗化(Lock Coarsening):JVM 会将多个连续的加锁和解锁操作合并为一个更大的加锁操作,减少锁的开销。
111. synchronized 和 lock 的区别⭐⭐⭐⭐⭐
synchronized 和 Lock 是 Java 中用于实现线程同步的两种机制。它们都能确保线程安全。
synchronized 关键字
synchronized 是 Java 语言内置的同步机制,用于在方法或代码块上实现互斥锁。
用法简单:可以直接在方法声明上使用,表示整个方法是同步的。可以在代码块上使用,指定某个对象作为锁。
隐式锁:synchronized 使用的是隐式锁(内置锁或监视器锁),每个对象都有一个隐式锁。
自动释放锁:当线程退出 synchronized 方法或代码块时,锁会自动释放,无需手动释放。
不可中断:如果一个线程在等待获取锁时被阻塞,无法中断该线程。
性能开销:早期版本的 Java 中,synchronized 的性能较低,但从 Java 6 开始,JVM 对 synchronized 进行了大量优化,使其性能显著提升。
Lock 接口(及其实现)
Lock 是 Java 5 引入的更灵活的同步机制,位于 java.util.concurrent.locks 包中。Lock 接口提供了更丰富的锁功能,主要特点和使用方式如下:
灵活性更高:Lock 接口提供了多种实现,如 ReentrantLock,可以实现更加复杂的同步需求。提供了可中断锁、超时获取锁、非阻塞尝试获取锁等功能。
显式锁:需要显式地获取和释放锁,使用 lock()方法获取锁,使用 unlock()方法释放锁。
可中断锁:可以通过 lockInterruptibly()方法获取锁,允许在等待锁时响应中断。
条件变量:Lock 接口提供了条件变量(Condition),可以实现更加复杂的等待/通知机制。
性能开销:在某些情况下,Lock 的性能可能优于 synchronized,特别是在高竞争的情况下。
选择建议
对于简单的同步需求,synchronized 通常已经足够,并且其代码更加简洁和易读。
对于需要更高灵活性、可中断锁、超时获取锁或条件变量的复杂同步需求,Lock 接口及其实现(如 ReentrantLock)是更好的选择。
选择哪种同步机制取决于具体的需求和场景。在大多数情况下,synchronized 已经能够满足需求,但在一些特殊场景下,Lock 提供了更强大的功能和更高的灵活性。
112. 线程池的异步任务执行完后,如何回调⭐
使用 Future 和 Callable
Future 接口提供了一种检查任务是否完成、获取任务结果的方法。可以通过轮询 Future.isDone()方法来检查任务是否完成,或者直接调用 Future.get()方法来获取结果(此方法会阻塞直到任务完成)。
使用 CompletionService
CompletionService 提供了一种更方便的方式来处理异步任务的结果。它可以将任务提交到线程池,并在任务完成后立即处理结果。
使用 CompletableFuture
CompletableFuture 是 Java 8 引入的一个类,提供了更强大的异步编程能力。它允许在任务完成后执行回调操作。
使用自定义回调接口
可以定义一个回调接口,并在任务完成后调用回调方法。
使用 FutureTask
FutureTask 是一个同时实现了 Runnable 和 Future 的类,可以在任务完成后执行回调操作。
113. synchronized 能否被打断,什么情况下打断⭐⭐⭐⭐
synchronized 关键字在 Java 中用于实现同步控制,确保同一时间只有一个线程可以访问被同步的方法或代码块。synchronized 块或方法本质上是不可被打断的,即一旦一个线程获得了对象的监视器锁(monitor lock),其他线程就无法中断它,而只能等待这个锁被释放。持有 synchronized 锁的线程不可被打断。等待获取 synchronized 锁的线程可以被打断,但不会抛出 InterruptedException,而是继续等待锁的释放。
不可被打断: 当一个线程进入一个 synchronized 方法或代码块并获得了锁时,其他试图进入该 synchronized 方法或代码块的线程将被阻塞,直到持有锁的线程退出该 synchronized 方法或代码块并释放锁。此时,等待的线程会依次尝试获得锁。
等待锁时可以被打断: 虽然持有锁的线程不可被打断,但等待获取锁的线程是可以被打断的。具体来说,当一个线程在等待进入一个被 synchronized 修饰的方法或代码块时,如果该线程被其他线程调用了 Thread.interrupt()方法,那么该线程会被设置为中断状态,但不会抛出 InterruptedException。也就是说,线程仍然会继续等待获取锁,直到锁被释放。
demo
在这个示例中:
Thread-1 首先获得了锁并执行 synchronizedMethod 方法。
Thread-2 尝试进入 synchronizedMethod 方法,但会被阻塞,因为锁已经被 Thread-1 持有。
在 main 方法中,我们在 Thread-1 获得锁后,尝试中断 Thread-2。
尽管 Thread-2 被中断,它仍然会继续等待锁的释放,而不会抛出 InterruptedException。
114. synchronized 的不同作用范围有什么区别⭐⭐⭐⭐⭐
synchronized 可以应用于实例方法、静态方法和代码块。
同步实例方法
当 synchronized 用于实例方法时,锁定的是当前对象实例(this)。这意味着同一个对象实例的所有同步实例方法在同一时间只能被一个线程访问。
同一个对象实例的所有同步实例方法在同一时间只能被一个线程访问,不同对象实例之间的同步方法不互相影响。
同步静态方法
当 synchronized 用于静态方法时,锁定的是该类的 Class 对象。这意味着同一个类的所有同步静态方法在同一时间只能被一个线程访问。
锁定范围:当前类的 Class 对象。
影响:同一个类的所有同步静态方法在同一时间只能被一个线程访问,不同类之间的静态方法不互相影响。
同步代码块
当 synchronized 用于代码块时,可以指定锁定的对象。这提供了更细粒度的控制,可以锁定任意对象。
锁定范围:指定的锁对象。
影响:只有在访问相同锁对象的同步代码块时,线程才会互相阻塞。可以更灵活地控制锁的粒度。
代码 Demo
同步实例方法:锁定当前对象实例,确保同一时间只有一个线程可以访问该对象的同步实例方法。
同步静态方法:锁定当前类的 Class 对象,确保同一时间只有一个线程可以访问该类的同步静态方法。
同步代码块:锁定指定的对象,可以更灵活地控制锁的粒度,适用于需要更细粒度同步控制的场景。
115. synchronized 是可重入锁吗⭐⭐⭐⭐⭐
是的,synchronized 是一种可重入锁(Reentrant Lock)。
什么是可重入锁?
可重入锁是指一个线程在持有锁的情况下,可以再次获取该锁而不会被阻塞。这意味着,如果一个线程已经获取了某个锁,它可以再次进入由该锁保护的代码块,而无需重新获取锁。
synchronized 的可重入性
在 Java 中,synchronized 关键字实现的锁是可重入的。具体来说,如果一个线程已经持有了某个对象的锁,它可以再次进入由该对象锁保护的其他同步方法或同步代码块,而不会被阻塞。这种特性对于实现递归调用和避免死锁非常有用。
在这个例子中,当一个线程调用 methodA 时,它会首先获取 ReentrantLockExample 对象的锁。然后,methodA 内部调用了 methodB,而 methodB 也是一个同步方法。由于 synchronized 是可重入的,线程可以进入 methodB 而不会被阻塞。
输出结果
这个输出结果表明线程在执行 methodA 时,可以顺利进入 methodB,并且在 methodB 执行完毕后继续执行 methodA 的剩余部分。
可重入性的意义
116. 为什么 wait 和 notify 必须要在 synchronized 代码块使用?⭐
主要是为了确保线程间通信的正确性和一致性。
确保对象的监视器锁(Monitor Lock)
wait,notify, 和 notifyAll 方法是用于线程间通信的,它们依赖于对象的监视器锁(也称为对象锁)。在调用这些方法之前,线程必须持有该对象的监视器锁,否则会抛出 IllegalMonitorStateException 异常。
doWait 和 doNotify 方法都在 synchronized 代码块中执行,以确保当前线程持有对象的监视器锁。
确保线程安全
wait 和 notify 方法用于协调多个线程的执行顺序。如果不在 synchronized 代码块中使用,这些方法的调用可能会导致竞态条件,破坏线程间的通信协议。
在上面的代码中,wait 和 notify 方法不在 synchronized 代码块中调用,会导致 IllegalMonitorStateException,并且无法保证线程间通信的正确性。
保证操作的原子性
wait 方法会使当前线程进入等待状态,并释放对象的监视器锁。notify 方法会唤醒等待该对象监视器锁的某个线程。如果这些操作不在 synchronized 代码块中进行,可能会导致以下问题:
竞态条件:多个线程同时检查和修改共享资源,导致数据不一致。
死锁:线程可能会永远等待,因为没有其他线程能够持有对象锁并调用 notify。
通过使用 synchronized 代码块,可以确保这些操作的原子性和一致性。
确保正确的线程唤醒
当一个线程调用 wait 方法时,它会释放对象的监视器锁并进入等待状态,直到另一个线程调用 notify 或 notifyAll 方法。如果不在 synchronized 代码块中使用,可能会出现以下问题:
早期通知:如果 notify 方法在 wait 方法之前调用,等待的线程可能会错过通知,导致永远等待。
丢失通知:如果多个线程竞争调用 notify 和 wait 方法,可能会导致通知丢失。
通过在 synchronized 代码块中使用,可以确保线程在正确的时间点进入等待状态和被唤醒。
代码 Demo
在这个例子中,doWait 和 doNotify 方法都在 synchronized 代码块中执行,确保了线程间通信的正确性和一致性。
117. synchronized 解锁后是如何唤醒其它正在阻塞的线程的?⭐
在 Java 中,当一个线程退出一个 synchronized 块或方法时,它会释放持有的锁。释放锁的操作会触发 JVM 去唤醒等待该锁的线程。具体的唤醒机制由 JVM 实现决定,但通常情况下,当锁被释放时,JVM 会选择一个或多个等待该锁的线程并将其唤醒,使其能够重新竞争锁的获取。
详细过程
锁的获取和释放:
当一个线程进入一个synchronized
块或方法时,它会尝试获取该对象的监视器锁(monitor lock)。如果锁已经被其他线程持有,该线程将进入阻塞状态,等待锁被释放。当线程退出synchronized
块或方法时,它会释放该监视器锁。
线程的等待和唤醒:
当一个线程在等待synchronized
锁时,它会进入阻塞队列(也称为等待队列)。当锁被释放时,JVM 会从阻塞队列中选择一个或多个线程进行唤醒。被唤醒的线程会重新尝试获取锁。如果锁被其他线程抢先获取,唤醒的线程将再次进入阻塞状态,直到锁再次被释放。
JVM 如何选择唤醒的线程
JVM 选择唤醒线程的具体策略可能因不同的 JVM 实现而异,但通常会遵循以下原则:
公平性:某些 JVM 实现可能会尝试实现某种程度的公平性,按照线程进入阻塞队列的顺序进行唤醒。
非公平性:其他实现可能采取非公平的策略,随机选择一个等待的线程进行唤醒。
在这个示例中:
Thread 1
首先获取锁并持有它一段时间。
Thread 2
尝试获取锁但会被阻塞,直到Thread 1
释放锁。
当Thread 1
释放锁后,JVM 会唤醒Thread 2
,使其能够获取锁并执行同步块中的代码。
118. 什么是可重入锁及使用场景⭐
Java 中的可重入锁
在 Java 中,synchronized 关键字和 java.util.concurrent.locks.ReentrantLock 类都实现了可重入锁的机制。
使用 synchronized
outerMethod 调用了 innerMethod,而 innerMethod 也被 synchronized 修饰。由于 synchronized 是可重入的,线程可以在持有锁的情况下再次进入 innerMethod。
使用 ReentrantLock
ReentrantLock 提供了比 synchronized 更灵活的锁机制:
outerMethod 和 innerMethod 都使用了 ReentrantLock。由于 ReentrantLock 是可重入的,线程可以在持有锁的情况下再次获取锁。
119. 可重入锁实现原理⭐
可重入锁(Reentrant Lock)的实现原理涉及到锁的计数器和线程拥有者的管理。在 Java 中,ReentrantLock 是通过 java.util.concurrent.locks.AbstractQueuedSynchronizer(AQS)来实现的。
基本原理
可重入锁的基本原理是:
锁计数器:每个线程对锁的获取都会增加锁的计数器,释放锁则会减少锁的计数器。计数器为零时,锁才真正被释放。
线程拥有者:锁记录哪个线程持有它,并且只有持有锁的线程才能重新获取它。
ReentrantLock 的实现
ReentrantLock 通过内部类 Sync 来实现锁的具体机制,Sync 继承自 AbstractQueuedSynchronizer(AQS)。AQS 提供了一套框架,用于实现依赖 FIFO 队列的同步器。
主要成员变量和方法
状态变量:AQS 使用一个 volatile 的 int 变量 state 来表示锁的状态。对于可重入锁,state 表示锁的计数器。
当前持有锁的线程:使用 Thread 变量 exclusiveOwnerThread 来记录当前持有锁的线程。
120. 锁升级机制是怎样的⭐
锁的升级机制是指锁在不同的竞争条件下从一种状态升级到另一种状态,以优化性能。Java 虚拟机使用了一种称为“偏向锁(Biased Locking)”和“轻量级锁(Lightweight Locking)”的机制来减少锁操作的开销,并在必要时升级到“重量级锁(Heavyweight Locking)”。这种机制在 Java 6 中引入,并在后续版本中不断优化。
锁的状态
锁升级的具体过程
当一个对象刚刚被创建时,它处于无锁状态。此时,对象头(Object Header)中没有锁相关的信息。
偏向锁是为了优化无竞争情况下的锁操作。当一个线程第一次获取锁时,JVM 会将该线程的 ID 记录在对象头中,并将锁标志位设置为偏向锁。之后,如果同一个线程再次获取锁,无需进行任何同步操作,只需检查对象头中的线程 ID 是否匹配即可。
如果另一个线程尝试获取这个偏向锁,JVM 会撤销偏向锁,升级为轻量级锁。
当偏向锁被撤销或多个线程竞争同一个锁时,JVM 会将锁升级为轻量级锁。轻量级锁使用自旋锁(spinlock)来避免线程阻塞。自旋锁会让线程在短时间内不断尝试获取锁,而不是立即进入阻塞状态。
如果自旋次数超过一定阈值,或者锁竞争变得激烈,轻量级锁会升级为重量级锁。
当锁竞争非常激烈时,轻量级锁会升级为重量级锁,使用操作系统的互斥量(mutex)来进行线程同步。这会导致线程阻塞和唤醒,开销较大。
锁升级的实现细节
锁的升级和降级主要通过对象头(Object Header)中的锁标志位和相关数据结构实现。
对象头(Object Header):包含锁标志位和其他锁相关信息。
Mark Word:对象头的一部分,用于存储锁信息。根据锁的状态,Mark Word 中的内容会有所不同。
以下是对象头中 Mark Word 的不同状态:
欢迎关注 ❤
我们搞了一个免费的面试真题共享群,互通有无,一起刷题进步。
没准能让你能刷到自己意向公司的最新面试题呢。
感兴趣的朋友们可以加我微信:wangzhongyang1993,备注:面试群。
版权声明: 本文为 InfoQ 作者【王中阳Go】的原创文章。
原文链接:【http://xie.infoq.cn/article/588b30764440d01c774e7cb93】。文章转载请联系作者。
评论