1. equals vs hashCode
在 Java 中,如果你重写了 equals 方法,就必须重写 hashCode 方法。这是因为这两个方法在某些数据结构(如 HashMap、HashSet 等)中是密切相关的。具体原因如下:
-
Hash 码的使用 :
当一个对象被存储在一个哈希表中时,哈希表会使用 hashCode 方法来确定对象存储的位置。如果两个对象的 hashCode 相同,它们会被存储在同一个位置(即"桶")中。这种现象称为哈希碰撞。
-
相等性的要求 :
Java 的约定要求,如果两个对象通过 equals 方法被认为是相等的(即 a.equals(b) 返回 true),那么这两个对象的 hashCode 方法也必须返回相同的哈希码。这是为了保证在哈希表中能够正确找到和识别相等的对象。
-
避免错误的行为 :
如果不重写 hashCode 方法,使用相等的对象存储在哈希表中时,可能会导致查找、插入和删除操作出现不正确的行为。例如,如果两个对象 a 和 b 被认为相等(a.equals(b) 返回 true),但是它们的哈希码不相同,可能会导致在哈希表中找不到对象 b,从而引发错误。
示例
以下是一个简单的示例,展示如何同时重写 equals 和 hashCode:
java
public class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof Person)) return false;
Person other = (Person) obj;
return this.name.equals(other.name) && this.age == other.age;
}
@Override
public int hashCode() {
int result = name.hashCode();
result = 31 * result + age; // 31是一个常数,通常用于计算哈希值
return result;
}
}
在这个例子中,Person 类重写了 equals 和 hashCode,确保了当两个 Person 对象相等时,它们的哈希码也相同。这种做法确保了在使用 HashMap 或 HashSet 等集合时不会出现错误。
2. 锁升级
在 Java 中,锁升级是指在多线程环境下,随着锁的使用情况的变化,锁的类型从一种状态升级到另一种状态的过程。Java 中主要有三种锁类型:
- 无锁(无状态):即不使用任何同步机制的状态。
- 偏向锁:一种优化的锁,适用于某个线程频繁获取同一把锁的情况。
- 轻量级锁:适用于多个线程争用锁的情况,通常当一个线程尝试获取锁时,如果锁没有被其他线程持有,它会使用轻量级锁。
- 重量级锁:即传统的互斥锁,适用于线程竞争非常激烈的情况。
锁升级的过程
锁的升级通常会发生在以下几种情况下:
- 偏向锁到轻量级锁 :
- 当一个偏向锁被持有的线程以外的线程尝试获取时,偏向锁会被撤销,转为轻量级锁。
- 这通常发生在锁被多个线程竞争的情况下。
- 轻量级锁到重量级锁 :
- 当多个线程竞争轻量级锁,并且自旋锁的竞争未能成功获取锁时,轻量级锁会被升级为重量级锁。
- 这意味着锁的性能会下降,系统会使用操作系统的原生锁机制。
锁的实现
Java 的锁管理主要通过 java.util.concurrent 包下的并发工具实现,尤其是 ReentrantLock 和 synchronized 关键字的实现中。下面是一些关键点:
-
synchronized 关键字:
- Java 中的 synchronized 关键字在 JVM 中实现了上述锁的升级机制。
- 初始时,synchronized 采用偏向锁,随着锁的使用情况,它会动态调整为轻量级锁或重量级锁。
-
ReentrantLock:
- ReentrantLock 提供了一种更灵活的锁机制,但它不直接使用偏向锁和轻量级锁的升级机制,而是提供了显式的锁操作,允许在更细粒度的控制下进行线程管理。
示例
以下是一个简单的示例,展示如何使用 synchronized 锁和 ReentrantLock:
java
public class LockExample {
private final Object lock = new Object();
// 使用 synchronized
public synchronized void synchronizedMethod() {
// 临界区
System.out.println("Using synchronized method");
}
// 使用 ReentrantLock
private final ReentrantLock reentrantLock = new ReentrantLock();
public void reentrantLockMethod() {
reentrantLock.lock();
try {
// 临界区
System.out.println("Using ReentrantLock");
} finally {
reentrantLock.unlock();
}
}
}
总结
锁升级是 Java 中管理并发的一个重要机制,旨在优化锁的性能和提高多线程程序的效率。通过使用合适的锁类型,开发者可以有效地管理资源访问和线程同步,从而提高程序的整体性能。
3. 线程池
Java 中的线程池是一种管理和复用线程的机制,旨在提高性能和资源利用率。线程池的基本思想是创建一定数量的线程并将其放入池中,以便可以重用这些线程来执行任务,而不是为每个任务创建新线程。这样可以减少线程的创建和销毁所带来的开销。
1. 常用的线程池
1. Fixed Thread Pool(固定线程池)
- 创建方式:Executors.newFixedThreadPool(int nThreads)
- 特点 :
- corePoolSize = n
- maximumPoolSize = n
- keepAliveTime = 0
- workQueue = LinkedBlockingQueue
- 定长工作线程个数为 n,由于使用了 LinkedBlockingQueue 无界队列,所以 maximumPoolSize 意义不大,设置为跟核心线程数一样大就行了。
- 适用于负载稳定的场景。
示例代码:
java
public class ThreadPoolExample {
public static void main(String[] args) {
// 创建固定大小的线程池
ExecutorService executorService = Executors.newFixedThreadPool(3);
// 提交多个任务
for (int i = 0; i < 10; i++) {
final int taskId = i;
executorService.submit(() -> {
System.out.println("Task " + taskId + " is running in " + Thread.currentThread().getName());
try {
Thread.sleep(1000); // 模拟任务执行
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
// 关闭线程池
executorService.shutdown();
}
}
2. Cached Thread Pool(可缓存线程池)
- 创建方式:Executors.newCachedThreadPool()
- 特点 :
- corePoolSize = 0
- maximumPoolSize = Integer.MAX_VALUE
- keepAliveTime = 60s
- workQueue = SynchronousQueue
- 创建一个可缓存的线程池,可以根据需要创建线程。
- 创建一个核心线程数为0,最大线程数为 int 最大值的线程池,任务队列使用 SynchronousQueue 没有空闲线程,则立即创建一个工作线程;有空闲线程,则获取一个工作线程,从 SynchronousQueue 中交换任务,由于在没有空闲工作线程时,每次都会创建一个新的工作线程,导致工作线程数量比较多,所以需要关闭那些 60s 内没有工作的工作线程。
3. Single Thread Executor(单线程池)
- 创建方式:Executors.newSingleThreadExecutor()
- 特点 :
- corePoolSize = 1
- maximumPoolSize = 1
- keepAliveTime = 0
- workQueue = LinkedBlockingQueue
- 提交的任务会按照顺序执行,适用于需要保证任务执行顺序的场景。
- 适合于只需要一个线程的情况,避免了线程的创建和销毁开销。
- 创建一个仅有一个工作线程的线程池,任务队列使用 LinkedBlockingQueue 无界队列,所以 maximumPoolSize 的大小意义不大,此处设置为1。
4. Scheduled Thread Pool(定时任务线程池)
- 创建方式:Executors.newScheduledThreadPool(int corePoolSize)
- 特点 :
- corePoolSize = n
- maximumPoolSize = Integer.MAX_VALUE
- keepAliveTime = 10ms
- workQueue= DelayedWorkQueue
- 创建一个可调度的定长线程池,可以定期或延迟执行任务。
- 提供 schedule(), scheduleAtFixedRate(), 和 scheduleWithFixedDelay() 等方法来管理任务。
- DelayedWorkQueue 是用数组来储存队列中的元素,核心数据结构是二叉最小堆的优先队列,队列满时会自动扩容。
5. Single Thread Scheduled Pool(单线程定时任务线程池)
- 创建方式:Executors.newSingleThreadScheduledExecutor()
- 特点 :
- corePoolSize = 1
- maximumPoolSize = Integer.MAX_VALUE
- keepAliveTime = 10ms
- workQueue = DelayedWorkQueue
- 它保证所有任务将在同一个线程中按顺序执行(即任务不会并发执行),并且提供了定时执行任务的能力。
- 常见的场景包括需要按顺序执行的定时任务,比如在某个时刻执行某些任务,或周期性地执行一些操作。
- SingleThreadScheduledExecutor 只有一个线程,如果该线程因任务执行异常而终止,线程池将创建一个新的线程来继续执行后续的任务。
示例代码:
java
public class SingleThreadScheduledExecutorExample {
public static void main(String[] args) {
// 创建 SingleThreadScheduledExecutor
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
// 定时执行一次性任务,延迟 2 秒执行
executor.schedule(() -> System.out.println("One-time task executed after 2 seconds"), 2, TimeUnit.SECONDS);
// 周期性执行任务,延迟 1 秒后每 3 秒执行一次
executor.scheduleAtFixedRate(() -> {
System.out.println("Repeating task executed every 3 seconds");
}, 1, 3, TimeUnit.SECONDS);
// 使用者可在合适时关闭线程池
executor.schedule(() -> {
System.out.println("Shutting down executor...");
executor.shutdown();
}, 15, TimeUnit.SECONDS); // 15 秒后关闭线程池
}
}
6. Work Stealing Pool(工作窃取池)(Java 8 引入)
- 创建方式:Executors.newWorkStealingPool()
- 特点 :
- 创建一个可扩展的线程池,使用工作窃取算法。
- 每个线程都有自己的任务队列,线程可以从其他线程的任务队列中"窃取"任务,从而提高 CPU 使用率。
- 适合于执行大量小任务的情况。
WorkStealingPool 不是 ThreadPoolExecutor 的扩展,它是新的线程池类 ForkJoinPool 的扩展,但是都是在统一的一个Executors类中实现,由于能够合理的使用CPU进行对任务操作(并行操作),所以适合使用在很耗时的任务中。
在 ForkJoinPool 中,其内部有一个共享的任务队列,除此之外每个线程都有一个对应的双端队列 Deque , 当一个线程中任务被 Fork 分裂了,那么分裂出来的子任务就会放入到对应的线程自己的 Deque 中,而不是放入公共队列。这样对于每个线程来说成本会降低很多,可以直接从自己线程的队列中获取任务而不需要去公共队列中争夺,有效的减少了线程间的资源竞争和切换。
java
public class WorkStealingPoolExample {
public static void main(String[] args) throws InterruptedException, ExecutionException {
// 创建一个 WorkStealingPool,线程数基于可用的处理器核心数
ExecutorService workStealingPool = Executors.newWorkStealingPool();
// 创建一个任务列表
List<Callable<String>> tasks = new ArrayList<>();
// 添加 10 个任务到任务列表,每个任务模拟不同的执行时间
for (int i = 0; i < 10; i++) {
final int taskId = i;
tasks.add(() -> {
// 模拟任务执行时间
Thread.sleep((long) (Math.random() * 2000));
return "Task " + taskId + " completed by " + Thread.currentThread().getName();
});
}
// 提交任务列表并获取结果,invokeAll 会阻塞直到所有任务完成
List<Future<String>> results = workStealingPool.invokeAll(tasks);
// 输出每个任务的结果
for (Future<String> result : results) {
System.out.println(result.get());
}
// 关闭线程池
workStealingPool.shutdown();
}
}
7. ForkJoinPool(分支合并池)(Java 7 引入)
- 创建方式:ForkJoinPool forkJoinPool = new ForkJoinPool();
- 特点 :
- 主要用于并行处理可分解的任务,支持分治法。
- 允许将任务递归分解为更小的子任务,适合于处理大规模数据集的并行计算。
- 通过 ForkJoinTask 类提交任务。
示例代码:
java
public class ForkJoinPoolExample {
// RecursiveTask 用于递归计算任务,这里计算数组元素的总和
static class SumTask extends RecursiveTask<Integer> {
private static final int THRESHOLD = 10; // 阈值,任务足够小就不再拆分
private int[] array;
private int start;
private int end;
public SumTask(int[] array, int start, int end) {
this.array = array;
this.start = start;
this.end = end;
}
@Override
protected Integer compute() {
// 如果任务小到可以直接计算(不需要再拆分),则计算结果
if (end - start <= THRESHOLD) {
int sum = 0;
for (int i = start; i < end; i++) {
sum += array[i];
}
return sum;
} else {
// 将任务拆分为两个子任务
int middle = (start + end) / 2;
SumTask leftTask = new SumTask(array, start, middle);
SumTask rightTask = new SumTask(array, middle, end);
// 执行子任务
leftTask.fork(); // 异步执行 leftTask
int rightResult = rightTask.compute(); // 同步执行 rightTask
int leftResult = leftTask.join(); // 等待 leftTask 的结果
// 合并两个子任务的结果
return leftResult + rightResult;
}
}
}
public static void main(String[] args) {
// 创建一个随机数组
int[] array = new int[100];
for (int i = 0; i < array.length; i++) {
array[i] = i + 1; // 初始化数组 1 到 100
}
// 创建 ForkJoinPool
ForkJoinPool forkJoinPool = new ForkJoinPool();
// 创建任务
SumTask task = new SumTask(array, 0, array.length);
// 提交任务到 ForkJoinPool 并获取结果
int result = forkJoinPool.invoke(task);
// 输出结果
System.out.println("Sum: " + result);
// 关闭线程池
forkJoinPool.shutdown();
}
}
总结
Java 中提供的线程池类型可以满足多种需求,从固定数量的线程到可调度任务,开发者可以根据具体场景选择合适的线程池。合理使用线程池能够提高程序性能,管理系统资源,避免线程的频繁创建和销毁,从而提高应用程序的响应速度和稳定性。
2. 核心参数
Java 中的线程池有一些核心参数,用于配置和管理线程池的行为。这些参数通常用于 ThreadPoolExecutor 类,这个类是 Java 中线程池的核心实现,支持灵活的线程管理和任务调度。
线程池的核心参数
-
corePoolSize(核心线程数):
- 线程池中保持的核心线程数,即使线程处于空闲状态也不会被销毁,除非 allowCoreThreadTimeOut 设置为 true。
- 当任务提交时,如果当前运行的线程少于 corePoolSize,即使有空闲线程,也会创建新的线程处理任务。
- 默认情况下,核心线程是常驻线程,即使空闲也不会被回收。
-
maximumPoolSize(最大线程数):
- 线程池中允许的最大线程数。当任务队列满了并且线程数已经达到 corePoolSize 时,线程池会继续创建新线程直到达到 maximumPoolSize。
- 这个参数限制了线程池中能够同时运行的最大线程数量。
-
keepAliveTime(线程存活时间):
- 当线程数超过 corePoolSize 时,多余的空闲线程在等待新任务时最多能存活的时间。超过这个时间没有新任务,线程将会被终止。
- 这个参数只对非核心线程起作用。除非调用了 allowCoreThreadTimeOut(true),否则核心线程不受此参数的影响。
-
unit(时间单位):
- 用于指定 keepAliveTime 的时间单位,通常使用 TimeUnit 枚举类,包括 TimeUnit.SECONDS, TimeUnit.MILLISECONDS 等。
-
workQueue(任务队列):
- 用于存放等待执行的任务的队列。当所有核心线程都在忙碌时,新的任务会被放入队列中等待执行。
- 常用的队列类型有:
- ArrayBlockingQueue:有界的阻塞队列,使用数组存储元素。
- LinkedBlockingQueue:可选有界或无界的阻塞队列,使用链表存储元素。
- SynchronousQueue:每个插入操作必须等待一个取出操作,反之亦然,没有任何缓冲能力。
- PriorityBlockingQueue:支持任务按优先级顺序执行的无界队列。
-
threadFactory(线程工厂):
- 用于创建线程的工厂。你可以自定义 ThreadFactory,例如为线程指定名字、设置为守护线程等。
-
handler(拒绝策略/饱和策略):
- 当线程池和任务队列都满时,执行任务的策略。常见的拒绝策略有:
- AbortPolicy:默认策略,丢弃任务并抛出 RejectedExecutionException 异常。
- CallerRunsPolicy:直接在调用者线程中执行任务。
- DiscardPolicy:直接丢弃任务,不抛出异常。
- DiscardOldestPolicy:丢弃队列中最旧的任务,并尝试提交新任务。
- 自定义拒绝策略,除了以上内置策略,可以实现 RejectedExecutionHandler 接口来定义自己的拒绝策略。例如记录日志、尝试将任务加入到另一个线程池或者延迟执行。返回友好提示或者页面。
- 当线程池和任务队列都满时,执行任务的策略。常见的拒绝策略有:
线程池参数配置示例
java
public class ThreadPoolExample {
public static void main(String[] args) {
// 创建线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(
5, // corePoolSize (核心线程数)
10, // maximumPoolSize (最大线程数)
60, TimeUnit.SECONDS, // keepAliveTime 和 unit (非核心线程存活时间和单位)
new LinkedBlockingQueue<>(100), // workQueue (任务队列)
Executors.defaultThreadFactory(), // threadFactory (线程工厂)
new ThreadPoolExecutor.AbortPolicy() // handler (拒绝策略)
);
// 提交任务给线程池
for (int i = 0; i < 20; i++) {
final int taskID = i;
executor.submit(() -> {
System.out.println("Task " + taskID + " is running in " + Thread.currentThread().getName());
try {
Thread.sleep(2000); // 模拟任务执行时间
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Task " + taskID + " completed.");
});
}
// 关闭线程池
executor.shutdown();
}
}
3. 线程池阻塞原理
Java 线程池的阻塞原理是通过任务队列和线程同步机制来实现的。线程池使用了任务队列(如 BlockingQueue)来存放待执行的任务,并使用线程的等待/通知机制来协调工作线程和任务队列之间的交互。
线程池中的任务队列通常是阻塞队列,它有两大特性:
- 阻塞添加任务:当任务队列已满时,调用 put() 方法的线程会被阻塞,直到队列有空闲位置。
- 阻塞获取任务:当任务队列为空时,调用 take() 方法的线程会被阻塞,直到队列中有任务。
这意味着当线程池中没有可用线程或者任务队列满时,线程池可以通过阻塞来防止新的任务被立即执行,直到有线程空闲或任务队列中有空间。
线程池的工作机制:
- 核心线程数:线程池会在 corePoolSize 以内保持常驻线程。这些线程在任务到达时会直接处理任务,当没有任务时,核心线程会等待新任务。
- 非核心线程:当核心线程全部忙碌时,如果任务队列也满了,线程池会创建新的非核心线程来处理任务,直到达到 maximumPoolSize。这些非核心线程在任务完成后,若空闲超过 keepAliveTime,将被销毁。
- 队列阻塞处理:如果线程池没有可用线程并且任务队列已满,线程池将会根据配置的拒绝策略(RejectedExecutionHandler)来决定如何处理多余的任务,通常的选择是阻塞、抛出异常或直接丢弃任务。
线程的同步机制:
- 线程池内部使用了锁机制(如 ReentrantLock)和条件队列(如 Condition),使得工作线程和任务队列之间能够进行同步。当任务到达时,如果没有可用线程,线程池会让任务在队列中等待,直到有线程可用。反之,当线程池中有可用线程但任务队列为空时,线程会等待新的任务到达。
线程的阻塞和唤醒是通过 Lock 和 Condition 实现的,如:
- 等待:当线程池中的线程在等待任务时,它们会调用阻塞队列的 take() 方法等待任务。这会导致线程进入等待状态,直到有新的任务可以执行。
- 唤醒:当新的任务被提交到线程池时,任务会被放入阻塞队列中,阻塞的线程会被唤醒以执行任务。
任务调度:
- 线程池中的每个线程都会执行一个循环(通常是 while 循环),在循环中从队列中取出任务并执行。当队列为空时,线程会在 take() 调用处阻塞,直到有任务可执行。
- 任务队列通过阻塞队列的 take() 和 put() 来实现任务的等待和线程的阻塞。例如,LinkedBlockingQueue 使用了独立的锁(ReentrantLock)和条件变量(Condition)来协调生产者和消费者线程之间的并发访问,确保线程能够有序地等待任务或唤醒线程。
4. ThreadLocal
ThreadLocal 是 Java 提供的一种用于实现线程本地变量的机制。它允许每个线程独立地存储和访问自己的变量副本,从而避免线程之间的共享数据问题,确保线程安全。ThreadLocal 特别适用于存储与线程相关的状态信息,比如用户会话、数据库连接等。
主要特点:
- 每个线程独立的副本 :
- 每个线程通过 ThreadLocal 获取的变量副本是独立的,其他线程无法访问或修改。
- 避免竞争条件 :
- 由于每个线程持有自己的数据副本,因此可以避免多线程环境下的竞争条件和同步问题。
- 内存管理 :
- ThreadLocal 变量在使用完后,应该通过调用 remove() 方法进行清理,以避免内存泄漏。尤其是在使用线程池时,线程可能会被复用,导致旧的线程本地变量仍然存在。
使用方法
ThreadLocal 类提供了一些基本的方法,最常用的方法包括:
- set(T value):设置当前线程的线程本地变量值。
- get():获取当前线程的线程本地变量值。
- remove():删除当前线程的线程本地变量值。
示例代码
java
public class ThreadLocalExample {
// 创建一个 ThreadLocal 变量
private static ThreadLocal<Integer> threadLocalValue = ThreadLocal.withInitial(() -> 0);
public static void main(String[] args) {
Runnable task = () -> {
// 每个线程都可以独立地设置和获取 ThreadLocal 的值
int value = threadLocalValue.get();
System.out.println(Thread.currentThread().getName() + " initial value: " + value);
threadLocalValue.set(value + 1);
System.out.println(Thread.currentThread().getName() + " updated value: " + threadLocalValue.get());
};
// 创建多个线程
Thread thread1 = new Thread(task, "Thread-1");
Thread thread2 = new Thread(task, "Thread-2");
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 在主线程中获取 ThreadLocal 的值
System.out.println("Main thread value: " + threadLocalValue.get());
}
}
输出示例
yaml
Thread-1 initial value: 0
Thread-1 updated value: 1
Thread-2 initial value: 0
Thread-2 updated value: 1
Main thread value: 0
ThreadLocal 是 Java 中一个强大的工具,可以在多线程环境下方便地管理线程局部变量。合理使用 ThreadLocal 可以提高程序的安全性和可维护性,它适合用于少量的、频繁访问的状态数据,对于大型数据对象或需要长时间持有的状态,使用其他机制可能更合适。
5. volatile
在 Java 中,volatile 是一个关键字,用于修饰变量,确保变量的可见性和有序性,主要在多线程环境下使用。使用 volatile 的变量可以防止线程间的可见性问题,并且保证一定的有序性,但它不能保证原子性。
1. volatile 的作用
volatile 关键字保证了线程之间的可见性。当一个线程修改了 volatile 变量的值时,其他线程可以立即看到这个修改。
- 可见性问题:在多线程环境中,线程通常会从主内存中读取变量的值到线程的本地缓存中,而不是每次都直接访问主内存。如果一个线程修改了变量的值,其他线程可能无法立即看到这个修改,因为它们可能仍然使用的是本地缓存中的旧值。
- volatile 保证可见性:当一个变量被声明为 volatile 时,它的值会被强制写回主内存,并且其他线程在读取这个变量时,也会直接从主内存中读取,而不是从本地缓存中读取。因此,任何线程对 volatile 变量的修改对其他线程都是可见的。
volatile 还保证了一定的有序性,防止指令重排序。
- 指令重排序:为了优化程序执行速度,CPU 和编译器可能会对指令进行重排序,使程序不按代码编写的顺序严格执行。如果没有同步机制,指令重排序可能会导致线程看到不一致的状态。
- volatile 保证有序性:volatile 保证了变量的读和写操作不会被重排序,也就是说,所有对 volatile 变量的读写操作在执行时的顺序是和程序代码顺序一致的。虽然 volatile 不能完全避免所有类型的重排序,但它能保证读/写操作不会越过 volatile 变量。
2. volatile 的局限性
虽然 volatile 提供了可见性和一定的有序性,但它不能保证原子性。即,多个线程并发地修改 volatile 变量时,可能会出现线程安全问题。比如在 i++ 操作中,虽然读取和写入 i 的操作是可见的,但自增操作本身并不是原子的(读取值、增加值、写入新值是三步操作)。这种情况下,使用 volatile 不能保证线程安全,仍然需要其他同步机制(如 synchronized 或 Atomic 类)来保证原子性。
3. volatile 使用场景
volatile 关键字适用于那些需要保证线程间变量可见性,但不涉及复杂同步和原子操作的场景。典型的使用场景包括:
标志位
使用 volatile 修饰一个布尔标志位,确保线程之间的可见性。常用于控制线程的启动和停止。
java
public class VolatileFlagExample {
private volatile boolean running = true;
public void start() {
new Thread(() -> {
while (running) {
// 线程正在运行
}
System.out.println("Thread stopped.");
}).start();
}
public void stop() {
running = false; // 设置标志位,停止线程
}
public static void main(String[] args) throws InterruptedException {
VolatileFlagExample example = new VolatileFlagExample();
example.start();
Thread.sleep(1000); // 让线程运行一段时间
example.stop(); // 停止线程
}
}
在这个例子中,running 被声明为 volatile,确保一个线程修改了 running 变量后,其他线程能立即看到修改结果,保证线程可以及时停止。
双重检查锁定 (Double-Checked Locking) 实现单例模式
在某些情况下,volatile 可用于双重检查锁定模式,避免指令重排序导致的对象未完全初始化问题。
java
public class Singleton {
private static volatile Singleton instance;
private Singleton() {
// 私有构造函数,防止外部实例化
}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton();
}
}
}
return instance;
}
}
在这个例子中,instance 被声明为 volatile,确保对象在构造时不会出现指令重排序导致的部分初始化问题。
4. volatile 与 synchronized 的比较
特性 | volatile | synchronized |
---|---|---|
作用 | 保证变量的可见性和一定的有序性 | 保证临界区的同步,保证线程间的原子性和可见性 |
性能 | 较轻量,不会引入线程上下文切换和锁开销 | 较重,会导致线程阻塞、上下文切换 |
原子性 | 不保证原子性 | 保证原子性 |
使用场景 | 适用于简单的状态标志、不可变的操作 | 适用于需要原子性操作的复杂同步场景 |
锁机制 | 不使用锁,不会导致线程阻塞 | 使用锁,线程可能会进入阻塞状态 |
5. 示例:多线程可见性问题解决
假设有一个共享变量 count,两个线程同时操作该变量:
java
public class VisibilityProblem {
private static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
count++;
System.out.println("Thread 1: count = " + count);
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
count++;
System.out.println("Thread 2: count = " + count);
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
}
}
在这个例子中,虽然 count 被声明为 volatile,确保了线程间的可见性,但并不能保证自增操作 count++ 的原子性。多个线程对 count 的修改可能会互相覆盖,导致结果不正确。这种情况下,需要使用同步机制(如 synchronized)或 AtomicInteger 来确保原子性。
总结
- volatile 关键字确保了变量的可见性 和一定的有序性 ,但不保证原子性。
- 它适用于简单的状态标志、标记位等需要确保线程间立即可见的场景。
- 在涉及多个线程对同一变量的复杂操作时,通常需要使用 synchronized 或 Atomic 类来确保原子性。
6. synchronized vs ReentrantLock
在 Java 中,synchronized 和 ReentrantLock 都是用于实现线程同步的机制,防止多线程环境下出现数据竞争和线程安全问题。它们的目的相同,但在用法、功能和性能等方面有一些区别。下面是它们的详细对比:
1. synchronized 和 ReentrantLock 的概述
- synchronized:这是 Java 提供的一个内置的同步机制,使用 synchronized 关键字可以实现对代码块或方法的同步。它是 Java 中最常用的同步方式,使用简单且语法直观。
- ReentrantLock:这是 java.util.concurrent.locks 包中的一个类,提供了一种更灵活的锁机制。它是一个可重入锁(Reentrant),提供了比 synchronized 更加丰富的功能,比如支持公平锁、可中断的锁获取、尝试获取锁的功能等。
2. 区别对比
特性/方面 | synchronized | ReentrantLock |
---|---|---|
实现机制 | Java 的内置关键字,隐式锁(JVM 层面实现)。 | 显式锁,需手动加锁和解锁(基于 Java API)。 |
可重入性 | 是可重入锁,允许同一线程多次获取相同的锁。 | 同样是可重入锁,允许同一线程多次加锁,并需多次解锁。 |
锁的释放 | 锁释放是自动的,当同步方法或块执行完毕时自动释放。 | 需要手动释放锁,必须通过 unlock() 方法显式释放。 |
锁的范围 | 可以用于方法级别和代码块级别的同步。 | 只能在代码块中显式调用锁方法,灵活性更高。 |
公平性 | 不支持公平锁,无法保证线程获取锁的顺序。 | 可支持公平锁,使用构造函数可指定是否是公平锁。 |
中断响应 | 不支持响应中断,线程无法在等待锁时被中断。 | 支持响应中断,可以通过 lockInterruptibly() 获取锁。 |
尝试获取锁 | 不支持尝试获取锁。 | 支持尝试获取锁 (tryLock()),可以避免无限等待锁。 |
条件变量 | 不支持条件变量。 | 支持条件变量 (Condition),可以更灵活地管理线程通信。 |
性能 | 在低锁争用时,synchronized 的性能较好,JVM 有锁优化(如偏向锁、轻量级锁)。 | 在高锁争用时,ReentrantLock 提供了更好的控制和功能。 |
锁超时 | 不支持锁的超时机制。 | 支持超时机制,可以通过 tryLock(long time, TimeUnit unit) 尝试在超时时间内获取锁。 |
锁状态监控 | 无法直接获取锁的状态信息。 | 提供 isLocked()、isHeldByCurrentThread() 等方法,可以获取锁的状态。 |
嵌套锁 | 支持嵌套锁,可在嵌套的同步代码块中重复获取同一把锁。 | 也支持嵌套锁,可以在同一个线程中多次获取同一把锁。 |
- ReentrantLock:支持中断响应。可以使用 lockInterruptibly() 方法获取锁,如果当前线程在等待锁时被中断,会抛出InterruptedException,使线程有机会退出等待。
java
try {
lock.lockInterruptibly(); // 支持中断
} catch (InterruptedException e) {
// 响应中断
}
- synchronized:通过 Object 的 wait() 和 notify() 等方法实现线程间的等待通知机制。但这种机制相对简单,且不够灵活。
- ReentrantLock:提供了更灵活的条件变量 Condition,可以通过 newCondition() 方法创建多个条件变量。Condition 可以精确地控制线程的等待和通知机制,比如可以指定某个条件变量上的线程被唤醒,而不是唤醒所有等待线程。
java
Condition condition = lock.newCondition();
lock.lock();
try {
condition.await(); // 当前线程等待
// 被其他线程唤醒后继续执行
} finally {
lock.unlock();
}
// 在其他线程中唤醒等待的线程
lock.lock();
try {
condition.signal(); // 唤醒一个线程
} finally {
lock.unlock();
}
3. 适用场景
- 使用 synchronized:
- 适用于大多数简单的同步场景。
- 不需要手动管理锁的获取和释放,代码简洁。
- 锁竞争不激烈,且不需要锁的高级功能(如公平性、可中断等)。
- 代码已经在较新的 Java 版本上运行,享受 JVM 对 synchronized 的优化。
- 使用 ReentrantLock:
- 需要更灵活的锁控制,比如公平锁、可中断的锁获取、定时锁获取等。
- 需要多个条件变量来控制线程通信。
- 在锁竞争激烈的场景下,希望有更好的控制和优化。
- 需要对锁的状态进行监控,或者需要尝试获取锁而不阻塞线程。
1. 加锁和解锁的原理
1. synchronized 的加锁和解锁原理
1.1 内部机制
Synchronized 是 Java 内置的锁机制,它通过 JVM (Java 虚拟机) 实现,依赖于 对象头 和 Monitor 锁。
- 对象头 (Object Header): 在 Java 中,每个对象在内存中都有一部分称为"对象头",它包含了对象的元数据。在多线程环境下,对象头中的锁信息用于同步。
- Monitor: 每个对象在 JVM 层面都有一个隐式的监视器锁(Monitor),当线程进入 synchronized 代码块或方法时,会尝试获取这个监视器锁。一旦某个线程获得了对象的 Monitor 锁,其他线程必须等待该锁释放后才能进入同步代码。
1.2 加锁过程
当一个线程进入 synchronized 代码块或方法时,JVM 会尝试获取目标对象的 Monitor 锁。具体步骤如下:
- 尝试获取锁 :线程试图通过 CAS (Compare-And-Swap) 操作获取对象头中的锁标志位。
- 如果当前对象头中表示未被加锁,线程会成功获取锁,进入同步块。
- 如果锁已被其他线程持有,线程会阻塞并进入等待队列,直到锁被释放。
- 锁的优化 :为了提高性能,JVM 引入了多种锁优化机制,如偏向锁 、轻量级锁 和 重量级锁 ,根据锁竞争的情况动态调整锁的状态。
- 偏向锁:在没有竞争的情况下,JVM 将偏向某个线程持有锁,避免多次加锁和解锁的开销。
- 轻量级锁:如果有轻度竞争,JVM 会使用 CAS 操作实现快速加锁。
- 重量级锁:当锁竞争激烈时,JVM 会升级为重量级锁,涉及线程阻塞和唤醒操作。
1.3 解锁过程
当线程离开 synchronized 方法或代码块时,JVM 自动释放 Monitor 锁。解锁的过程如下:
- 释放锁:当前线程执行完同步代码块,JVM 会将对象头中的锁标志位清除,表示锁已释放。
- 唤醒等待线程:如果有其他线程在等待该锁,JVM 会将等待队列中的线程唤醒,允许它们尝试获取锁。
1.4 底层实现
synchronized 的加锁和解锁由 JVM 通过字节码指令来实现,主要使用了两条字节码指令:
- monitorenter:线程进入同步块时执行,尝试获取对象的 Monitor 锁。
- monitorexit:线程离开同步块时执行,释放 Monitor 锁。
JVM 中的这些操作依赖于操作系统的原语,如 pthread_mutex_lock 和 pthread_mutex_unlock 来实现操作系统级别的锁管理。
2. ReentrantLock 的加锁和解锁原理
2.1 内部机制
ReentrantLock 是 Java java.util.concurrent.locks 包中的锁实现,它基于 AQS(AbstractQueuedSynchronizer)框架实现。ReentrantLock 提供了比 synchronized 更加灵活的锁控制机制,例如:公平锁、非公平锁、可中断锁等。
2.2 加锁过程
ReentrantLock 的加锁过程依赖于 AQS 的同步队列和 CAS 操作。
- 尝试获取锁 :
- 非公平锁:默认情况下,ReentrantLock 是非公平锁。当前线程会通过 CAS 操作直接尝试获取锁,如果成功,立即进入临界区。如果锁已被持有,它将被添加到同步队列中等待。
- 公平锁:如果使用了公平锁 (ReentrantLock(true)),则线程会先检查队列中是否有其他线程正在等待,如果有,当前线程会进入队列排队,按顺序获取锁。
- 同步队列 :如果锁不可用,线程将进入 AQS 的同步队列排队等待。AQS 维护了一个双向链表,所有等待锁的线程都被加入到这个队列中。AQS 中通过一个 state 变量来表示锁的状态:
- state == 0 表示锁未被持有。
- state > 0 表示锁已被持有。
- 当前线程通过 CAS 操作尝试将 state 置为 1(代表加锁成功),或者置回 0(代表解锁成功)。
2.3 可重入锁机制
ReentrantLock 支持同一线程多次加锁,这是通过 state 变量实现的。每次同一线程获取锁时,state 的值递增;当解锁时,state 递减到 0 时,锁才会真正释放。
2.4 解锁过程
ReentrantLock 的解锁过程也依赖于 AQS 的状态变量和同步队列。
- 释放锁:当前线程调用 unlock() 方法后,会将 state 变量减 1。如果 state 变为 0,表示锁已完全释放。
- 唤醒等待线程:如果有其他线程在同步队列中等待,AQS 会将下一个等待的线程从队列中移出并唤醒,让其尝试获取锁。
2.5 底层实现
ReentrantLock 的底层实现依赖 AQS,AQS 使用了先进先出的等待队列,维护线程的同步状态,所有线程通过 CAS 操作对锁进行竞争。
- CAS (Compare-And-Swap):这是实现无锁并发操作的关键技术,通过硬件指令原子地比较和交换变量值,确保在多线程环境下数据的一致性。
- 同步队列:线程无法获取锁时,会被加入 AQS 的等待队列,AQS 负责管理线程的等待和唤醒操作。
2. 类锁和对象锁
1. synchronized
在 Java 中,synchronized 可以用于对方法或代码块加锁,以保证线程安全。锁可以是对象锁或类锁,它们的加锁范围和作用对象不同:
- 对象锁:锁定的是具体的对象实例,当一个线程获取了该对象的锁,其他线程无法访问该对象的 synchronized 代码块或方法,直到锁被释放。
- 类锁:锁定的是整个类,通常通过 synchronized 修饰静态方法或代码块(以 Class 对象作为锁),这种情况下,无论多少对象实例,只要线程获取了类锁,其他线程若是想要获取类锁的话,则必须等待。
- 类锁和对象锁相互独立,互不影响。
下面是一个包含类锁和对象锁的代码示例:
java
class SyncExample {
// 对象锁:锁定当前实例对象
public synchronized void objectLockMethod() {
System.out.println(Thread.currentThread().getName() + " 获取了对象锁");
try {
Thread.sleep(2000); // 模拟执行任务
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " 释放了对象锁");
}
// 类锁:锁定整个类
public static synchronized void classLockMethod() {
System.out.println(Thread.currentThread().getName() + " 获取了类锁");
try {
Thread.sleep(2000); // 模拟执行任务
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " 释放了类锁");
}
// 使用 synchronized(this) 实现对象锁
public void objectLockBlock() {
synchronized (this) {
System.out.println(Thread.currentThread().getName() + " 在对象锁代码块中");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " 离开对象锁代码块");
}
}
// 使用 synchronized(SyncExample.class) 实现类锁
public void classLockBlock() {
synchronized (SyncExample.class) {
System.out.println(Thread.currentThread().getName() + " 在类锁代码块中");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " 离开类锁代码块");
}
}
}
public class SynchronizedDemo {
public static void main(String[] args) {
SyncExample instance1 = new SyncExample();
SyncExample instance2 = new SyncExample();
// 对象锁示例
new Thread(() -> instance1.objectLockMethod(), "线程1").start();
new Thread(() -> instance2.objectLockMethod(), "线程2").start();
// 类锁示例
new Thread(() -> SyncExample.classLockMethod(), "线程3").start();
new Thread(() -> SyncExample.classLockMethod(), "线程4").start();
}
}
代码分析
- 对象锁 :
- objectLockMethod() 方法是实例方法,使用了 synchronized,因此它是一个对象锁。当线程调用 instance1.objectLockMethod() 时,它会锁定 instance1 对象,其他线程在没有获取 instance1 锁之前无法调用该方法。
- instance2 是 SyncExample 类的另一个实例,它的锁与 instance1 的锁互不影响。因此,线程1 和线程2 分别锁定不同的对象,可以并行执行。
- 类锁 :
- classLockMethod() 是静态方法,使用了 synchronized,因此它是类锁,锁定的是 SyncExample 类的 Class 对象。无论多少实例,都共享这把锁。
- 当一个线程调用 classLockMethod() 时,其他线程必须等待该线程释放类锁后才能继续执行。
2. ReentrantLock
ReentrantLock 是一个独立的锁对象,因此它没有直接的对象锁和类锁之分,但我们可以通过合理的使用方式来实现类似的功能。
1. 对象锁
ReentrantLock 的锁是对象级别的,通常是针对某个共享资源的访问来加锁。每个实例的 ReentrantLock 是独立的,因此可以在对象层面实现锁的粒度。
对象锁示例:
java
class ObjectLockExample {
private final ReentrantLock lock = new ReentrantLock();
public void objectLockMethod() {
lock.lock(); // 加锁
try {
System.out.println(Thread.currentThread().getName() + " 获取了对象锁");
Thread.sleep(2000); // 模拟执行任务
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock(); // 解锁
System.out.println(Thread.currentThread().getName() + " 释放了对象锁");
}
}
}
public class ObjectLockDemo {
public static void main(String[] args) {
ObjectLockExample instance1 = new ObjectLockExample();
ObjectLockExample instance2 = new ObjectLockExample();
new Thread(() -> instance1.objectLockMethod(), "线程1").start();
new Thread(() -> instance2.objectLockMethod(), "线程2").start();
}
}
2. 类锁
ReentrantLock 本身不区分类锁或对象锁的概念,但是我们可以通过使用静态的 ReentrantLock 实现类似于类锁的效果。将 ReentrantLock 定义为 static,这样就可以确保该锁对于所有实例都是唯一的,从而模拟类锁的行为。
类锁示例:
java
class ClassLockExample {
private static final ReentrantLock lock = new ReentrantLock(); // 静态锁,类级别
public void classLockMethod() {
lock.lock(); // 加锁
try {
System.out.println(Thread.currentThread().getName() + " 获取了类锁");
Thread.sleep(2000); // 模拟执行任务
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock(); // 解锁
System.out.println(Thread.currentThread().getName() + " 释放了类锁");
}
}
}
public class ClassLockDemo {
public static void main(String[] args) {
ClassLockExample instance1 = new ClassLockExample();
ClassLockExample instance2 = new ClassLockExample();
new Thread(() -> instance1.classLockMethod(), "线程1").start();
new Thread(() -> instance2.classLockMethod(), "线程2").start();
}
}
3. 总结
- ReentrantLock 本身没有类似于 synchronized 的"对象锁"和"类锁"之分。
- 对象锁:可以通过每个实例持有不同的 ReentrantLock 对象来实现。
- 类锁:可以通过使用 static 静态的 ReentrantLock 对象,使多个实例共享同一把锁,达到类锁的效果。
7. JVM
Java 虚拟机(JVM)是运行 Java 程序的引擎,它是 Java 语言 "一次编译,处处运行" 的核心技术。JVM 的主要任务是将 Java 字节码(Bytecode)解释成机器码并执行,负责内存管理、线程管理、垃圾回收等功能。JVM 主要由以下几个重要的结构组成:
1. 类加载子系统(Class Loader Subsystem)
1. 类加载
JVM 中的类加载是将 Java 字节码(即 .class 文件)动态加载到 JVM 内存中的过程,并为这些类分配内存、解析依赖、执行初始化并为类创建对应的 Java 类对象的步骤。
类加载的整个过程分为以下五个步骤:
- 加载(Loading)
- 验证(Verification)
- 准备(Preparation)
- 解析(Resolution)
- 初始化(Initialization)
有些文档也会将 "使用" 和 "卸载" 视为后续步骤,但这两个阶段并非严格的类加载步骤的一部分。
1. 加载(Loading)
这是类加载过程的第一步,类加载器根据类的全限定名(包括包名)找到对应的 .class 文件并加载到内存中。在加载过程中,JVM 会创建一个 java.lang.Class 对象,用来表示这个类的元数据。加载阶段会通过类加载器完成。
加载过程涉及:
- 通过 双亲委派模型 请求父加载器加载类,若父加载器无法加载,再由当前类加载器加载。
- 查找 .class 文件的位置(通常从文件系统、JAR 包或者网络等路径中查找)。
2. 验证(Verification)
验证阶段是为了确保加载的字节码是符合 JVM 规范的、安全的字节码。JVM 通过一系列的校验来确保字节码不会破坏 JVM 运行的稳定性或安全性。验证主要包括以下几个方面:
- 文件格式验证:检查 .class 文件是否符合 Class 文件格式规范。
- 元数据验证:检查类中的元数据信息是否合理。例如,类是否继承了非法的父类、类的方法签名是否正确等。
- 字节码验证:对类的方法中的字节码进行验证,确保指令序列是合法的、符合逻辑的。
- 符号引用验证:对符号引用进行验证,确保所有引用的类、方法、字段都存在并且可访问。
验证阶段保证了字节码不会对 JVM 运行时环境构成威胁,但它可能会导致 VerifyError 错误。
3. 准备(Preparation)
在准备阶段,JVM 会为类的静态变量分配内存,并将其初始化为默认值(并非编写代码时的赋值)。此阶段主要是分配内存而不执行具体的初始化操作。
类中的静态字段会被赋予默认值:
- 数字类型(如 int, long 等)初始化为 0。
- 布尔类型初始化为 false。
- 引用类型初始化为 null。
例如,假设类中有以下静态变量:
java
public static int a = 10;
public static boolean flag = true;
在准备阶段,a 被初始化为 0,flag 被初始化为 false。真正的值(10 和 true)将在初始化阶段赋值。
4. 解析(Resolution)
解析阶段是将类的符号引用替换为直接引用的过程。符号引用是指在字节码中通过字符串等符号来引用类、字段、方法,解析过程会将这些符号引用解析为内存地址的直接引用。
解析过程会涉及以下几类引用:
- 类或接口解析:将符号引用的类或接口名解析为实际的 Class 对象。
- 字段解析:将符号引用的字段解析为实际的内存位置。
- 方法解析:将符号引用的方法解析为实际的可执行代码地址。
- 接口方法解析:针对接口中的方法引用进行解析。
解析阶段可能会导致 NoSuchFieldError 或 NoSuchMethodError 等错误,如果解析失败,类加载过程也会中断。
5. 初始化(Initialization)
这是类加载的最后一个阶段,也是执行静态变量赋值和静态代码块的阶段。在这个阶段,JVM 会根据程序员的指令对类的静态变量进行显式初始化,并执行静态代码块。
类初始化的具体顺序是:
- 父类静态初始化优先于子类静态初始化。
- 静态变量按照它们在类中的声明顺序进行初始化。
- 执行静态代码块。
假设有如下类:
java
class Parent {
static int a = 10;
static {
System.out.println("Parent static block");
}
}
class Child extends Parent {
static int b = 20;
static {
System.out.println("Child static block");
}
}
当 Child 类被初始化时,JVM 会先执行 Parent 类的静态初始化(包括静态字段和静态块),然后再执行 Child 类的静态初始化。
2. 类加载时机
类的加载不是在 JVM 启动时就加载所有的类,而是在类被首次主动使用时才加载。这种按需加载机制被称为 类的延迟加载(Lazy Loading)。类的主动使用场景包括:
- 创建类的实例(new 操作)。
- 调用类的静态方法。
- 访问类的静态字段。
- 通过反射调用类。
- 初始化子类时,先初始化父类。
- JVM 启动时,指定的启动类(包含 main 方法)自动初始化。
类加载器工作示例
java
public class ClassLoaderExample {
public static void main(String[] args) {
// 获取系统类加载器
ClassLoader classLoader = ClassLoaderExample.class.getClassLoader();
System.out.println("ClassLoader is: " + classLoader);
try {
// 使用类加载器加载一个类
Class<?> loadedClass = classLoader.loadClass("java.util.ArrayList");
System.out.println("Loaded class: " + loadedClass.getName());
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
输出:
kotlin
ClassLoader is: jdk.internal.loader.ClassLoaders$AppClassLoader@xxxxx
Loaded class: java.util.ArrayList
3. 类加载器
-
Bootstrap Class Loader(引导类加载器)
- 这是 JVM 自带的、最顶层的类加载器,负责加载核心类库(如 rt.jar 中的类)。
- 通过 C/C++ 代码实现,不继承自 java.lang.ClassLoader 类。
- 主要负责加载:
- 位于 JAVA_HOME/lib 目录下的类。
- 标准核心库(如 java.lang.、java.util. 等)。
-
Platform Class Loader(平台类加载器)
- 从 Java 9 开始引入,用来加载 Java 平台类库(如 java.sql、java.xml 等)。
- 位于 JAVA_HOME/lib/ext 目录及 platform 模块中。
- 是引导类加载器与应用类加载器之间的一个中间层级,用于支持多种库和平台相关的类。
-
Application Class Loader(应用类加载器)
- 负责加载用户类路径(classpath)下的类,包括用户自定义的类和第三方库。
- 使用 java.lang.ClassLoader 的默认实现,通常是系统类加载器。
- 加载的类包括应用程序所需的所有 .jar 文件和 .class 文件。
4. 双亲委派模型的工作机制
JVM 类加载器体系遵循 双亲委派模型,这是类加载器设计中的核心机制。它的工作原理是:当一个类加载器收到类加载请求时,它会首先将该请求委托给父加载器去处理,只有当父加载器无法加载时,才由当前加载器自己去加载。这种模型有以下特点:
- 安全性:防止核心类库(如 java.lang.String)被自定义类加载器加载,保证核心类库的唯一性和安全性。
- 类的唯一性:同一个类在 JVM 内存中只会被加载一次(除非使用不同的类加载器),保证了类的唯一性。
- 防止重复加载:通过父加载器优先机制,避免不同类加载器重复加载同一个类。
2. 运行时数据区(Runtime Data Areas)
JVM 在运行时管理的内存区域,可以分为以下几个部分:
1. 方法区(Method Area)
在 JVM 中,方法区(Method Area)是用于存储类结构信息的内存区域。它保存了每个类的元数据信息,例如类的名称、访问修饰符、字段、方法、常量池等。方法区是 JVM 规范中的一部分,是堆外内存的一部分,属于非堆区域。它与堆相对独立,主要存储与类和常量相关的数据。它是线程共享的区域。
特点
- 存储内容 :
- 类元数据:每个类的结构信息,包括类名、父类名、访问修饰符、接口信息等。
- 字段和方法信息:包括字段的名称、类型和方法的名称、签名等。
- 常量池(Constant Pool):存储编译时生成的字面量和符号引用(如字符串字面量、方法和字段的符号引用)。
- 静态变量:存储类的静态字段。
- 类的静态方法和代码:类中的字节码和方法相关的信息也存储在这里。
- 生命周期 :
- 方法区的生命周期与 JVM 一致,JVM 运行时,方法区会随着类的加载不断扩展,当 JVM 关闭时,方法区也会被销毁。
- 大小 :
- 方法区可以设置大小,但如果装载的类太多,方法区可能会导致内存不足(出现 OutOfMemoryError: Metaspace 错误)。从 Java 8 开始,方法区被替换为 元空间(Metaspace),并且其内存存储在本地内存(Native Memory)中,而不是 JVM 的堆中。
演变
在 Java 7 及之前,方法区的实现被称为 永久代(Permanent Generation,PermGen),它是 JVM 堆的一部分。永久代有以下特点:
- 固定大小:PermGen 有一个固定的大小,可以通过 JVM 参数(如 -XX:PermSize 和 -XX:MaxPermSize)来设置。
- 内存管理问题:因为 PermGen 的内存是有限的,如果应用程序动态生成大量类或者使用了大量字符串常量,容易导致内存不足的错误,比如 OutOfMemoryError: PermGen space。
从 Java 8 开始,永久代被移除,取而代之的是 元空间(Metaspace)。元空间的存储改为使用本地内存而不是 JVM 堆内存,有以下显著变化:
- 动态扩展:元空间的大小不再像 PermGen 那样受限,可以动态扩展。默认情况下,元空间的大小是由系统内存决定的。
- 配置灵活性 :可以通过以下 JVM 参数来控制元空间的行为:
- -XX:MetaspaceSize:设置元空间的初始大小。当元空间使用达到这个值时,JVM 会触发垃圾回收来清理不再使用的类。
- -XX:MaxMetaspaceSize:设置元空间的最大大小。元空间可以动态扩展,但如果达到这个限制,就会抛出 OutOfMemoryError: Metaspace 错误。
- -XX:CompressedClassSpaceSize:指定类指针的压缩空间大小。通常默认大小为 1GB,适用于 64 位 JVM。
- -XX:+UseCompressedClassPointers:启用压缩类指针,节省内存空间。
元空间的引入使得类加载和卸载更加高效,避免了 PermGen 的内存管理问题。
作用
方法区作为 JVM 中非常重要的内存区域,主要用于类的加载、运行时常量池的维护和类的相关信息存储。其作用包括:
- 支持类加载机制:JVM 加载的每一个类,其相关的元数据都会存储在方法区中。
- 支持常量的引用与解析:运行时常量池中的数据,如方法引用、字段引用、字符串常量,存储在方法区内。
- 支持静态变量存储:方法区中还存储类的静态变量,所有类的静态变量在内存中的唯一副本存放于方法区。
- 垃圾回收的影响:尽管方法区不属于堆内存,但 JVM 仍然对方法区的内存进行管理,并且可以对无用的类信息进行垃圾回收。
2. 堆(Heap)
在 JVM(Java 虚拟机)中,堆(Heap)是用于存储所有 Java 对象和数组的主要内存区域。堆是 JVM 运行时数据区域中最大的一部分,所有通过 new 关键字创建的对象都会被分配在堆内存中。堆内存的管理和分配对于 Java 程序的性能有着直接的影响。
- 对象的生命周期由垃圾回收机制决定,堆上的对象不再被引用时,JVM 的垃圾回收器会自动回收这些对象所占的内存。
- 堆的生命周期与 JVM 相同,堆内存随着 JVM 启动而创建,并在 JVM 关闭时销毁。
- JVM 堆是所有线程共享的。每个线程都可以访问堆中的对象,多个线程也可能同时操作堆中的相同对象。
- JVM 堆由垃圾回收器(Garbage Collector, GC)进行管理。GC 负责清理无用对象、回收内存空间,从而使得开发人员不需要手动管理内存。
1. 内存结构
堆内存通常会被划分为多个区域,用于更高效的内存管理和垃圾回收策略。典型的划分方式如下:
- 年轻代(Young Generation) :
- 年轻代主要用于存放新创建的对象 ,大多数的对象在这里分配内存。年轻代的回收频率较高,垃圾回收通常使用的是Minor GC。
- 年轻代又分为三个子区域:
- Eden 区:对象在首次创建时被分配到 Eden 区。当 Eden 区满时,触发 Minor GC。
- 两个 Survivor 区(S0 和 S1):当对象在 Eden 区存活过一次 GC 后,会被移动到 Survivor 区。两个 Survivor 区交替使用,即 S0 和 S1 中只有一个区会被使用,另一个为空,GC 时对象会从一个 Survivor 区复制到另一个区。
- 老年代(Old Generation) :
- 老年代存储的是生命周期较长、在年轻代中经历过多次 GC 仍未被回收的对象。相比于年轻代,老年代的垃圾回收频率较低,但执行的是Full GC,且 Full GC 的开销要比 Minor GC 大得多。
2. 堆的大小设置
JVM 的堆内存大小可以通过以下 JVM 参数来配置:
- -Xms:设置堆的初始大小。
- -Xmx:设置堆的最大大小。
- -XX:NewRatio:设置年轻代与老年代的比例。
- -XX:SurvivorRatio:设置 Eden 区与 Survivor 区的比例。
- -XX:MaxMetaspaceSize:设置元空间的最大大小(Java 8 及之后)。
3. 堆内存分配和回收的过程
- 对象创建 :
- 当程序中使用 new 关键字创建对象时,内存首先分配在 Eden 区。
- Minor GC :
- 当 Eden 区满了,JVM 触发 Minor GC,回收不再使用的对象。存活下来的对象会被移到 Survivor 区(S0 或 S1)。
- 当对象在 Survivor 区存活多次后(通常为 15 次 GC,但可以通过 -XX:MaxTenuringThreshold 调整),这些对象会被移动到老年代。
- Full GC :
- 当老年代被填满时,会触发 Full GC。Full GC 是对整个堆(年轻代和老年代)进行垃圾回收,回收那些不再被引用的对象。这是一个相对耗时的操作,可能会导致应用暂停(即STW:Stop-The-World),所以 Full GC 频率需要尽量减少。
3. 虚拟机栈(JVM Stacks)
在 JVM(Java Virtual Machine)中,虚拟机栈(Java Virtual Machine Stack,简称 JVM 栈)是每个线程在执行 Java 程序时创建的私有内存区域。它负责管理 Java 方法的执行,存储方法调用时的局部变量、操作数栈、动态链接、方法出口等。每个线程都有自己独立的虚拟机栈,因此线程之间的栈空间是不共享的。
1. 虚拟机栈的作用
虚拟机栈是用来保存线程的 栈帧(Stack Frame)的,每个方法在执行时都会创建一个栈帧。每个线程的虚拟机栈由多个 栈帧 组成,一个栈帧对应一个正在执行的 Java 方法。当一个方法被调用时,JVM 会将栈帧压入当前线程的虚拟机栈。当方法执行完成后,对应的栈帧会从栈中弹出。
栈帧 主要包括:
- 局部变量表:存储方法的局部变量,包括方法参数和在方法体内定义的变量。是一组用于存放方法参数和局部变量的数组,以槽(Slot)为单位存储。一个 Slot 可以存储一个 int、float 等基本类型,或者引用类型的变量。long 和 double 类型占用两个 Slot。
- 操作数栈:用于方法执行中的各种临时数据存储,是计算过程中的"工作区"。在方法执行过程中,用来存放中间计算的结果和参与运算的操作数。类似于一个后进先出的栈。操作数栈的大小是在编译期间确定的,每个栈帧的操作数栈容量也由编译器确定。
- 动态链接:用于支持方法调用时的动态连接,每个栈帧中包含了一个指向当前方法所属的类的运行时常量池的引用。这个引用用于实现 方法调用 时的动态连接,具体来说是将常量池中的符号引用转换为方法的实际调用地址。
- 方法返回地址:用来存放方法执行完毕后需要返回的地址,以便于返回到上一个栈帧继续执行。
2. 特点
- 线程私有:每个线程在创建时,都会创建一个虚拟机栈。栈是线程私有的,不会在线程之间共享。
- 栈的生命周期与线程一致:线程创建时分配虚拟机栈,线程结束时虚拟机栈也会被销毁。
- 栈的大小:虚拟机栈的大小可以通过 JVM 参数设置,通常使用 -Xss 参数设置每个线程的栈大小(例如:-Xss1m 设置每个线程的栈大小为 1 MB)。栈的大小影响到线程的深度(即一个线程可以调用多少次方法,特别是递归调用的深度)。
- StackOverflowError:当线程的调用深度超过虚拟机栈的限制时,会抛出该异常。通常是因为方法调用过深(例如递归方法没有合适的退出条件)。因此需要合理设置虚拟机栈的大小。
4. 程序计数器(PC Register)
在 JVM 中,程序计数器(Program Counter Register,简称 PC 寄存器)是每个线程私有的一个小内存区域,它记录了当前线程执行的字节码指令的地址。由于 JVM 是多线程的,每个线程都需要独立执行自己的指令,因此每个线程都有一个独立的程序计数器。它的主要作用是在线程切换时能够恢复到正确的执行位置。
1. 程序计数器的作用
- 记录当前线程执行的字节码指令地址 :程序计数器用来存放当前线程正在执行的 字节码指令的地址,每当一条字节码指令被执行完,程序计数器会更新为下一条即将执行的指令地址。
- 支持线程切换 :JVM 采用 时间片轮转 的方式进行线程切换。为了保证线程恢复后可以继续正确执行代码,每个线程都有自己的程序计数器。当线程切换时,当前线程的执行状态(包括程序计数器的值)会被保存,切换回来时可以从该位置继续执行。
- 处理 Java 和 Native 方法 :对于正在执行 Java 方法 的线程,程序计数器存储的是正在执行的字节码指令地址;而对于 本地方法(Native Method),程序计数器则为空(undefined),因为本地方法不通过字节码执行。
2. 程序计数器的特点
- 线程私有:每个线程都有自己独立的程序计数器,彼此之间不共享。
- 生命周期与线程一致:程序计数器的生命周期与线程相同,线程创建时分配,线程结束时销毁。
- 唯一一个不会出现 OutOfMemoryError 的区域:与其他内存区域(如堆、方法区、栈)不同,程序计数器是一个非常小的区域,它不会发生内存溢出。
3. 程序计数器的工作机制
- 在执行 Java 方法时:程序计数器保存当前线程正在执行的字节码指令的地址。例如,当一个线程正在执行某个字节码指令时,程序计数器记录该指令的地址;当指令执行完后,程序计数器会自动更新为下一条指令的地址。
- 在执行 Native 方法时:当一个线程执行本地方法时,程序计数器处于未定义状态。这是因为本地方法不通过字节码执行,程序计数器在这种情况下不会保存任何字节码指令地址。
4. 程序计数器的作用举例
设想下面的一个 Java 代码示例:
java
public class ProgramCounterExample {
public static void main(String[] args) {
int a = 10;
int b = 20;
int result = a + b;
System.out.println("Result: " + result);
}
}
- 当 main 方法开始执行时,程序计数器记录的是 main 方法的第一条字节码指令的地址。
- 随着每一条指令的执行(如 int a = 10;、int b = 20;、int result = a + b;),程序计数器会更新为下一条指令的位置。
- 当执行 System.out.println("Result: " + result); 时,程序计数器记录的是这一条指令的地址。
- 如果有线程切换发生,程序计数器会保存当前字节码指令的位置,切换回来时可以继续执行。
5. 程序计数器的意义
- 线程隔离:程序计数器为每个线程提供了独立的指令记录机制,这对于多线程并发执行至关重要,确保每个线程能够独立执行自己的代码,而不影响其他线程。
- 线程调度的支持:程序计数器为线程调度提供了支持,当发生线程切换时,程序计数器记录了线程执行的具体位置,能够在线程恢复时继续从正确的位置执行。
5. 本地方法栈(Native Method Stack)
在 JVM 中,本地方法栈(Native Method Stack)是为执行 本地方法(Native Methods)提供支持的内存区域。本地方法是使用其他编程语言(如 C 或 C++)编写的代码,这些代码可以直接与底层操作系统或硬件进行交互,通常通过 JNI(Java Native Interface)调用。
1. 特点:
- 本地方法栈(Native Method Stack)用于支持本地方法的执行:存放 Native 方法调用时的局部变量、操作数栈、返回地址等。
- 与 Java 虚拟机栈类似,但专注于本地方法,例如通过 JNI 调用 C、C++ 等非 Java 代码。
- 线程私有:每个线程都有自己的本地方法栈。
- 栈溢出异常:可能抛出 StackOverflowError 或 OutOfMemoryError。
- 可选的存在:并非所有 JVM 实现都支持本地方法栈,一些 JVM 实现可能将本地方法栈与 JVM 栈合并。
- 生命周期与线程相同:本地方法栈的生命周期与线程一致,在线程创建时分配,线程结束时销毁。
2. 作用
本地方法栈与 JVM 虚拟机栈类似,但它为调用本地方法服务。它主要负责管理本地方法的调用状态和执行。其作用包括:
- 存储本地方法执行的上下文:在执行本地方法时,本地方法栈存储相关的局部变量和执行状态。
- 与 JNI 一起工作:Java 本地接口(JNI)用于调用非 Java 代码,本地方法栈在这其中负责管理与本地代码交互的细节。
- 桥接底层系统资源:通过本地方法,Java 程序可以调用操作系统提供的底层资源(如文件系统、网络设备、图形界面等)。
3. 本地方法栈与 JVM 栈的区别
- JVM 栈 用于管理 Java 方法的执行,每个 Java 方法调用时都会在 JVM 栈中生成一个栈帧来存储局部变量和操作数栈等信息。
- 本地方法栈 用于管理本地方法的执行,执行本地方法时,本地方法栈记录本地方法的执行状态和局部变量。对于调用本地代码的场景,JVM 栈和本地方法栈会配合使用。
4. 本地方法栈异常
本地方法栈可能会遇到以下异常:
- StackOverflowError:当本地方法栈的调用层次过深,栈空间不足时,会抛出此异常。这个与 JVM 栈的 StackOverflowError 类似,通常出现在递归调用或大量本地方法调用的情况下。
- OutOfMemoryError:如果本地方法栈无法申请到足够的内存,JVM 会抛出 OutOfMemoryError。这种情况通常是在栈的初始大小设置过小或系统内存不足时发生。
5. 本地方法栈的工作机制
本地方法栈的工作流程如下:
- 当 JVM 调用 Java 方法时,会使用 JVM 栈进行栈帧管理;
- 当 Java 方法调用本地方法时,JVM 切换到本地方法栈,将控制权交给本地方法栈,负责管理本地方法的调用状态。调用结束后,返回到虚拟机栈,继续执行 Java 方法。
6. 本地方法的调用示例
Java 可以通过 JNI 调用本地方法,以下是一个简单的本地方法调用示例:
java
public class NativeMethodExample {
// 声明一个本地方法
public native void nativeMethod();
static {
// 加载本地方法库
System.loadLibrary("NativeLib");
}
public static void main(String[] args) {
NativeMethodExample example = new NativeMethodExample();
example.nativeMethod(); // 调用本地方法
}
}
在上述代码中,nativeMethod() 是一个本地方法,通过 System.loadLibrary() 加载与之对应的本地方法库(例如,C 或 C++ 编写的动态链接库)。当 nativeMethod() 被调用时,JVM 将切换到本地方法栈进行执行。
7. JVM 中的本地方法栈管理
JVM 提供了一些参数用于调整本地方法栈的大小,尽管不同的 JVM 实现可能略有不同。通过合理的参数调整,可以避免内存不足或栈溢出异常。
6. 垃圾回收算法和垃圾回收器
在 JVM 中,垃圾回收(Garbage Collection,GC)是自动管理内存的机制,旨在回收不再使用的对象,释放内存资源。Java 提供了多种垃圾回收算法和垃圾回收器,以适应不同的应用场景和需求。
垃圾回收算法
- 标记-清除算法(Mark-Sweep)
- 原理 :该算法分为两个阶段:
- 标记阶段:从根对象开始,遍历所有可达对象并标记它们。
- 清除阶段:扫描整个堆,回收未被标记的对象。
- 优点:简单有效,能够处理对象的循环引用。
- 缺点:清理后会产生内存碎片,可能导致后续的内存分配失败。
- 原理 :该算法分为两个阶段:
- 标记-整理算法(Mark-Compact)
- 原理:与标记-清除算法类似,但在清除阶段会整理存活对象,将它们移动到堆的一端,并更新引用地址。
- 优点:避免了内存碎片问题,适合老年代。
- 缺点:移动对象需要更新引用,开销较大。
- 复制算法(Copying)
- 原理:将存活的对象从一块内存区域(源区)复制到另一块内存区域(目标区),清理源区。
- 优点:高效地回收内存,没有内存碎片。
- 缺点:需要分配两块内存,适用于年轻代,且内存利用率较低(通常只用到一半)。
- 分代收集算法(Generational Collection)
- 原理:基于对象生命周期的特点,将堆分为年轻代和老年代。年轻代中的对象经过多次垃圾回收后,晋升到老年代。
- 优点:提高了垃圾回收的效率,因为大多数对象都是短生命周期的。
- 缺点:需要处理对象晋升的逻辑,复杂度略高。
垃圾回收器
JVM 提供了多种垃圾回收器,适用于不同的场景和需求:
- Serial GC
- 垃圾回收算法:标记-清除(Mark-Sweep) 和 复制算法(Copying)
- 原理 :
- 对于年轻代使用 复制算法,将存活对象从 Eden 区复制到 Survivor 区。
- 对于老年代使用 标记-清除算法,通过标记不再使用的对象并清除它们。
- 类型:单线程的垃圾回收器。
- 特点:在进行 GC 时,会暂停所有应用线程(STW,Stop-The-World),适合单处理器系统。
- 适用场景:适用于小型应用或内存较小的应用。
- Parallel GC
- 垃圾回收算法:标记-清除-整理算法(Mark-Compact) 和 复制算法(Copying)
- 原理 :
- 对年轻代使用 复制算法,并行地回收对象,将存活的对象复制到 Survivor 区。
- 对老年代使用 标记-整理算法,在标记完成后整理堆内存,避免内存碎片。
- 类型:多线程的垃圾回收器。
- 特点:通过多线程并行进行垃圾回收,适合多核处理器系统。可以通过参数调整并行度。
- 适用场景:适用于高吞吐量的应用。
- CMS(Concurrent Mark-Sweep)GC
- 垃圾回收算法:标记-清除算法(Mark-Sweep)
- 原理 :
- CMS 是针对老年代的回收器,分为四个阶段:初始标记(STW) 、并发标记 、重新标记(STW)和并发清除。
- 年轻代使用 复制算法 进行回收。
- 类型:并发标记清除垃圾回收器。
- 特点:在垃圾回收过程中,应用线程仍然可以运行。分为标记阶段和清除阶段,后续执行部分工作与应用线程并发进行。
- 适用场景:适合对响应时间敏感的应用,但可能导致内存碎片。
- G1(Garbage-First)GC
- 垃圾回收算法:标记-整理算法(Mark-Compact) 和 复制算法(Copying)
- 原理 :
- G1 将堆分为多个区域(Region),每个区域可以存放年轻代或老年代对象。
- 对年轻代使用 复制算法。
- 对老年代使用 标记-整理算法,优先回收垃圾最多的区域。
- 在 Full GC 时,G1 使用全局的 标记-整理算法。
- 类型:分代垃圾回收器。
- 特点:将堆划分为多个区域(Region),优先回收垃圾最多的区域。适用于大内存应用,支持并行和并发回收。
- 适用场景:适合低延迟和大内存的应用,能够提供可预测的暂停时间。
- ZGC(Z Garbage Collector)
- 垃圾回收算法:标记-整理算法(Mark-Compact) 和 并发回收
- 原理 :
- ZGC 的垃圾回收过程分为:并发标记 、并发重新定位 和并发清理。
- ZGC 的最大特点是大部分工作与应用线程并发执行,最大 GC 暂停时间一般不会超过 10 毫秒。
- 它主要使用标记-整理算法,通过颜色指针(Colored Pointers)来标记对象的状态,进行内存整理时对象会重新定位到新的内存区域。
- 类型:低延迟垃圾回收器。
- 特点:支持大堆和并发回收,极大减少了 GC 暂停时间(通常不超过 10ms)。
- 适用场景:适合对延迟非常敏感的大型应用。
- Shenandoah GC
- 垃圾回收算法:标记-整理算法(Mark-Compact) 和 并发回收
- 原理 :
- Shenandoah 和 ZGC 类似,垃圾回收的主要阶段与应用线程并发执行。
- 与 G1 相比,Shenandoah 也将堆划分为多个区域,但它的目的是在最短的时间内回收任何区域的内存。
- Shenandoah 使用的是并发标记 和并发整理算法。
- 类型:低延迟垃圾回收器,类似 ZGC。
- 特点:通过并发回收和混合空间管理,旨在降低 GC 暂停时间。
- 适用场景:适用于需要低延迟的应用,尤其是大堆内存。
垃圾回收器的选择
选择合适的垃圾回收器可以显著提高 Java 应用程序的性能。以下是一些常用的 JVM 参数,用于选择和配置垃圾回收器:
- -XX:+UseSerialGC:使用 Serial GC。
- -XX:+UseParallelGC:使用 Parallel GC。
- -XX:+UseConcMarkSweepGC:使用 CMS GC。
- -XX:+UseG1GC:使用 G1 GC。
- -XX:+UseZGC:使用 ZGC。
- -XX:+UseShenandoahGC:使用 Shenandoah GC。
7. GCROOT
在 Java 中,GC Root(垃圾回收根对象)是垃圾回收器进行内存管理的重要起点,任何从 GC Root 可达的对象都不会被垃圾回收。Java 使用 可达性分析算法(Reachability Analysis Algorithm)来确定哪些对象可以被回收,而可达性的判断始于 GC Roots。
以下是 Java 中哪些对象可以充当 GC Root:
1. 虚拟机栈中的引用对象
- 描述:方法执行时,局部变量表中的所有引用类型变量(局部变量、方法参数等)都可以作为 GC Root。
- 实例:当方法调用时,局部变量表中的对象引用始终可达,JVM 不会回收这些对象,直到方法结束后局部变量表被销毁。
- 例子:
java
public void exampleMethod() {
Object obj = new Object(); // 局部变量 obj 是 GC Root
// do something...
}
2. 方法区中的静态变量
- 描述:类的静态属性(static 修饰的变量)会随着类的加载进入方法区,并且静态变量会一直存在于内存中,直到类被卸载。因此,所有的静态变量也是 GC Root。
- 例子:
java
public class ExampleClass {
private static Object staticObject = new Object(); // staticObject 是 GC Root
}
3. 方法区中的常量
- 描述:常量(如 final 修饰的常量)在类加载时就已经被初始化,它们存在于方法区中,可以作为 GC Root。
- 例子:
java
public class ExampleClass {
private static final Object constantObject = new Object(); // constantObject 是 GC Root
}
4. 本地方法栈中的 JNI 引用
- 描述:JNI(Java Native Interface) 是 Java 调用本地(非 Java)代码的机制。在 JNI 中使用的引用也是 GC Root。JVM 通过本地方法栈来管理 JNI 的本地引用。
- 例子:当 Java 调用 C/C++ 代码时,通过 JNI 持有的对象引用。
5. 活跃的线程对象
- 描述:所有当前正在执行的线程对象也是 GC Root,线程不被垃圾回收器回收,直到它们运行结束。
- 例子:
java
Thread thread = new Thread(() -> {
Object obj = new Object(); // thread 是 GC Root,持有 obj 的引用
// do something...
});
thread.start();
6. Java 虚拟机内部的 GC Root
- 描述:JVM 内部的一些系统级对象,如类加载器(ClassLoader)等,也可以作为 GC Root,通常这些对象与应用的执行息息相关。
7. JMX Beans、JVMTI 中的注册对象
- 描述:通过 JMX(Java Management Extensions)管理的 MBeans 对象,以及通过 JVMTI(Java Virtual Machine Tool Interface)注册的对象,也可以作为 GC Root,因为 JVM 需要对这些对象进行管理和监控。
8. 判断对象是否可以被回收的方法
1. 引用计数法(Reference Counting)
原理:
- 每个对象都维护一个引用计数器,当有一个地方引用该对象时,计数器加一;当引用失效时,计数器减一。
- 当计数器的值为零时,说明该对象不再被引用,系统就会认为它是垃圾,可以被回收。
优点:
- 实现简单,效率较高,能快速判断对象是否可以被回收。
缺点:
- 循环引用问题:如果两个对象互相引用(形成循环依赖),它们的引用计数器不会为零,即使它们都无法被访问,引用计数法也无法回收它们。
2. 可达性分析法(Reachability Analysis)
原理:
- JVM 采用可达性分析算法 来判断对象是否可以被回收。这个方法从一组称为 GC Roots 的根对象开始,沿着引用链进行遍历,能够到达的对象被认为是"存活"的,无法到达的对象被认为是不可达的,可以被回收。
- 如果某个对象在从 GC Root 的引用路径上是不可达的,说明它可以被回收。
优点:
- 没有循环引用问题:因为是通过可达性分析来判断对象是否可以回收,循环引用不会影响对象的回收。
- 更加准确,现代垃圾回收器大多基于这种方法。
总结
在 JVM 中,判断对象是否可以被回收主要通过两种方法:
- 引用计数法:效率高但无法处理循环引用问题,现代 JVM 很少单独使用此方法。
- 可达性分析法:从 GC Root 进行可达性分析,是现代 JVM 垃圾回收算法的基础,能够有效处理循环引用问题。
现代 JVM 大多数采用 可达性分析法,因为它更加高效和准确。
9. 引用类型
1. 强引用(Strong Reference)
- 这是最常见的引用类型。通过正常的赋值创建的引用,只要有强引用指向一个对象,垃圾回收器就不会回收该对象。
java
Object obj = new Object(); // obj 是一个强引用
特点:
- 垃圾回收:强引用所指向的对象在任何情况下都不会被垃圾回收。只有当引用失效时,垃圾回收器才会考虑回收这个对象。
- 如果一个对象被强引用所引用,即使内存不足,JVM 也不会回收它。
适用场景:
- 强引用适用于对必须存在的对象进行引用,比如大多数普通对象的引用方式。
2. 软引用(Soft Reference)
- 用于描述一些还有用但并非必需的对象,内存不足时会回收这些对象。软引用可以通过 SoftReference 类来实现。
java
SoftReference<Object> softRef = new SoftReference<>(new Object());
特点:
- 垃圾回收:当 JVM 发现内存不足时,会回收软引用指向的对象。只有在 JVM 面临内存不足时,才会回收软引用关联的对象,避免内存溢出(OOM)。
- 软引用常用于实现内存敏感的缓存。例如缓存中存储的数据在内存充足时保留,当内存不足时进行回收。
适用场景:
- 适用于缓存设计。当系统内存充足时,缓存数据不会被回收,但在内存不足时,缓存数据会被回收以避免 OOM。
3. 弱引用(Weak Reference)
- 用于描述非必需对象,GC 扫描时一旦发现只有弱引用指向的对象就会回收。弱引用可以通过 WeakReference 类来实现。它用于描述非必须的对象。
java
WeakReference<Object> weakRef = new WeakReference<>(new Object());
特点:
- 垃圾回收:无论内存是否充足,垃圾回收器在进行可达性分析时,只要发现对象只被弱引用所引用,便会立即回收该对象。
- 弱引用通常用于实现规范化映射(canonicalizing mappings),例如 WeakHashMap,用来处理缓存或对象池中的弱引用对象。
适用场景:
- 适用于那些希望对象在不被强引用时可以随时被回收的场景,比如弱引用缓存,避免对象长时间占用内存。
4. 虚引用(Phantom Reference)
- 最弱的引用,不能通过虚引用获取对象实例,唯一的作用是能在对象被回收时收到系统通知。虚引用可以通过 PhantomReference 类来实现。
java
PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), referenceQueue);
特点:
- 垃圾回收:虚引用的存在主要用于跟踪对象的生命周期,它不能阻止对象被回收。与虚引用关联的对象在垃圾回收时被标记为可回收,回收之前会将虚引用对象加入到一个 ReferenceQueue 队列中。
- 虚引用与 ReferenceQueue 联合使用,主要用于在对象被回收时进行一些后续处理,比如资源释放。
适用场景:
- 虚引用适用于管理直接内存的回收、监控对象生命周期或执行对象销毁前的清理工作。例如,当使用 DirectByteBuffer 时,可以通过虚引用在对象被回收前执行内存释放操作。
四种引用的垃圾回收触发顺序
当 JVM 进行垃圾回收时,四种引用的回收顺序如下:
- 虚引用:最弱,最早被回收。
- 弱引用:当下次 GC 扫描时回收。
- 软引用:只有在内存不足时才会被回收。
- 强引用:永远不会被回收,除非没有引用指向它。
总结
引用类型 | 类名 | 回收时机 | 使用场景 |
---|---|---|---|
强引用 | 无 | 永不回收 | 普通对象引用 |
软引用 | SoftReference | 内存不足时回收 | 缓存实现 |
弱引用 | WeakReference | GC 扫描时立即回收 | 弱引用缓存、规范化映射 |
虚引用 | PhantomReference | 对象被 GC 回收时,将其添加到 ReferenceQueue 队列中 | 监控对象生命周期、资源释放 |
这四种引用类型为开发者提供了不同的内存管理选项,使得在内存敏感的场景下可以灵活控制对象的生命周期。
10. Younggc 为什么比 fullgc 快很多
在 JVM 中,Young GC (Minor GC)比 Full GC 快很多,主要是因为两者在内存区域 、回收对象的数量 、算法复杂度等方面存在本质上的区别。
1. 内存区域的区别
- Young GC(Minor GC) :只发生在新生代(Young Generation) 。新生代分为三个区域:Eden 区 和两个Survivor 区(S0 和 S1)。当 Eden 区填满时,JVM 会触发 Young GC,回收新生代的短命对象(大多数对象在创建后很快就会被回收)。新生代的区域较小,通常只包含一些存活时间较短的对象,所以回收的时间较短。
- Full GC :涉及整个堆内存 ,包括新生代 、老年代 (Old Generation)以及永久代(Metaspace)。Full GC 会回收整个堆中的所有对象,包括长寿命的对象,这些对象通常分布在老年代。老年代区域较大,回收时需要扫描和处理的对象更多,涉及到的区域更广。
2. 回收对象的数量和对象生命周期
- Young GC :新生代主要存储短生命周期的对象,大多数对象在进入 Eden 区后很快就会变成垃圾。由于新生代的大部分对象都可以很快被回收,存活对象较少,因此 Young GC 回收速度较快。
- Full GC :在 Full GC 中,除了新生代的对象外,老年代中的长生命周期对象也需要被回收。由于老年代中存放了很多长期存活的对象(甚至包括存活了多个 GC 周期的对象),需要花费更多时间去检查这些对象是否可以被回收。老年代的对象比较多、比较稳定,垃圾回收的复杂度也更高。
3. 垃圾回收算法的复杂度
- Young GC :新生代通常采用复制算法(Copying Algorithm),即将存活的对象从 Eden 区和一个 Survivor 区复制到另一个 Survivor 区。复制算法的特点是简单、高效,只需要扫描存活的对象,未存活的对象直接被清除,因此回收速度很快。
- Full GC :老年代通常采用的是标记-清除算法 (Mark-Sweep)或标记-整理算法 (Mark-Compact)。这些算法首先需要标记出所有的存活对象,然后再执行清除或整理。相比复制算法,标记-清除和标记-整理算法的执行过程复杂得多,尤其是标记和整理阶段会导致 Full GC 变慢。
4. GC 频率和触发条件
- Young GC:新生代空间较小,Eden 区填满时频繁触发 Young GC,但因为新生代回收的是短命对象,并且区域小,所以尽管频繁发生,单次执行的时间较短。
- Full GC:Full GC 触发的条件更为复杂,通常是在老年代空间不足时触发。Full GC 的开销大,JVM 会尽量避免频繁进行 Full GC。
5. GC 停顿时间
- Young GC:停顿时间较短,因为回收的新生代区域较小,存活的对象少,复制算法效率高。
- Full GC:停顿时间较长,回收整个堆内存,尤其是涉及到标记和整理阶段,老年代中对象的数量和生命周期都较长,导致停顿时间长。
6. 内存整理(Compaction)
- Young GC:因为采用的是复制算法,在 Young GC 中不存在内存碎片的问题。新生代中没有使用的内存会被连续的清理和整理。
- Full GC:老年代在标记-清除算法后可能会产生内存碎片。如果老年代存在内存碎片,则需要进行内存整理(Compaction),这会导致回收耗时增加。内存碎片会影响大对象的分配,因为即使有足够的总内存,但由于碎片化,可能没有足够连续的空间来存储大对象。
总结
- Young GC 快的原因 :
- 新生代空间较小,回收区域小。
- 新生代对象生命周期短,大部分是短命对象,容易被回收。
- 使用简单高效的复制算法。
- 回收过程中需要处理的存活对象较少。
- Full GC 慢的原因 :
- 涉及整个堆(包括新生代、老年代、元空间)。
- 老年代的对象较多且存活时间长,回收时需要更多的标记和检查工作。
- 使用的标记-清除或标记-整理算法更复杂。
- 可能涉及内存整理,处理内存碎片化问题。
因此,Young GC 比 Full GC 快很多,而 Full GC 的频率应该尽量避免,因为它对应用的性能影响较大。
11. Minor GC,Full GC 的触发条件
Minor GC(Young GC)的触发条件
- Eden 区满 :
- 当新生代中的 Eden 区被填满时,JVM 会触发 Minor GC。这是最常见的触发条件。JVM 会检查新生代中的对象,回收那些不再使用的短生命周期对象。
- 手动调用 :
- 虽然不推荐,但可以通过 System.gc() 手动请求垃圾回收,可能会导致 Minor GC 的发生。
Full GC(Major GC)的触发条件
- 老年代满 :
- 当老年代的空间不足以容纳新分配的对象时,JVM 会触发 Full GC。这是 Full GC 最常见的触发条件。
- 永久代(Metaspace)满 :
- 在 Java 8 之前,Java 使用永久代来存放类的元数据。如果永久代满了,会触发 Full GC。在 Java 8 之后,永久代被 Metaspace 替代,Metaspace 的满也是触发 Full GC 的条件之一。
- Minor GC 后老年代未能释放足够内存 :
- 当进行 Minor GC 后,如果老年代没有足够的空间来容纳新对象,JVM 会触发 Full GC。
- 调用 System.gc() :
- 通过调用 System.gc(),JVM 会建议执行 Full GC,尽管并不保证会执行。
- JVM 参数设置 :
- 一些 JVM 参数设置可能会影响 Full GC 的触发,如 -XX:+UseG1GC 或其他垃圾收集器的特定配置。
总结
- Minor GC 主要由新生代的空间不足引起,目的是回收短生命周期的对象,通常处理速度快。
- Full GC 主要由老年代空间不足、永久代(Metaspace)空间不足等引起,通常处理速度较慢,涉及整个堆的回收。
GC 影响与调优
由于 Full GC 的影响比 Minor GC 更大,因此在应用中要尽量减少 Full GC 的频率。可以通过调整 JVM 的参数、优化对象的使用、减少大对象的创建等方式来优化垃圾回收的性能。
12. JVM 调优
在生产环境中,JVM 调优是确保 Java 应用程序性能和稳定性的重要步骤。调优的目标通常是减少垃圾回收的时间、降低内存使用和提高应用程序的吞吐量。以下是一些常见的 JVM 调优策略和方法:
1. 选择合适的垃圾收集器
不同的垃圾收集器适用于不同的应用场景,选择合适的垃圾收集器可以显著提高性能。
- Serial GC:适合单线程环境和小型应用。
bash
-XX:+UseSerialGC
- Parallel GC:适合多核处理器和需要高吞吐量的应用。
bash
-XX:+UseParallelGC
- Concurrent Mark-Sweep (CMS) GC:适合低延迟应用,减少停顿时间。
bash
-XX:+UseConcMarkSweepGC
- G1 GC:适合大内存应用,具有低延迟和高吞吐量的特性。
bash
-XX:+UseG1GC
- ZGC 和 Shenandoah GC:适合需要极低延迟的应用,主要在 Java 11 及之后的版本中使用。
2. 调整堆内存大小
通过调整堆内存的大小,可以控制应用程序的性能。
- 设置初始堆大小:
bash
-Xms512m
- 设置最大堆大小:
bash
-Xmx2048m
- 设置年轻代大小:
bash
-Xmn256m
一般推荐将初始堆和最大堆的比值设置为 1:2 或 1:3。
3. 调整垃圾收集参数
根据具体应用需求,调整垃圾收集的参数可以进一步优化性能。
- 设置新生代和老年代的比例:
bash
-XX:NewRatio=3 # 新生代与老年代的比例
- 设置 Survivor 区的大小:
bash
-XX:SurvivorRatio=8 # Eden 区与 Survivor 区的比例
- 设置最大 GC 停顿时间(对于 G1 GC):
bash
-XX:MaxGCPauseMillis=200
4. 监控和分析
定期监控和分析 JVM 的运行状态,使用各种工具来观察性能和内存使用情况。
- JVisualVM:Java 自带的可视化监控工具,可以用来查看内存、线程、CPU 使用情况。
- JConsole:用于监控 Java 应用的图形界面工具。
- GC 日志:启用 GC 日志以分析垃圾收集的性能。
bash
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log
- 使用 Java Flight Recorder:这是一个强大的性能监控工具,可以提供深入的性能分析。
5. 优化代码
有时,代码的优化可以显著减少内存使用和垃圾回收的压力。
- 减少对象创建:尽量复用对象,避免频繁创建短命对象。
- 使用合适的数据结构:选择合适的集合类,例如 ArrayList vs LinkedList,并根据需求选择合适的实现。
- 避免内存泄漏:定期检查代码中是否存在内存泄漏,例如未清理的缓存、静态集合中的对象引用等。
6. 设置线程数
在多线程应用中,合理配置线程数可以提高性能。
- 设置最大线程数(取决于应用和服务器的具体情况)。
bash
-XX:ParallelGCThreads=4 # 设置并行 GC 线程数
7. 使用 JDK 8 及之后的版本的特性
- Metaspace:Java 8 之后,类元数据存储在本地内存中,避免了旧版本中永久代的限制。可以通过设置 Metaspace 大小来优化性能。
bash
-XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m
8. 测试与迭代
调优是一个迭代过程。在生产环境中,测试是非常重要的步骤:
- 负载测试:在类似生产环境中进行负载测试,以观察系统在高负载情况下的表现。
- 逐步调整:每次只调整一个参数,观察性能变化,再进行下一步调整。
结论
JVM 调优需要综合考虑应用的特点、系统资源和性能需求。通过选择合适的垃圾收集器、调整堆内存大小、监控和分析、优化代码等方式,可以显著提高 Java 应用的性能和稳定性。调优的过程中应根据具体情况进行反复测试和验证,确保调整带来正面的效果。
13. Full GC 排查
在生产环境中,排查 Java 应用的 Full GC 问题是确保系统稳定性和性能的关键步骤。以下是一些有效的排查方法和工具:
1. 启用 GC 日志
启用 GC 日志可以帮助你分析 Full GC 的发生频率、持续时间和触发原因。
- 启用 GC 日志:
bash
-Xloggc:gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps
- 如果你使用的是 Java 9 及以上版本,可以使用以下参数:
bash
-Xlog:gc*:file=gc.log:time
2. 分析 GC 日志
使用工具或脚本分析生成的 GC 日志,查找 Full GC 的详细信息。
- 工具 :
- GCViewer:一个可视化工具,可以帮助分析 GC 日志。
- GCEasy:一个在线工具,可以上传 GC 日志进行分析。
通过这些工具,你可以查看 Full GC 的时间、频率、回收的内存量以及各个阶段的耗时。
3. 监控应用性能
使用监控工具观察应用的性能指标,找出与 Full GC 相关的趋势。
- JVisualVM:Java 自带的可视化监控工具,可以查看内存使用情况、线程情况和 CPU 使用情况。
- JConsole:可监控 Java 应用的性能。
- Prometheus + Grafana:可以实时监控 JVM 的指标,包括 GC 相关的指标。
4. 检查内存配置
确认 JVM 的内存配置是否合理,避免内存不足导致频繁的 Full GC。
- 堆内存配置:
bash
-Xms1024m -Xmx2048m
- 新生代与老年代的比例:
bash
-XX:NewRatio=3 # 新生代与老年代的比例
5. 分析应用的内存使用
- 使用内存分析工具 :
- Eclipse Memory Analyzer (MAT):可以帮助分析堆转储文件,找出内存泄漏和长生命周期对象。
- 生成堆转储:
bash
jmap -dump:live,format=b,file=heapdump.hprof <pid>
然后使用 MAT 等工具进行分析。
6. 检查对象生命周期
通过分析代码,检查是否存在内存泄漏的情况,可能导致 Full GC 频繁发生。
- 静态集合:检查是否有静态集合中引用的对象未被清理。
- 长生命周期对象:分析老年代中存活的对象,找出那些不再使用的对象。
7. 应用程序代码优化
- 减少对象创建:避免频繁创建短命对象。
- 使用合适的数据结构:根据需要选择合适的集合类。
8. 测试与调整
- 负载测试:在生产环境中进行负载测试,观察 Full GC 的发生情况。
- 逐步调整参数:调整 JVM 参数后,观察效果,逐步进行调整。
9. 查看 JVM 版本和参数
- 确保使用的是最新的稳定版本,并查看 JVM 的启动参数,某些参数可能会影响 GC 行为。
10. 识别 Full GC 的原因
根据 GC 日志中的信息,识别 Full GC 的原因,如:
- 老年代不足。
- PermGen/Metaspace 区域不足。
- 对象的存活时间过长。
- 系统内存压力。
总结
排查 Full GC 问题需要综合考虑多个因素,包括 GC 日志分析、监控应用性能、内存配置检查、代码优化等。通过这些方法,可以帮助你有效定位问题,并采取相应措施进行优化和调整,从而减少 Full GC 的发生频率,提高应用性能和稳定性。
14. GC 日志解析
JVM GC 日志 是帮助开发人员分析和调优 Java 应用内存管理的重要工具。通过解析 GC 日志,可以了解 JVM 垃圾收集的行为,包括垃圾回收频率、持续时间、回收的内存大小、各代(新生代、老年代、元空间等)的变化情况等。
以下是 GC 日志的基本格式、常见垃圾回收器日志示例,以及如何解析这些日志。
1. 如何启用 GC 日志
通过以下 JVM 参数可以启用并配置 GC 日志:
bash
-XX:+PrintGCDetails # 打印详细的 GC 日志
-XX:+PrintGCDateStamps # 打印 GC 发生的时间戳
-XX:+PrintGCTimeStamps # 打印 GC 发生的相对时间
-XX:+PrintHeapAtGC # 打印 GC 前后的堆状态
-Xloggc:<file_path> # 将 GC 日志输出到指定文件
例如:
bash
java -Xms512m -Xmx1024m -XX:+UseG1GC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCApplicationStoppedTime -Xloggc:gc.log MyApplication
2. GC 日志常见格式和解析
(1) Minor GC (新生代 GC) 日志示例
bash
2024-10-11T15:30:24.123+0000: 0.197: [GC (Allocation Failure) [PSYoungGen: 15360K->1984K(19456K)] 15360K->2000K(62976K), 0.0043510 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
解析:
- 时间戳:2024-10-11T15:30:24.123+0000 表示 GC 发生的实际时间。
- 相对时间:0.197 表示从 JVM 启动开始经过的时间(秒)。
- GC 类型:GC (Allocation Failure) 表示 GC 触发的原因是内存分配失败。
- Young Generation :PSYoungGen: 15360K->1984K(19456K) 表示 GC 发生时,新生代(Young Generation)的内存使用情况:
- GC 前:新生代占用了 15360K。
- GC 后:新生代占用了 1984K。
- 总空间:新生代的容量是 19456K。
- Heap Usage :15360K->2000K(62976K) 表示整个堆的使用情况:
- GC 前:堆总使用 15360K。
- GC 后:堆总使用 2000K。
- 总容量:堆的总容量是 62976K。
- GC 耗时:0.0043510 secs 表示此次 GC 持续了 4.351 毫秒。
- CPU 时间 :user=0.01 sys=0.00, real=0.00 secs 表示:
- 用户态时间(user):0.01 秒。
- 内核态时间(sys):0.00 秒。
- 实际时间(real):0.00 秒。
(2) Full GC 日志示例
bash
2024-10-11T15:31:15.789+0000: 10.456: [Full GC (Allocation Failure) [PSYoungGen: 1024K->0K(19456K)] [ParOldGen: 20480K->18400K(20480K)] 21504K->18400K(39936K), [Metaspace: 3072K->3072K(1056768K)], 0.1234560 secs] [Times: user=0.10 sys=0.02, real=0.12 secs]
解析:
- GC 类型:Full GC (Allocation Failure) 表示发生了 Full GC,原因是内存分配失败。
- Young Generation :PSYoungGen: 1024K->0K(19456K) 表示:
- GC 前:新生代占用了 1024K。
- GC 后:新生代占用了 0K。
- 总空间:新生代的容量是 19456K。
- Old Generation :ParOldGen: 20480K->18400K(20480K) 表示:
- GC 前:老年代占用了 20480K。
- GC 后:老年代占用了 18400K。
- 总空间:老年代的容量是 20480K。
- Heap Usage :21504K->18400K(39936K) 表示:
- GC 前:堆使用了 21504K。
- GC 后:堆使用了 18400K。
- 总容量:堆的总容量是 39936K。
- Metaspace:Metaspace: 3072K->3072K(1056768K) 表示 Metaspace 空间的使用量没有变化,依然是 3072K。
- GC 耗时:0.1234560 secs 表示 Full GC 持续了 123.456 毫秒。
- CPU 时间:user=0.10 sys=0.02, real=0.12 secs。
(3) G1 GC 日志示例
bash
2024-10-11T15:32:45.234+0000: 45.678: [GC pause (G1 Evacuation Pause) (young) (to-space exhausted), 0.0211234 secs]
[Parallel Time: 18.9 ms, GC Workers: 8]
[Other: 2.2 ms]
[Eden: 8192.0K(8192.0K)->0.0B(7168.0K) Survivors: 1024.0K->2048.0K Heap: 18.0M(28.0M)->12.0M(28.0M)]
解析:
- GC 类型:GC pause (G1 Evacuation Pause) 表示 G1 GC 的 Evacuation Pause(即新生代 GC)。
- GC 耗时:0.0211234 secs 表示 GC 持续了 21.123 毫秒。
- 并行时间:Parallel Time: 18.9 ms, GC Workers: 8 表示 8 个 GC 工作线程花费了 18.9 毫秒。
- Eden 区 :Eden: 8192.0K(8192.0K)->0.0B(7168.0K) 表示:
- GC 前:Eden 区使用了 8192K。
- GC 后:Eden 区的内存被清空。
- 总容量:Eden 区从 8192K 调整为 7168K。
- 堆内存 :Heap: 18.0M(28.0M)->12.0M(28.0M) 表示:
- GC 前堆使用了 18M。
- GC 后堆使用了 12M。
- 总容量保持不变,28M。
3. 常见的 GC 日志分析
- GC 频率过高 :
- 如果 Minor GC 频率过高,可能表明 Eden 区容量不足,可以通过增大新生代空间或调优对象分配策略来优化。
- Full GC 频率过高 :
- 如果 Full GC 频繁发生,可能是老年代空间不足或碎片化严重。可以通过增大老年代空间或优化对象的生命周期来减少 Full GC 发生。
- GC 时间过长 :
- 如果 GC 持续时间较长,可能影响应用的响应时间。可以通过增加并行 GC 线程数(如 -XX:ParallelGCThreads)或调整垃圾收集器类型(如使用 G1 或 ZGC)来优化。
4. 总结
GC 日志能够反映 JVM 内存管理的细节,解析 GC 日志有助于了解 JVM 的垃圾收集行为,进而进行内存调优。在生产环境中,合理配置 GC 日志并定期分析它们可以帮助开发人员识别性能瓶颈并优化应用的稳定性和性能。
15. 栈日志
在 JVM 中,栈日志(Stack Trace Logs)是用于记录线程执行过程中的方法调用栈信息的日志文件。栈日志通常在出现异常或错误时生成,并为开发人员和运维人员提供调试和问题诊断的依据。通过分析栈日志,可以了解线程的执行路径、方法调用的顺序、异常发生的位置等信息,从而定位性能瓶颈、线程问题或代码缺陷。
1. 栈日志的作用
栈日志的主要作用是帮助开发者和运维人员分析和解决以下问题:
1.1 异常定位
当 JVM 抛出异常(如 NullPointerException、ArrayIndexOutOfBoundsException 等)时,会生成栈日志,描述从异常发生点到当前方法调用栈的完整路径。这可以帮助开发者快速定位问题的根源。
示例:
java
Exception in thread "main" java.lang.NullPointerException
at com.example.MyClass.myMethod(MyClass.java:10)
at com.example.MyClass.main(MyClass.java:5)
通过栈日志可以看到 NullPointerException 是在 MyClass 的 myMethod 方法的第 10 行发生的,而 myMethod 是从 main 方法调用的。
1.2 线程状态分析
栈日志可以显示每个线程当前的状态(如运行、等待、阻塞等),帮助分析线程问题。例如,通过线程栈信息,可以发现死锁、线程饥饿、线程阻塞等问题。
示例:
java
"Thread-1" prio=5 tid=0x00007f8c28010000 nid=0x2f03 waiting on condition [0x00007f8c9b500000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.MyClass.synchronizedMethod(MyClass.java:20)
- waiting to lock <0x000000076b2e46d0> (a java.lang.Object)
- locked <0x000000076b2e4700> (a java.lang.Object)
这里可以看出线程 "Thread-1" 处于 阻塞状态,并且等待获取对象锁。
1.3 性能分析
栈日志可以记录方法调用的深度和频率。当性能问题(如 CPU 使用率过高或方法调用栈过深)出现时,通过分析栈日志,可以发现哪个方法或代码段占用了过多的资源。
1.4 内存泄漏定位
栈日志在处理内存泄漏问题时也很有帮助。通过 OutOfMemoryError 相关的栈日志,可以发现哪些对象没有被正确释放,或在哪些地方频繁分配内存。
示例:
java
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at com.example.MyClass.allocateMemory(MyClass.java:25)
at com.example.MyClass.main(MyClass.java:10)
这里显示 OutOfMemoryError 是在 allocateMemory 方法中发生的,可以用作内存分析的起点。
2. JVM 栈日志的触发条件
栈日志通常在以下情况下生成:
- 异常抛出时:当 Java 程序抛出未捕获的异常时,JVM 会自动生成栈日志,显示异常的原因和发生位置。
- 死锁检测时:如果 JVM 检测到死锁,栈日志会显示参与死锁的线程信息。
- 显式打印栈信息:可以通过程序显式地调用 Thread.dumpStack() 方法打印当前线程的栈日志。
- 性能监控:一些监控工具(如 JVisualVM、JProfiler)可以生成线程的栈信息,用于性能调优和分析。
3. 栈日志的典型格式
栈日志的结构通常包括以下几个部分:
- 线程名称:显示线程的名称和优先级。
- 线程状态:显示线程的当前状态(如 RUNNABLE、BLOCKED、WAITING 等)。
- 方法调用栈:记录每个方法的调用顺序,包括类名、方法名和行号。
- 锁信息:如果线程被阻塞或等待锁,栈日志中会包含锁相关的信息。
示例栈日志:
java
"main" prio=5 tid=0x00007f8c20002800 nid=0x2c03 runnable [0x00007f8c9b507000]
java.lang.Thread.State: RUNNABLE
at java.io.FileInputStream.readBytes(Native Method)
at java.io.FileInputStream.read(FileInputStream.java:233)
at java.io.BufferedInputStream.fill(BufferedInputStream.java:246)
at java.io.BufferedInputStream.read1(BufferedInputStream.java:286)
at java.io.BufferedInputStream.read(BufferedInputStream.java:345)
at sun.nio.cs.StreamDecoder.readBytes(StreamDecoder.java:284)
at sun.nio.cs.StreamDecoder.implRead(StreamDecoder.java:326)
at sun.nio.cs.StreamDecoder.read(StreamDecoder.java:178)
at java.io.InputStreamReader.read(InputStreamReader.java:184)
at java.io.BufferedReader.fill(BufferedReader.java:161)
at java.io.BufferedReader.readLine(BufferedReader.java:324)
at java.io.BufferedReader.readLine(BufferedReader.java:389)
at com.example.MyClass.readFile(MyClass.java:35)
at com.example.MyClass.main(MyClass.java:20)
3.1 线程状态分析
线程的状态可以帮助确定线程当前的执行情况。栈日志中的线程状态通常包括:
- RUNNABLE:线程正在运行或等待 CPU 时间片。
- BLOCKED:线程被阻塞,等待获取某个锁。
- WAITING:线程在等待其他线程的通知(例如通过 Object.wait() 或 Thread.join())。
- TIMED_WAITING:线程在等待一定时间(例如 Thread.sleep() 或带超时的 wait()、join())。
- TERMINATED:线程已经结束。
如何分析线程状态:
- 如果大多数线程处于 RUNNABLE 状态,而 CPU 使用率较高,可能是系统出现了 高负载 或 CPU 密集型操作。
- 如果大量线程处于 BLOCKED 状态,可能是 锁争用 问题,某些线程长时间持有锁,导致其他线程无法继续执行。
- 如果线程处于 WAITING 或 TIMED_WAITING 状态,通常表示线程在等待外部事件(如 I/O 操作或其他线程的通知),可以检查等待时间是否过长。
3.2 死锁分析
死锁通常表现为多个线程互相等待彼此持有的锁,导致线程无法继续执行。栈日志中的死锁信息通常表现为:
- 两个或多个线程的状态为 BLOCKED。
- 线程显示 "waiting to lock" 和 "locked" 同时存在,形成循环。
示例死锁日志:
java
"Thread-1" prio=5 tid=0x00007f8c28010000 nid=0x2f03 waiting for monitor entry [0x00007f8c9b500000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.MyClass.method1(MyClass.java:20)
- waiting to lock <0x000000076b2e46d0> (a java.lang.Object)
- locked <0x000000076b2e4700> (a java.lang.Object)
"Thread-2" prio=5 tid=0x00007f8c28020000 nid=0x2f04 waiting for monitor entry [0x00007f8c9b507000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.MyClass.method2(MyClass.java:30)
- waiting to lock <0x000000076b2e4700> (a java.lang.Object)
- locked <0x000000076b2e46d0> (a java.lang.Object)
3.3 锁争用分析
锁争用是性能问题的常见来源,当多个线程争夺同一个锁时,可能会导致线程阻塞,降低系统的并发性能。栈日志中的锁争用信息通常显示为:
- 线程处于 BLOCKED 状态。
- 日志中显示 "waiting to lock" 和 "locked" 关键字。
示例锁争用日志:
java
"Thread-3" prio=5 tid=0x00007f8c28010000 nid=0x2f05 waiting for monitor entry [0x00007f8c9b500000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.MyClass.synchronizedMethod(MyClass.java:40)
- waiting to lock <0x000000076b2e46d0> (a java.lang.Object)
"Thread-4" prio=5 tid=0x00007f8c28020000 nid=0x2f06 runnable [0x00007f8c9b507000]
at com.example.MyClass.synchronizedMethod(MyClass.java:40)
- locked <0x000000076b2e46d0> (a java.lang.Object)
在这个例子中,Thread-3 被阻塞,等待 Thread-4 释放锁。在实际生产环境中,如果发现多个线程被长时间阻塞,可以通过栈日志找到持有锁的线程,从而优化锁的使用,减少争用。
3.4 分析线程过多的问题
如果栈日志显示了大量线程,尤其是大量线程处于相同状态(如 WAITING 或 TIMED_WAITING),这可能表明系统存在 线程过多 或 线程泄漏 问题。通常的表现是:
- 有成百上千的线程在栈日志中,可能是由于线程池配置不当或没有及时销毁线程。
- 线程的栈信息重复,表明某些线程在进行重复的任务。
这种情况下,可以考虑减少线程池的最大线程数,或者更高效地使用异步任务。
3.5 分析 CPU 高使用率问题
当 JVM 中的 CPU 使用率高时,栈日志可以帮助确认哪些线程消耗了大量的 CPU。通常表现为:
- 多个线程处于 RUNNABLE 状态。
- 线程栈深度较大,且在执行 CPU 密集型任务,如复杂的计算或循环操作。
示例:
java
"Thread-5" prio=5 tid=0x00007f8c28010000 nid=0x2f07 runnable [0x00007f8c9b500000]
java.lang.Thread.State: RUNNABLE
at com.example.MyClass.compute(MyClass.java:50)
at com.example.MyClass.main(MyClass.java:10)
3.6 如何有效地分析 JVM 栈日志
- 关注线程状态:查看是否有大量线程处于 BLOCKED、WAITING 或 TIMED_WAITING 状态。线程的状态信息是栈日志分析的关键。
- 检查锁争用情况:如果线程被阻塞,检查是否有锁争用问题,锁的使用是否合理,锁等待时间是否过长。
- 定位异常:如果程序抛出异常(如 NullPointerException、OutOfMemoryError 等),通过栈日志定位异常发生的位置,确定异常的原因。
- 检查线程数量:观察是否有过多的线程创建,线程数是否超过合理的范围。
- 结合上下文分析:栈日志分析不应孤立进行,结合应用的运行环境(如 CPU、内存使用情况、I/O 状况等)和日志中的异常或性能信息,才能得到更加准确的分析结果。
4. 如何生成 JVM 栈日志
- 使用 jstack 工具:jstack 是一个常用的命令行工具,用于打印 JVM 中所有线程的栈信息。可以通过以下命令生成栈日志:
bash
jstack <pid> > thread_dump.txt
其中 是目标 JVM 进程的 ID。
- 通过 Java 代码生成:可以使用 Thread.getAllStackTraces() 或 Thread.dumpStack() 生成当前线程的栈信息并输出到日志中:
java
public class StackTraceExample {
public static void main(String[] args) {
Thread.dumpStack(); // 打印当前线程的栈日志
}
}
- 在异常捕获时打印:可以在 catch 块中通过 Exception.printStackTrace() 方法打印异常的栈信息。
示例:
java
try {
// 可能抛出异常的代码
} catch (Exception e) {
e.printStackTrace(); // 打印栈日志
}
5. 总结
- 栈日志 在 JVM 中的作用是记录线程的执行路径、方法调用的顺序和状态,帮助开发人员快速定位异常、死锁、性能瓶颈等问题。
- 通过栈日志,可以分析 Java 应用的执行流程、线程状态和异常发生的位置,进而为调试和优化提供依据。
- 栈日志是调试和性能分析的重要工具,特别是在生产环境中,通过栈日志可以及时发现和解决复杂问题。
8. 常用数据结构
Java 中常用的数据结构可以分为线性结构和非线性结构。它们的底层实现和线程安全性各有不同。下面是一些常用的数据结构及其底层实现、线程安全性分析:
1. 数组(Array)
在 Java 中,数组是一种数据结构,用于存储多个相同类型的元素。它是一个固定大小的、连续的内存区域,可以通过索引访问。数组可以存储基本数据类型(如 int、char、float 等)和对象引用。
1. 数组的特性
- 固定大小:数组的大小在创建时确定,不能动态改变。如果需要更大的数组,必须创建一个新的数组并复制原数组的元素。
- 元素类型:数组中的所有元素必须是同一种数据类型(基本数据类型或对象类型)。
- 零索引:数组的索引从 0 开始,因此第一个元素的索引为 0,最后一个元素的索引为 length - 1。
- 默认值:如果数组未初始化,基本数据类型的元素会被赋予默认值(如 0、false、null 等),而对象类型的数组元素默认为 null。
- 底层数据结构:连续的内存空间,存储相同类型的数据。
- 特点:通过索引访问,时间复杂度为 O(1)。大小固定,插入、删除操作开销较大,时间复杂度为 O(n)。
- 线程安全性:非线程安全。要在多线程环境中使用数组,需要自己进行同步控制。
2. ArrayList
ArrayList 是 Java 中最常用的集合类之一,属于 List 接口的实现类。它基于动态数组实现,允许存储重复元素,按插入顺序存储,并且支持快速随机访问。
1. ArrayList 的特点:
- 底层数据结构:动态数组,随着数据的增加,容量会自动扩展。
- 元素有序:ArrayList 中的元素按照插入顺序存储,索引从 0 开始。
- 允许重复元素:可以存储重复的元素。
- 随机访问速度快:支持通过索引快速访问元素,时间复杂度为 O(1),插入、删除较慢,适合读多写少的场景。
- 线程不安全:ArrayList 不是线程安全的。在多线程环境下,多个线程同时修改 ArrayList 时,可能会导致数据不一致或异常。不过,可以使用 Collections.synchronizedList(new ArrayList<>()) 来得到线程安全的 ArrayList。
- 扩容机制:ArrayList 使用一个数组 Object[] elementData 来存储元素。数组的初始大小可以通过构造方法指定。它有固定的初始容量(默认是 10),当元素数量超过数组的容量时,ArrayList 会自动扩容,容量增加为原来的 1.5 倍(1.5 倍机制),分配更大的数组来存储元素。这是一个耗时的操作,因为需要复制现有的元素到新数组中。
3. LinkedList
在 Java 中,LinkedList 是一个基于链表的数据结构,属于 Java Collections Framework 的一部分。它实现了 List、Deque 和 Queue 接口,提供了动态大小的存储,允许在列表的两端快速插入和删除元素。
1. LinkedList 的特性
- 动态大小:与数组不同,LinkedList 可以动态调整大小,能够容纳任意数量的元素,直到内存耗尽。
- 元素存储:LinkedList 由节点(Node)构成,每个节点包含一个数据元素和指向前一个节点和下一个节点的引用。
- 双向链表:LinkedList 是双向链表,每个节点都存储了对前一个节点和下一个节点的引用,因此可以方便地向前和向后遍历。
- 性能:LinkedList 在插入和删除操作上的性能优于 ArrayList,在链表的头部或尾部添加或删除元素的时间复杂度为 O(1),在中间位置插入或删除元素的时间复杂度为 O(n),随机访问元素的时间复杂度也为 O(n),因为需要遍历链表。
- 实现接口:LinkedList 实现了 List 接口,允许重复元素和使用索引访问元素。此外,它还实现了 Deque 接口,允许在队列的两端进行操作。
- 线程安全性:非线程安全。可以使用 Collections.synchronizedList(new LinkedList<>()) 来实现线程安全。
- 适用场景 :
- 需要频繁地在列表中间插入和删除元素的场景。
- 不需要频繁随机访问的场景。
4. HashMap
在 Java 中,HashMap 是一种常用的集合类,它实现了 Map 接口,允许通过键(key)来存储和访问值(value)。HashMap 使用哈希表(Hash Table)作为底层数据结构,能够提供高效的查找、插入和删除操作。
1. HashMap 的特性
- 键值对存储:HashMap 将数据存储为键值对(key-value pairs)。每个键必须是唯一的,多个键可以对应同一个值。
- 无序:HashMap 中的元素是无序的,这意味着元素的顺序不依赖于插入的顺序。
- 允许空值:HashMap 允许一个空键和多个空值。
- 线程不安全:HashMap 不是线程安全的,如果多个线程同时访问一个 HashMap,并且至少有一个线程修改它,必须在外部进行同步。这方面可以使用 ConcurrentHashMap 作为线程安全的替代。
- 特点:使用哈希表存储键值对,提供平均 O(1) 的时间复杂度用于插入、删除和查找操作,但在哈希冲突严重的情况下,最坏情况时间复杂度为 O(n)。
- 负载因子:HashMap 的负载因子(load factor)是一个衡量哈希表何时扩展的参数。默认值为 0.75,表示当哈希表的填充度达到 75% 时,将自动扩展其容量以提高性能。
- 扩容机制
- 当 HashMap 的元素数量超过了当前容量与负载因子的乘积时,会进行扩容:
- 创建一个新的数组,大小是旧数组的两倍。
- 将旧数组中的所有元素重新哈希并放入新数组中。
- 当 HashMap 的元素数量超过了当前容量与负载因子的乘积时,会进行扩容:
- HashMap 的默认初始容量为 16。
2. 底层数据结构
在 Java 中,HashMap 的底层数据结构主要是 数组 + 链表 + 红黑树(Java 8 及以后版本中,当链表长度超过阈值时,链表转为红黑树)。
HashMap 使用一个数组来存储链表(或树)的头指针。每个数组元素称为一个 桶(bucket),它存储的是相应哈希值计算出的链表或红黑树的第一个节点。当向 HashMap 中插入一个键值对时,首先会根据键的哈希值确定该键应该存储在哪个桶中。
在每个桶中,HashMap 使用链表来处理哈希冲突。当两个或多个键通过哈希函数计算出相同的桶索引时,这些键会被存储在同一个桶中,形成一个链表。链表中的每个节点存储着一个键值对(key-value)。
在 Java 8 及之后的版本中,HashMap 进行了优化。当一个桶中的元素数量超过 8 时,链表会被转换为红黑树。这种结构可以提供更高效的查找性能,因为红黑树的查找时间复杂度为 O(log n),而链表在最坏情况下为 O(n)。这种优化在哈希冲突较多的情况下可以显著提高性能。
示意图
以下是 HashMap 的简化结构示意图:
sql
HashMap
+-------------------+
| Bucket Array |
|-------------------|
| Bucket 0 |--> LinkedList/Red-Black Tree (K1, V1) -> (K2, V2)
|-------------------|
| Bucket 1 |--> (K3, V3)
|-------------------|
| Bucket 2 |--> null
|-------------------|
| Bucket 3 |--> LinkedList (K4, V4)
|-------------------|
| ... |
|-------------------|
| Bucket N |--> ...
+-------------------+
3. 在多线程环境下的问题
3.1 数据不一致
多个线程同时对同一个 HashMap 实例进行读写操作时,可能导致数据不一致。例如,一个线程可能在写入数据的同时,另一个线程在读取数据,这可能会导致读取到过期或未初始化的数据。
3.2 死循环
在并发环境中,HashMap 的结构可能会被破坏,导致无限循环。例如,当一个线程在调整数组大小(即扩容)时,另一个线程可能会访问这个正在调整的 HashMap。在这种情况下,遍历线程可能会卡在一个特定的键值对上,因为在读取过程中,数据结构的修改(如链表或树的重组)导致了它的状态不一致。例如,它可能在链表中反复访问同一个节点而无法结束循环,导致应用程序崩溃。
put 造成链表形成闭环,get的时候出现死循环,这种情况目前在 jdk8 已经修复,改进了 resize 方法,不再进行链表的逆转, 而是保持原有链表的顺序, 如果在多线程环境下, 会在链表后边追加元素, 不会出现环的情况。
3.3 丢失更新
当多个线程同时对同一个 HashMap 进行写操作时,可能会出现丢失更新的情况。例如,两个线程同时向 HashMap 中添加相同的键,可能导致一个线程的更新被另一个线程覆盖。
3.5 解决方案
- 使用 ConcurrentHashMap :一个线程安全的哈希表实现,支持并发访问。它使用分段锁(segment lock)来实现高效的读写操作,适用于多线程环境。
- 使用 Collections.synchronizedMap() :将 HashMap 包装成线程安全的 Map。这种方法会在每个操作上加锁,性能相对较低。
- 使用显式锁 :如果需要更细粒度的控制,可以使用 ReentrantLock 或其他同步机制来保护对 HashMap 的访问。
3.4 ConcurrentModificationException
虽然 HashMap 的迭代器不会在并发修改时抛出 ConcurrentModificationException,但在并发情况下,如果有一个线程在迭代期间修改了 HashMap 的结构(例如添加或删除元素),可能会导致意外的行为,甚至是抛出 ConcurrentModificationException。
5. HashSet
在 Java 中,HashSet 是一个实现了 Set 接口的集合类,用于存储唯一的元素,即它不允许重复的元素。HashSet 的底层是基于 HashMap 实现的,它是一个不保证顺序、不允许重复、并且允许存储 null 值的集合。
主要特点
- 无序性:HashSet 并不保证集合中的元素按插入顺序排列,它基于哈希表实现,因此元素的顺序可能与插入顺序不同。
- 唯一性:HashSet 只存储唯一的元素,如果尝试将重复元素添加到 HashSet,添加操作将返回 false,且不会引发异常。
- 允许 null 值:HashSet 允许存储一个 null 值(但只能有一个 null 值)。
- 基于 HashMap 实现:底层是利用 HashMap 来实现的,每个添加的元素实际上是作为 HashMap 的键,值是一个固定的常量 PRESENT。
- 线程安全性:非线程安全。可以使用 Collections.synchronizedSet(new HashSet<>()) 来实现线程安全,或使用 ConcurrentHashMap 提供的并发集合,如 ConcurrentSkipListSet。
- 时间复杂度 :添加、删除、查询操作的时间复杂度为 O(1),因为底层的哈希表可以快速定位到元素。
6. TreeMap
TreeMap 是 Java 中基于红黑树 实现的有序 Map 接口的实现类。与 HashMap 不同的是,TreeMap 按照键的自然顺序 或者根据提供的自定义比较器对键进行排序,从而确保键值对总是保持有序。
主要特点
- 有序性 :
- TreeMap 中的键是有序的,默认情况下是根据键的自然顺序(通常是按升序排序),即实现了 Comparable 接口的顺序。
- 也可以通过构造函数传入自定义的 Comparator 来指定排序规则。
- 基于红黑树 :
- TreeMap 是基于红黑树(Red-Black Tree)实现的,因此它的所有操作(插入、删除、查找)时间复杂度为 O(log n)。
- 红黑树是一种平衡二叉查找树,它的插入和删除操作会通过旋转和颜色变换来保持树的平衡。
- 键唯一 :
- TreeMap 的键是唯一的,不能有重复的键。若插入重复的键,后插入的键值对会覆盖之前的值。
- 线程不安全 :
- TreeMap 不是线程安全的,若在多线程环境中需要使用,可以使用 Collections.synchronizedSortedMap(new TreeMap<>()) 方法将其包装为线程安全的版本,或者使用并发版本的数据结构如 ConcurrentSkipListMap。
- 允许 null 值,但不允许 null 键 :
- TreeMap 不允许键为 null,因为它需要对键进行比较排序。但是,值可以为 null。
7. TreeSet
TreeSet 是 Java 集合框架中基于 NavigableSet 接口实现的一个类,底层是通过 红黑树 (Red-Black Tree) 来实现的。因此,TreeSet 保证元素是 有序 且不允许重复。由于其排序特性,TreeSet 是常用来维护有序集合的工具。
特点
- 有序性 :
- TreeSet 中的元素是有序的。默认是按照元素的自然顺序排序(需要实现 Comparable 接口),也可以通过构造函数传入自定义的 Comparator 进行排序。
- 基于红黑树 :
- TreeSet 是通过红黑树实现的,因此所有操作的时间复杂度为 O(log n),包括插入、删除和查找。
- 底层数据结构:基于 TreeMap 实现,利用 TreeMap 的键来存储元素。
- 不允许重复元素 :
- TreeSet 不允许添加重复的元素。如果试图添加已经存在的元素,TreeSet 会忽略该操作。
- 不允许 null 元素 :
- 从 Java 7 开始,TreeSet 不允许存储 null 值,因为在排序过程中需要对元素进行比较,无法对 null 进行比较。
- 线程不安全 :
- TreeSet 不是线程安全的。如果需要在多线程环境中使用,需要手动同步或者使用 Collections.synchronizedSortedSet() 来获得线程安全版本。
- 适用场景 :
- 需要有序集合:当需要按顺序存储元素并进行排序时,TreeSet 是理想的选择。
- 范围查询:TreeSet 提供子集操作,支持范围查询,比如查找某个区间内的元素。
8. Stack
- 底层数据结构:继承自 Vector 类,基于动态数组实现的栈(后进先出,LIFO)。
- 特点:支持栈的基本操作 push()、pop(),时间复杂度为 O(1)。
- 线程安全性:Stack 是 Vector 的子类,因此它是线程安全的,内部方法通过 synchronized 关键字同步实现。虽然线程安全,但 Stack 的性能不如基于 ArrayDeque 的实现高效,因为同步机制增加了开销。因此 Stack 不推荐使用,Deque 的实现如 ArrayDeque 或 LinkedList 更适合。
- 底层数据结构
Stack 继承自 Vector,因此底层是基于动态数组来存储元素的(后进先出,LIFO)。由于它是 Vector 的子类,它继承了 Vector 的一些特性,比如:
- 动态扩容 :
- 当栈的容量不足时,Stack 会像 Vector 一样自动扩容。默认情况下,Vector 的扩容因子是 100%(即扩容时,容量会翻倍)。
9. Vector
Vector 是 Java 中的一个 动态数组 类,实现了 List 接口。它与 ArrayList 类似,但它是 线程安全 的。Vector 通过同步方法来保证线程安全性,因此可以在多线程环境中安全地使用。不过,由于同步的开销和一些设计上的问题,Vector 在现代 Java 编程中已不再常用,通常使用 ArrayList 或其他并发集合类(如 CopyOnWriteArrayList)来替代。
- 底层数据结构:Vector 的底层是一个数组,与 ArrayList 类似,采用动态数组来存储元素。当存储的元素超过当前数组的容量时,Vector 会自动扩容,默认情况下,扩容因子为 100%,即每次扩容时将容量扩大一倍。
- 特点:所有操作都被同步,适用于多线程环境,但性能较低。
- 线程安全性:线程安全。通过方法加锁来实现同步,性能较低。
10. Queue(队列)
- 常用实现 :
- LinkedList:实现 Deque 接口,可以作为双向链表使用,可以作为双端队列使用,也可以作为栈来使用。线程不安全,需要在多线程场景下自行加锁。
- ArrayDeque:基于数组的双端队列,可以用作栈或队列,性能优于 LinkedList,线程不安全,适合单线程场景。
- PriorityQueue:基于优先级的队列,内部元素按优先级顺序排列,不保证元素按插入顺序排列。线程不安全,适用于单线程或手动同步的多线程场景。
- 线程安全实现 :
- ArrayBlockingQueue:一个有界的基于数组的阻塞队列。
- PriorityBlockingQueue:带优先级的阻塞队列,基于堆实现。
- LinkedBlockingQueue:基于链表的阻塞双端队列,可以选择是否有界。
- ConcurrentLinkedQueue:无界的非阻塞的线程安全双端队列,底层是链表,基于 CAS 实现,适用于高并发场景。
- SynchronousQueue:一个没有容量的阻塞队列,生产者必须等待消费者接受之后才能插入。
- Deque 接口 :
- Deque 接口是 Queue 的子接口,代表双端队列,可以从队列两端添加和移除元素,常见实现类有 ArrayDeque 和 LinkedList。
11. ConcurrentHashMap
1. 数据结构
在 Java 中,ConcurrentHashMap 的底层数据结构在不同的版本有不同的实现。我们来分别介绍 Java 7 和 Java 8 中的实现。
1. Java 7 的实现:
在 Java 7 中,ConcurrentHashMap 的底层数据结构是分段锁(Segmented Locking)机制。它将整个哈希表分为多个段(Segment),每个段本质上是一个独立的哈希表,并且每个段维护自己的锁。
scss
ConcurrentHashMap
|
| - Segment[0] --> HashEntry[] --> (Key1, Value1) --> (Key2, Value2)
| (bucket array) (linked list)
|
| - Segment[1] --> HashEntry[] --> (Key3, Value3)
| (bucket array)
|
| - Segment[2] --> HashEntry[] --> (Key4, Value4)
| (bucket array)
|
| - ... (more segments)
- Segment[]:ConcurrentHashMap 维护了一个 Segment 数组,每个 Segment 就像是一个小型的 HashMap。每个 Segment 是独立的,这意味着不同的线程可以在不同的段中并发执行读写操作,而不会相互阻塞。
- HashEntry[]:每个 Segment 内部维护一个 HashEntry 数组,这是一个桶数组,类似于 HashMap 中的数组。
- 链表:在哈希冲突时,每个桶通过链表存储多个键值对。
- 通过对每个段加锁的方式,实现了局部锁。只有当对某个段的数据进行修改时,才需要加锁,这样减少了锁的粒度,提高了并发性能。
- 插入、删除或更新操作只会锁定数据所在的段,而不会锁定整个哈希表。
- 读操作(如 get())在大多数情况下不需要加锁,因为其设计是无锁的。
2. Java 8 及以后版本的实现:
在 Java 8 中,ConcurrentHashMap 的实现发生了较大的变化,分段锁 (Segment)机制被移除 ,取而代之的是 CAS(Compare-And-Swap)和 Synchronized 相结合的方式,并且引入了红黑树来优化冲突的哈希桶。
scss
ConcurrentHashMap
|
| - Node[] (bucket array) --> (Key1, Value1) --> (Key2, Value2)
| (linked list or red-black tree)
|
| - Node[] (bucket array) --> (Key3, Value3)
|
| - Node[] (bucket array) --> (Key4, Value4)
|
| - ... (more nodes)
- Node[]:在 Java 8 中,ConcurrentHashMap 直接使用一个 Node[] 数组。每个 Node 存储一个键值对(key-value),并且指向下一个节点(在发生哈希冲突时形成链表)。
- 链表与红黑树:每个桶(Node)要么是一个链表,要么是一个红黑树,当哈希冲突较多时,链表的长度超过阈值(默认 8)时,为了提升在哈希冲突较多时的性能,链表会自动转换为红黑树(TreeNode)结构,这样查找操作的时间复杂度从 O(n) 优化为 O(log n)。
- Java 8 采用了更细粒度的锁机制,结合了 CAS 和 synchronized 关键字。CAS 用于无锁更新,减少不必要的锁竞争。
- put、remove 等修改操作会使用 synchronized 关键字来锁定特定的桶,而不是整个哈希表。
- 读取操作:无锁进行,直接通过数组查找,使用了 volatile 变量确保可见性。
- 写入操作:写操作使用了 CAS 来保证原子性。在某些情况下(如链表扩展、树转换等),会使用 synchronized 锁定特定的桶。
- 特点:允许并发读写,查找和插入的时间复杂度为 O(1),适合高并发场景。
2. 红黑树转化条件
1. 转化条件
- 链表长度达到阈值:当同一个哈希桶中的元素数量超过阈值(默认是 8 个)时,链表会转换为红黑树。这个阈值由常量 TREEIFY_THRESHOLD 定义,默认值为 8。
- 数组容量足够大 :在链表转化为红黑树之前,HashMap 会首先检查整个哈希表的数组容量是否达到了最小容量(MIN_TREEIFY_CAPACITY),默认值为 64。只有当数组容量大于或等于这个值时,链表才会转化为红黑树。
- 如果数组容量不足,即小于 64,此时不会转换为红黑树,而是会触发数组扩容操作,将哈希表的容量增大,之后再重新计算是否满足树化条件。
- 当 HashMap 转化为红黑树时,链表的节点引用(即 next 引用)并不会被保留,链表节点会被完全转换为红黑树节点。
2. 回退条件
- 当红黑树中的元素由于删除操作而减少,树中的节点数低于阈值(默认是 6,由常量 UNTREEIFY_THRESHOLD 定义)时,红黑树将退化回链表结构。这是为了在存储较少元素时减少不必要的复杂树结构,提高操作效率。
- 在退化回链表时,HashMap 会根据当前红黑树的节点重新构建链表结构,而不是恢复原先的链表节点间的引用关系。
HashMap 中的链表和红黑树是两种完全独立的数据结构,在转化和回退过程中,HashMap 会动态地重组节点以适应当前存储需求。
3. HashMap vs ConcurrentHashMap
1. null 值的处理:
- HashMap:HashMap 允许键和值为 null。键可以是 null,值也可以是 null。
- ConcurrentHashMap:ConcurrentHashMap 不允许 键或值为 null。这主要是为了避免多线程环境下可能出现的混淆。例如,如果 null 作为一个返回值,可能无法区分该键是否不存在还是值本身就是 null。
2. 并发度(Concurrency Level):
- HashMap:没有并发度的概念,所有线程对同一个 HashMap 的访问都需要外部同步控制。
- ConcurrentHashMap:允许多线程同时访问并发度(默认是 16 段,Java 8 之后取消了分段锁)决定了 ConcurrentHashMap 可以同时支持多个线程安全访问,从而提高并发性能。每个线程操作不同的哈希桶,避免锁的冲突。
3. 迭代器的行为:
- HashMap:HashMap 的迭代器是快速失败(fail-fast)的。这意味着如果在迭代的过程中有其他线程修改了 HashMap 的结构(如插入或删除元素),迭代器会抛出 ConcurrentModificationException。
- ConcurrentHashMap:ConcurrentHashMap 的迭代器是弱一致性(weakly consistent)的。它不会抛出 ConcurrentModificationException,并且能够在迭代过程中看到某些最新的更新(但不保证看到所有的更新)。
4. 扩容机制:
- HashMap:HashMap 在进行扩容时,会重新计算每个元素的哈希值,并重新分配到新的桶位置。这是一个全局的过程,并且不支持多线程。
- ConcurrentHashMap:ConcurrentHashMap 在扩容时,采取的是渐进式扩容,多个线程可以参与扩容过程,分配负载,这样就不会阻塞其他操作,提升了并发环境下的扩展性。
5. 渐进式扩容
在 Java 8 中,ConcurrentHashMap 引入了渐进式扩容(progressive resizing)机制,旨在提升扩容过程的效率,特别是在多线程环境下。传统的 HashMap 在扩容时是一次性全表重哈希和搬迁的,而 ConcurrentHashMap 的渐进式扩容允许多个线程同时参与,并且是在进行正常操作(如读取或写入)时逐步完成的。这种设计避免了大规模重分配时的性能瓶颈和阻塞。
工作原理
- 扩容触发条件 :
- 当 ConcurrentHashMap 的元素数量超过当前容量的负载因子阈值时(默认负载因子为 0.75),就会触发扩容。
- 扩容的核心是将哈希表的容量翻倍,并重新分配现有元素到新的哈希桶中。
- 扩容过程 :
- ConcurrentHashMap 在扩容时不会像 HashMap 一样阻塞整个表,而是通过将数据的迁移任务分批完成,允许多个线程协同参与扩容任务。
- 扩容任务被分为若干个小任务,每次处理一部分哈希桶的数据搬迁操作,而不是一次性搬迁所有数据。
- 线程协同参与扩容 :
- 扩容时,多个线程可以共同参与搬迁元素的过程。如果一个线程发现 ConcurrentHashMap 需要扩容,它不会等待整个表扩容完成,而是将扩容过程中的一小部分(部分桶的重哈希搬迁)作为自己的一项任务去完成。
- 每个线程在进行插入、更新、读取等操作时,会检查哈希表是否正在扩容,如果是,线程会"顺便"处理一部分哈希桶的搬迁任务。
- 当一个线程完成一部分搬迁后,它会继续处理正常的读写操作,同时其他线程也可以继续搬迁剩余的桶。
- 迁移标记 :
- ConcurrentHashMap 使用特殊的标志位来标记哪些桶已经搬迁完成。搬迁后的桶会被标记为 ForwardingNode,以指示该桶的数据已经搬迁到新的表中。对旧桶的访问会被重定向到新表中的相应位置。
- 当所有哈希桶的数据都搬迁完毕后,ConcurrentHashMap 会将旧表的引用丢弃,整个扩容过程结束。
具体流程:
- 触发扩容:当元素数量超过阈值时,ConcurrentHashMap 开始扩容,分配一个新的哈希表,容量是当前表的两倍。
- 标记搬迁状态:某个线程开始扩容时,会对部分哈希桶进行标记,并开始搬迁这些桶中的元素。
- 多个线程并行搬迁:如果有多个线程同时操作 ConcurrentHashMap,它们可以同时参与搬迁不同的桶。搬迁后的桶会被设置为 ForwardingNode,这些节点负责将旧表中的访问重定向到新表中。
- 访问过程中搬迁:如果有线程在访问尚未搬迁的桶,它会优先完成相关的搬迁操作,然后继续执行自己的访问逻辑。
- 逐步完成搬迁:随着更多线程的参与,所有桶的数据最终会被搬迁到新的哈希表中。旧表将被废弃。
渐进式扩容的优势:
- 避免全表锁定:传统的 HashMap 在扩容时需要锁住整个哈希表,防止其他线程访问,而 ConcurrentHashMap 通过分批搬迁和多线程协作,避免了全表锁定问题。
- 提高并发性能:由于搬迁任务是逐步完成的,多个线程可以并发地操作数据,扩容不会导致整个数据结构处于不可用状态,保持了高并发环境下的性能。
- 负载均衡:多个线程可以分担扩容的负担,每个线程只需处理一部分数据,这避免了单个线程处理大规模数据搬迁带来的性能开销。
总结:
ConcurrentHashMap 的渐进式扩容通过多个线程协作搬迁数据,避免了传统哈希表扩容时的性能瓶颈。这种设计大大提高了并发环境下的扩容效率,使 ConcurrentHashMap 能在保证高并发访问的同时,平稳完成扩容操作。
12. CopyOnWriteArrayList
CopyOnWriteArrayList 是 Java 中的一种线程安全的 List 实现,属于 java.util.concurrent 包。它的设计专门针对读操作远多于写操作的场景,通过写时复制(Copy-On-Write)机制确保线程安全。
核心概念:
CopyOnWriteArrayList 的线程安全性基于写时复制的思想,具体来说:
- 读操作:所有的读操作直接从当前的底层数组中读取数据,不需要加锁,读操作不会阻塞,也不会受到写操作的影响,因此读操作非常高效。
- 写操作:每次修改操作(如添加、删除、更新元素)时,都会复制底层数组,在新的数组上进行修改,然后将新的数组替换为当前的数组的引用。由于写操作会创建新的数组并复制现有的元素,因此写操作的性能开销较大,适合读多写少的场景。
- 写时复制机制确保了在多线程环境下,多个线程可以安全地进行并发读和写操作。由于写操作会生成一个新的数组,且写操作只影响当前线程的视图,因此不会影响其他线程正在执行的读操作。
- 弱一致性 :CopyOnWriteArrayList 的迭代器是弱一致性的(weakly consistent),它不会抛出 ConcurrentModificationException,即使在遍历的过程中有其他线程修改了列表的内容,迭代器依然会使用迭代开始时的数组快照,而不是最新的写操作结果。新加入的元素或修改的元素不会立刻对正在进行的读操作产生影响。
13. WeakHashMap
WeakHashMap 是 Java 中一种基于弱引用(WeakReference)的 Map 实现,位于 java.util 包中。它的设计目的是允许使用弱引用作为键的 Map,在垃圾回收时,如果某个键没有其他强引用存在,该键值对会被自动回收。
核心概念:
- 弱引用:WeakHashMap 中的键是使用 WeakReference 包装的,而值仍然是强引用。这样,键的生命周期不会被 WeakHashMap 保持活跃。如果某个键只被 WeakHashMap 引用,而没有其他强引用,则它可能在下一次垃圾回收时被回收。一旦 GC 回收了键,WeakHashMap中与该键关联的键值对也会被移除。
- Entry 结构:WeakHashMap 的条目(Entry)使用弱引用键,但值部分是强引用。WeakHashMap 通过使用 ReferenceQueue 追踪已经被回收的键,并在适当时将相应的键值对从 Map 中移除。
- 非线程安全:WeakHashMap 不是线程安全的。如果需要在多线程环境中使用,可以使用外部同步机制或选择 ConcurrentHashMap 等其他线程安全的 Map 实现。
- 迭代器的弱一致性:在迭代 WeakHashMap 时,键值对可能会在垃圾回收时自动删除,因此它的迭代器可能会看到删除后的状态。
示例代码:
java
public class WeakHashMapExample {
public static void main(String[] args) {
// 创建 WeakHashMap
WeakHashMap<String, String> weakMap = new WeakHashMap<>();
// 创建字符串作为键
String key1 = new String("Key1");
String key2 = new String("Key2");
// 放入键值对
weakMap.put(key1, "Value1");
weakMap.put(key2, "Value2");
// 输出 WeakHashMap
System.out.println("Before GC: " + weakMap);
// 将 key1 置为 null,准备让 GC 回收它
key1 = null;
// 强制执行垃圾回收
System.gc();
// 等待 GC 完成(GC 回收可能不会立刻发生)
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 再次输出 WeakHashMap
System.out.println("After GC: " + weakMap);
}
}
输出示例:
css
Before GC: {Key1=Value1, Key2=Value2}
After GC: {Key2=Value2}
在上述代码中,key1 变量被置为 null 后,垃圾回收器会回收它,WeakHashMap 也会自动删除与该键关联的键值对。
应用场景:
WeakHashMap 通常适用于那些键值对不需要长期保留,并且可以根据垃圾回收自动清理的场景,比如缓存机制。以下是一些典型的使用场景:
- 缓存:在缓存中,如果某个对象已经没有其他引用存在,则它可以自动从缓存中移除。WeakHashMap 适合用于此类缓存场景,因为它可以根据 GC 自动删除不再使用的键值对。
- 元数据存储:用于对象的元数据映射,当对象不再被引用时,元数据可以自动移除,不必手动清理。
- 弱引用监听器:WeakHashMap 可以用于维护监听器或观察者列表,当监听器不再需要时,可以自动移除。
总结:
WeakHashMap 是一种适合用于缓存和弱引用关联存储的 Map 实现,它允许垃圾回收器自动清理不再需要的键值对,减少内存使用。在那些不需要长期保留对象的场景中,它提供了一种自动内存管理的方案,避免了手动清理缓存的麻烦。
14. Hashtable
Hashtable 是 Java 中最早期提供的一种线程安全的键值对集合实现,位于 java.util 包中。它存储键值对并允许基于键快速访问值。Hashtable 是基于哈希表的,因此操作的时间复杂度通常为 O(1),但由于是同步的(线程安全),在现代应用中常被其他更高效的集合类替代,例如 ConcurrentHashMap。
特点:
- 线程安全 :
- Hashtable 的所有方法都是同步的,每个操作都通过在方法上加 synchronized 关键字实现线程安全,多个线程可以安全地访问和修改同一个 Hashtable 对象。它可以在多线程环境下安全使用。不过这种实现方式会带来性能上的开销,因为每次对 Hashtable 的访问(包括读取)都会加锁,可能导致性能瓶颈。
- 不允许 null 键或值 :
- Hashtable 不允许键或值为 null。如果尝试插入 null 键或 null 值,会抛出 NullPointerException。
- 基于哈希表的实现 :
- Hashtable 使用哈希表来存储数据,通过键的哈希值计算出该键应存放的位置。哈希冲突的解决使用的是链地址法(也称为拉链法),即同一个哈希位置的多个键值对会以链表的形式存储。
- 遍历时的快速失败机制 :
- Hashtable 的迭代器是快速失败的(fail-fast),即如果在迭代过程中,检测到 Hashtable 被修改(例如添加或删除元素),会抛出 ConcurrentModificationException。
- 扩容机制 :
- 和 HashMap 类似,Hashtable 也会在超过负载因子时进行扩容。默认情况下,Hashtable 的初始容量为 11,负载因子为 0.75。当元素数量超过当前容量的 75% 时,Hashtable 会自动扩容,通常是将容量增加一倍加一,并重新计算每个键的位置(重新哈希)。
Hashtable 与 ConcurrentHashMap 的区别:
特性 | Hashtable | ConcurrentHashMap |
---|---|---|
线程安全性 | 是(全表加锁) | 是(分段锁) |
并发性能 | 较差,所有操作都需要锁整个表 | 较好,允许多线程同时访问不同部分的数据 |
是否允许 null 键或值 | 不允许 | 不允许 |
扩容机制 | 全表重新哈希 | 分段扩容,性能更好 |
应用场景:
虽然 Hashtable 可以保证线程安全,但由于其同步机制较为粗粒度,性能较差,因此在现代开发中很少使用。常见的替代方案是使用 HashMap(在单线程环境下)或 ConcurrentHashMap(在多线程环境下)。
- 历史遗留系统:Hashtable 最早在 Java 1.0 中就被引入,因此在一些老旧系统中,可能会发现它的身影。
- 线程安全要求:在需要线程安全的场景下,且没有性能瓶颈时,可以使用 Hashtable。
- 更高性能需求:现代应用程序中,ConcurrentHashMap 是 Hashtable 的更好替代,尤其是在高并发环境下。
1. 为什么 HashTable 不能存 null 键和 null 值,而 HashMap 却可以 ?
Hashtable 不能存储 null 键和 null 值,主要是由于其设计和历史原因。以下是导致这一行为的几个关键原因:
1. 历史原因:
- Hashtable 是在 Java 1.0 时引入的,当时的设计较为简单和保守。设计者为了避免处理复杂的 null 值相关问题,选择不允许 null 键和 null 值。
- 当 HashMap 在 Java 1.2 引入时,设计上更为灵活,允许使用 null 作为键和值,因此它能够更好地适应现代开发需求。
2. 方法逻辑的复杂性:
- Hashtable 的某些方法,比如 get() 和 put(),需要依赖 hashCode() 和 equals() 方法。
- hashCode():Hashtable 依赖于键的 hashCode() 方法来计算键的哈希值。如果键为 null,调用 hashCode() 方法时会抛出 NullPointerException。
- equals():在判断两个键是否相等时,Hashtable 需要调用 equals() 方法。对于 null 值,调用 equals() 也会导致 NullPointerException。
因此,不允许存储 null 键可以避免处理这些特殊情况,简化了代码逻辑。
3. 线程安全的考虑:
- Hashtable 是一个线程安全的集合类,其所有操作都进行了同步处理。为了在并发环境下确保一致性,避免 null 引起的异常或竞争条件(如在 null 值的比较中出现未预期的结果),Hashtable 禁止存储 null 键和 null 值。
4. 与 null 值的语义冲突:
- null 值在 Map 中有时可以表示多种不同的语义,例如:
- 键不存在:如果某个键没有值,可以认为键不存在。
- 键存在但值为空:键存在,但值可能为 null。
在 Hashtable 中,如果允许 null 键或值,可能会导致这种语义混乱。为了避免这种模糊性,Hashtable 的设计中选择禁止 null 键和值。
对比 HashMap:
相比之下,HashMap 则允许 null 键和 null 值:
- HashMap 允许一个 null 键,并且该键总是存储在哈希表的第一个槽位中(索引为 0),从而避开了 hashCode() 的问题。
- HashMap 也允许多个 null 值,因为 HashMap 使用 == 来判断键是否相等,因此不会抛出异常。还有就是它在存储和取值时能够区分键是否存在与键的值是否为 null。null 值的处理在 HashMap 中比较直观,可以通过 containsKey() 方法判断键是否存在,而通过 get() 判断值是否为 null。
- HashMap 主要设计用于单线程环境,不涉及同步处理,因此处理 null 键和值更加容易实现。
9. 深拷贝 vs 浅拷贝
在 Java 中,深拷贝和浅拷贝是两种复制对象的方式,主要区别在于它们如何处理对象内部的引用类型字段。
1. 浅拷贝(Shallow Copy):
浅拷贝是创建一个新对象,但是复制的对象中所有引用类型的成员变量仍然指向原对象的内存地址,即它们与原对象共享同一块内存区域。因此,浅拷贝只会复制对象的"外层",而内部的对象仍然是共享的。
特点:
- 基本数据类型(如 int, char, double 等)会被复制,即它们的值会被直接拷贝。
- 引用类型 (如对象、数组等)则不会被复制,只是将它们的引用地址复制到新对象,因此它们仍然指向相同的内存地址。
2. 深拷贝(Deep Copy):
深拷贝不仅复制对象本身,还会递归地复制所有内部引用的对象,即在深拷贝中,所有引用类型的成员变量也都会被复制为新对象。因此,深拷贝之后,原对象和复制的对象完全独立,修改一个不会影响另一个。
特点:
基本数据类型 和 引用类型 都会被复制,引用类型的成员变量也会被创建新对象,不再指向同一个内存地址。
10. comparator vs comparable
在 Java 中,Comparator 和 Comparable 都是用于对象比较的接口,但它们有不同的用途和实现方式。
1. Comparable 接口:
- 定义位置:在对象类本身实现。
- 主要用途:用于定义对象的自然排序(默认排序),即对象自身具有比较的能力。
- 方法:需要实现 compareTo(T o) 方法。
- 排序方式:单一的排序方式,只能在类中定义一种排序规则。
2. Comparator 接口:
- 定义位置:在外部定义比较逻辑,不需要修改类的代码。
- 主要用途:用于定义多个排序规则(自定义排序),可以为同一类对象创建不同的排序方式。
- 方法:需要实现 compare(T o1, T o2) 方法。
- 排序方式:可以根据不同的需求定义多个比较器。
具体区别总结:
- Comparable:对象自身的排序,实现类必须实现 Comparable 接口。该接口用于定义该类的自然排序方式。
- Comparator:外部的比较器,不需要修改对象本身,可以通过多个 Comparator 实现不同的排序逻辑。
示例代码
1. 使用 Comparable 进行排序:
假设我们有一个 Person 类,按年龄排序:
java
class Person implements Comparable<Person> {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
// 实现 Comparable 接口中的 compareTo 方法
@Override
public int compareTo(Person other) {
// 按年龄升序排序
return Integer.compare(this.age, other.age);
}
@Override
public String toString() {
return name + " (" + age + ")";
}
}
public class ComparableExample {
public static void main(String[] args) {
List<Person> people = new ArrayList<>();
people.add(new Person("Alice", 30));
people.add(new Person("Bob", 25));
people.add(new Person("Charlie", 35));
// 使用自然排序 (按年龄排序)
Collections.sort(people);
System.out.println(people);
}
}
2. 使用 Comparator 进行排序:
假设我们希望根据姓名排序,而不是年龄,这时候可以使用 Comparator:
java
class NameComparator implements Comparator<Person> {
@Override
public int compare(Person p1, Person p2) {
// 按名字升序排序
return p1.getName().compareTo(p2.getName());
}
}
public class ComparatorExample {
public static void main(String[] args) {
List<Person> people = new ArrayList<>();
people.add(new Person("Alice", 30));
people.add(new Person("Bob", 25));
people.add(new Person("Charlie", 35));
// 使用 Comparator 自定义排序(按姓名排序)
Collections.sort(people, new NameComparator());
System.out.println(people);
}
}
11. extends vs super
在 Java 的泛型中,extends 和 super 是通配符的上下限,用于定义泛型类型的范围。它们允许在处理泛型类型时灵活地指定类型的继承关系,并在类型安全的前提下使用对象。
1. ? extends T 通配符:上界通配符
- 含义:表示类型是 T 本身或 T 的子类(即 T 或其子类)。
- 适用场景:当你只读泛型对象时可以使用 extends,因为你不确定具体类型是 T 还是其子类,但你知道对象至少是 T 类型的某个子类。
- 限制:只能读取,不能写入(除了 null),因为编译器无法确定你写入的类型是否安全。
java
public class ExtendsExample {
public static void printList(List<? extends Number> list) {
for (Number number : list) {
System.out.println(number);
}
}
public static void main(String[] args) {
List<Integer> intList = Arrays.asList(1, 2, 3);
List<Double> doubleList = Arrays.asList(1.1, 2.2, 3.3);
// 可以传入 Number 的子类列表,例如 Integer、Double
printList(intList); // 输出:1 2 3
printList(doubleList); // 输出:1.1 2.2 3.3
}
}
在这个例子中,? extends Number 允许 List 和 List 都作为参数传递,因为 Integer 和 Double 都是 Number 的子类。只能读取,因为无法确定泛型的具体类型。
2. ? super T 通配符:下界通配符
- 含义:表示类型是 T 本身或 T 的父类(即 T 或其父类)。
- 适用场景:当你需要写入泛型对象时使用 super,因为你只需确保可以向泛型集合中写入 T 类型或其子类型的对象。
- 限制:只能写入 T 类型或其子类,读取时只能返回 Object 类型,因为无法确定具体类型。
java
public class SuperExample {
public static void addNumbers(List<? super Integer> list) {
list.add(1); // 可以添加 Integer
list.add(2);
// 不能读取为 Integer,只能作为 Object 读取
Object obj = list.get(0);
}
public static void main(String[] args) {
List<Number> numList = new ArrayList<>();
List<Object> objList = new ArrayList<>();
// 可以传入 Integer 的父类列表,例如 Number、Object
addNumbers(numList);
addNumbers(objList);
}
}
在这个例子中,? super Integer 允许向列表中添加 Integer 类型的元素,但不能确定从列表中读取的元素的具体类型,读取的结果是 Object 类型,因为列表的元素可能是 Integer 的父类对象。
总结:
- extends 适用于对象是"生产者"的场景,允许读取但不允许写入(只能写 null)。
- super 适用于对象是"消费者"的场景,允许写入但读取返回的类型只能是 Object。
通过这两种通配符,可以在泛型集合中提供灵活的类型约束,同时保证类型安全。