当前位置: 首页 > article >正文

Java并发面试题(题目来源JavaGuide)

问题1:什么是线程和进程?线程与进程的关系,区别及优缺点?

1. 什么是进程(Process)?

  • 进程 是操作系统分配资源和调度的基本单位。它是一个 程序的执行实例,包括了程序代码、程序计数器、堆栈、数据区等资源。

  • 每个进程 都有独立的内存空间、文件描述符等资源。

进程的特点

  • 独立性:进程是操作系统调度的基本单位,进程之间相互独立,内存空间、文件资源等是隔离的。

  • 资源开销:每个进程都有独立的内存空间,因此创建和销毁进程的开销较大。

  • 并行性:多个进程可以在多核 CPU 上并行执行。


2. 什么是线程(Thread)?

  • 线程 是操作系统调度的最小单位。线程是 进程中的执行单元,是程序执行的基本单元。

  • 一个进程可以包含多个线程,这些线程共享进程的内存空间和资源。

线程的特点

  • 共享资源:同一进程内的多个线程共享进程的内存空间、文件描述符等资源。

  • 轻量级:相对于进程,线程更加轻量,创建和销毁的开销小。

  • 并发性:多个线程可以并行执行,尤其在多核 CPU 环境下,多个线程可以同时运行,极大地提高程序的处理能力。


3. 线程与进程的关系

3.1. 进程与线程的嵌套关系

  • 线程是进程的一部分:每个进程至少有一个线程,称为主线程。进程是资源分配的单位,线程是执行调度的单位。

  • 一个进程可以有多个线程:同一个进程的多个线程共享该进程的内存空间和资源(如文件描述符),而每个线程则有自己的程序计数器、堆栈和局部变量。

3.2. JVM 中的进程和线程

  • JVM(Java 虚拟机) 是运行 Java 程序的 进程,每个 JVM 实例是一个进程。

  • JVM 启动时会创建一个主线程,这个主线程执行 main 方法。

  • 在 Java 程序中,可以通过创建 多个线程 来并发执行任务(例如,使用 Thread 类或 ExecutorService),这些线程共享 JVM 进程的内存空间。


4. 线程与进程的区别

对比项

进程

线程

定义

进程是程序的执行实例,拥有独立的内存空间和资源。

线程是进程中的执行单元,同一进程的多个线程共享资源。

资源分配

每个进程有独立的内存空间和资源。

线程共享进程的内存空间和资源。

开销

创建和销毁进程的开销较大,需要分配独立的内存。

创建和销毁线程的开销较小。

调度单位

进程是操作系统分配资源的基本单位。

线程是操作系统调度的最小单位。

通信方式

进程间通信较复杂,使用 IPC(进程间通信)机制。

线程间通信非常容易,可以直接共享内存和资源。

并发性

支持多个进程的并发执行,性能较差。

多个线程可以在同一进程内并行执行,提升计算性能。

稳定性

一个进程崩溃不会影响其他进程。

线程崩溃可能导致整个进程崩溃。

切换开销

进程切换开销大,因为涉及内存切换。

线程切换开销小,因为线程共享进程的资源。


5. 线程与进程的优缺点

5.1. 进程的优缺点

优点

  • 隔离性好:每个进程有自己的虚拟内存空间,进程之间互相隔离,一个进程崩溃不会影响其他进程。

  • 稳定性强:进程之间的资源是完全独立的,因此不会发生线程级的资源竞争问题。

缺点

  • 开销大:每个进程需要单独分配内存空间和系统资源,因此创建和销毁进程的开销较大。

  • 资源占用大:进程拥有独立的内存空间,因此需要更多的系统资源。

  • 通信复杂:进程间通信(IPC)较为复杂,需要操作系统提供的机制(如管道、消息队列等)。

5.2. 线程的优缺点

优点

  • 轻量级:线程的创建和销毁比进程轻便,因为线程间共享同一进程的资源。

  • 资源共享:同一进程中的多个线程共享内存和资源,减少了内存的使用。

  • 高效的通信:线程间通信非常简单,可以直接通过共享内存进行通信,无需复杂的 IPC。

缺点

  • 稳定性差:多个线程共享同一进程的资源,如果某个线程崩溃或者发生死锁,可能导致整个进程崩溃。

  • 线程安全问题:由于线程间共享内存,容易发生 数据竞争竞态条件,因此需要同步机制(如 synchronizedReentrantLock)来保证线程安全。

  • 调试困难:多线程程序的调试比单线程程序复杂,死锁和竞态条件等问题比较难以发现和解决。


6. 总结

对比项

进程

线程

定义

进程是操作系统分配资源的基本单位。

线程是操作系统调度的最小单位,同一进程中的多个线程共享资源。

资源占用

进程有独立的内存空间,资源开销大。

线程共享进程的内存和资源,内存开销小。

切换开销

进程切换涉及内存切换,开销大。

线程切换只需要保存少量的上下文,开销小。

通信

进程间通信复杂,使用 IPC。

线程间共享内存和资源,通信简单。

稳定性

进程崩溃不会影响其他进程。

线程崩溃可能导致整个进程崩溃。

适用场景

适用于任务隔离较强的场景,如独立运行的应用程序。

适用于需要高效并发、共享数据的场景,如 Web 服务器。

面试要点

  • 进程 vs 线程:进程是资源分配的单位,线程是执行调度的单位。

  • 线程的优势:线程比进程更轻量,能够提高程序并发执行的能力。

  • 线程的挑战:线程安全问题、死锁和调试难度较高。

问题2: 为什么要使用多线程呢?

1. 计算机角度:充分利用多核 CPU 的能力

1.1. 多核 CPU 的并行处理

  • 现代计算机多配备 多核 CPU(如双核、四核、八核等),每个核心可以独立执行任务。

  • 单线程程序只能利用一个核心,无法充分发挥多核 CPU 的并行计算能力。

  • 多线程程序可以将多个线程分配到不同的核心上运行,从而 提高计算能力执行效率,在同一时间处理更多的任务。

例子:

  • 假设有一个 四核 CPU,如果程序使用四个线程,每个线程分配到一个核心上进行并行处理,四个线程将可以在 同一时刻并行运行,而不是依赖单核的顺序执行。这样,计算任务的总处理时间会大幅减少。


1.2. 任务并行处理

  • 多线程程序可以把多个任务分配到多个处理核心上并行执行,从而在 更短的时间内完成更多工作

  • 多核 CPU 可以并行执行多个线程,使得多个任务可以同时进行,不再需要等待其他任务完成。

例子:

  • 如果一个程序需要处理多个独立的数据集,每个数据集可以分配给一个线程,多个线程并行处理不同的数据集,减少了处理的总时间。


2. 项目角度:提升系统的性能与响应能力

2.1. 提升并发性能

  • 在需要同时处理多个任务的场景中,使用多线程可以显著 提高系统的并发性能

  • 例如,在 Web 服务器 中,使用多个线程可以同时处理多个用户的请求,每个请求都分配给一个独立的线程来处理,从而 减少响应时间提高吞吐量

例子:

  • 一个 Web 服务器使用 线程池 来处理每个客户端请求,多个客户端可以并行发起请求,服务器会将请求分配给空闲的线程进行处理,从而显著提升系统的处理能力和响应速度。

2.2. 提高系统响应性

  • 在图形用户界面(GUI)应用程序中,UI 线程负责用户交互。如果某个耗时任务(如文件下载、数据处理等)阻塞了 UI 线程,界面就会出现卡顿或无响应。

  • 使用多线程可以将这些 耗时的任务(如 I/O 操作、计算密集型任务)分配到 后台线程,而让 UI 线程 保持响应,避免界面冻结。

例子:

  • 在一个桌面应用程序中,文件下载任务可以在一个 后台线程 中执行,而 UI 线程 继续响应用户输入,如按钮点击、滚动等操作。


2.3. I/O 密集型任务的高效处理

  • I/O 操作(如文件读写、网络请求) 通常是程序的瓶颈,单线程程序必须等待 I/O 操作完成才能继续执行后续任务。

  • 使用 多线程 可以 并行处理多个 I/O 操作,这样当一个线程在等待 I/O 操作时,其他线程可以继续执行,从而提升整体处理效率。

例子:

  • 假设一个程序需要从多个文件中读取数据,使用多个线程同时读取不同的文件,能够显著提高数据读取的速度。


2.4. 实现任务的解耦和异步处理

  • 多线程 允许程序将任务 解耦,使得不同任务可以异步执行。例如,前端请求可以异步发起,等待后端处理结果时,不会阻塞其他用户请求。

  • 异步任务通过多线程运行,保证系统的 高效性低延迟

例子:

  • 在电商网站中,订单支付后可以将通知消息发送到另一个后台线程进行处理,而前端仍然可以向用户显示订单确认页面,避免了阻塞操作。


3. 多线程的挑战和风险

尽管多线程带来了明显的性能优势,但它也伴随着一些挑战和风险:

  • 线程安全问题:多个线程共享资源时,容易引发数据竞争,需要使用同步机制(如 synchronizedReentrantLock)来确保线程安全。

  • 死锁:多个线程互相等待对方释放资源,可能导致程序死锁,需要避免或使用超时机制来处理。

  • 调试困难:多线程程序的调试比单线程程序复杂,尤其是线程间的竞态条件、死锁等问题可能很难重现和调试。


4. 总结:为什么使用多线程?

使用多线程的原因

具体优势

提高并发性能

多线程能够同时处理多个任务,减少等待时间,增加任务处理量。

充分利用多核 CPU

多核处理器能够让多个线程并行执行,提高计算能力和处理速度。

提升响应性

将耗时任务放入后台线程执行,保持主线程响应,避免程序阻塞。

提高系统吞吐量

通过并发执行多个任务,提升系统整体的吞吐能力。

高效处理 I/O 密集型任务

多线程可以同时处理多个 I/O 操作,提高 I/O 性能。

解耦和异步处理

使用多线程将任务解耦,实现异步执行,提高效率和响应速度。

面试要点:

  • 并行 vs 并发:并行是多个任务同时执行,而并发是多个任务交替执行。多线程通常指并发执行。

  • 多线程的优势:主要在于 提高并发性能响应能力,在计算密集型、I/O 密集型任务中尤为重要。

问题3:说说线程的生命周期和状态? 

1. 线程的生命周期

线程的生命周期是指线程从创建到结束的整个过程,它经历多个状态阶段。Java 中的线程生命周期由 ThreadThread.State 枚举类管理。


2. 线程的状态

线程的状态由 Thread.State 枚举类定义,线程可以处于以下几种状态:

2.1. 新建(New)

  • 线程刚刚被创建但尚未启动,处于 新建状态

  • 线程通过 Thread 类的构造方法创建,线程对象一旦创建,但没有调用 start() 方法时,线程处于新建状态。

Thread thread = new Thread();

2.2. 就绪(Runnable)

  • 线程调用 start() 方法后进入 就绪状态,准备由操作系统的调度器(Scheduler)进行调度。

  • 在这个状态下,线程已经准备好执行,但是具体的执行时机取决于操作系统调度器,可能会等待 CPU 资源。

  • 注意:Java 中的 就绪状态运行状态 都属于 Runnable 状态,所以在 Java 中,Runnable 包含了两个阶段:就绪运行

thread.start();  // 线程进入就绪状态

2.3. 运行(Running)

  • 线程处于 运行状态 时,它正在执行代码,并占用 CPU 资源。

  • 只有操作系统的调度器选择了线程并为其分配了 CPU 时间片,线程才会进入运行状态。

  • 一旦线程获得 CPU 资源,它就开始执行任务,直到任务完成或被阻塞。

2.4. 阻塞(Blocked)

  • 线程处于 阻塞状态 时,它在等待某个外部条件发生(如 I/O 操作完成,锁资源可用等),无法继续执行。

  • 线程进入阻塞状态的原因:

    1. 等待 I/O:例如,等待文件读取、网络请求等。

    2. 等待锁:例如,线程尝试获取一个已经被其他线程占用的锁。

synchronized (object) {
    // 如果对象被其他线程锁定,当前线程会进入阻塞状态,直到锁可用
}

2.5. 等待(Waiting)

  • 线程处于 等待状态 时,线程处于一种 无条件等待 的状态,必须由其他线程通过 通知机制(如 notify()notifyAll())来唤醒。

  • 线程进入等待状态的常见方法:

    1. Object.wait():当前线程等待,直到其他线程调用 notify()notifyAll() 唤醒。

    2. Thread.join():一个线程等待另一个线程完成后再继续执行。

synchronized (object) {
    object.wait();  // 当前线程进入等待状态
}

2.6. 超时等待(Timed Waiting)

  • 线程进入 超时等待状态 时,它会等待指定的时间后自动恢复执行。

  • 常见方法:

    1. Thread.sleep(milliseconds):当前线程等待指定的时间,时间结束后线程恢复执行。

    2. Object.wait(milliseconds):线程等待指定的时间,时间到达后恢复。

    3. Thread.join(milliseconds):线程等待另一个线程最多指定时间后恢复。

Thread.sleep(1000);  // 线程休眠 1 秒后恢复

2.7. 终止(Terminated)

  • 线程执行完毕后,进入 终止状态。线程的生命周期结束,无法再次启动。

  • 当线程的 run() 方法执行完毕或者因为异常而终止时,线程进入终止状态。

public void run() {
    // 线程执行完毕后进入终止状态
}

 问题4:什么是线程死锁?如何避免死锁?如何预防和避免线程死锁?

线程 死锁 是指两个或多个线程在执行过程中,因为争夺资源而导致一种 互相等待的状态,从而 无法继续执行,形成一种死循环。换句话说,死锁发生时,线程互相等待对方释放资源,而每个线程都没有机会获得所需的资源,导致整个系统无法继续执行。

死锁的条件(四个必要条件)

线程发生死锁需要满足以下四个条件,这四个条件称为死锁的 必要条件

  1. 互斥条件(Mutual Exclusion):至少有一个资源必须处于 独占 状态,即某个资源每次只能被一个线程使用。

  2. 占有并等待(Hold and Wait):线程已经持有至少一个资源,并且在等待其他线程持有的资源。

  3. 非抢占条件(No Preemption):资源不能被强制从线程中抢占,线程只能在自己使用完资源后才会释放资源。

  4. 循环等待(Circular Wait):存在一组线程 {T1, T2, T3, ..., Tn},其中每个线程 Ti 都在等待下一个线程 Ti+1 持有的资源,而最后一个线程 Tn 又在等待线程 T1 持有的资源,从而形成一个环形依赖。

2. 死锁的代码示例

2.1. 死锁的代码实例

下面是一个经典的死锁示例,两个线程试图同时获取两个锁,导致死锁:

public class DeadlockExample {
    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("Thread 1: Holding lock 1...");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (lock2) {
                    System.out.println("Thread 1: Holding lock 1 and lock 2...");
                }
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (lock2) {
                System.out.println("Thread 2: Holding lock 2...");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (lock1) {
                    System.out.println("Thread 2: Holding lock 2 and lock 1...");
                }
            }
        });

        t1.start();
        t2.start();
    }
}

2.2. 解释死锁现象

  • 线程 1 获取了 lock1 锁后,试图获取 lock2 锁;

  • 线程 2 获取了 lock2 锁后,试图获取 lock1 锁;

  • 由于两个线程互相等待对方持有的锁,造成死锁,程序永远不会输出 Thread 1: Holding lock 1 and lock 2...Thread 2: Holding lock 2 and lock 1...

3. 如何避免死锁?

避免死锁主要有以下几种方法,最有效的策略是 减少锁的粒度控制锁的顺序

3.1. 避免嵌套锁(减少锁的粒度)

  • 不要让一个线程在持有某个锁的同时去申请其他锁,避免 多个线程持有多个锁

  • 如果必须使用多个锁,尽量在较短的时间内释放锁,避免长时间占用锁。

3.2. 控制锁的获取顺序(锁的顺序)

改进示例:

  • 保证 所有线程按照相同的顺序 获取锁,避免因线程获取锁的顺序不同而造成死锁。

    public class DeadlockFixed {
        private static final Object lock1 = new Object();
        private static final Object lock2 = new Object();
    
        public static void main(String[] args) {
            Thread t1 = new Thread(() -> {
                synchronized (lock1) {
                    System.out.println("Thread 1: Holding lock 1...");
                    try { Thread.sleep(100); } catch (InterruptedException e) {}
                    synchronized (lock2) {
                        System.out.println("Thread 1: Holding lock 1 and lock 2...");
                    }
                }
            });
    
            Thread t2 = new Thread(() -> {
                synchronized (lock1) {  // Ensure lock1 is always acquired first
                    System.out.println("Thread 2: Holding lock 1...");
                    try { Thread.sleep(100); } catch (InterruptedException e) {}
                    synchronized (lock2) {
                        System.out.println("Thread 2: Holding lock 1 and lock 2...");
                    }
                }
            });
    
            t1.start();
            t2.start();
        }
    }
    

    3.3. 使用 tryLock 避免死锁(超时锁定)

  • ReentrantLock 提供了 tryLock() 方法,能够尝试获取锁,如果获取不到锁,则返回 false,可以在等待锁时避免死锁。

  • 如果线程获取锁失败,可以 主动释放已持有的锁,避免死锁。

    import java.util.concurrent.locks.Lock;
    import java.util.concurrent.locks.ReentrantLock;
    
    public class DeadlockAvoided {
        private static final Lock lock1 = new ReentrantLock();
        private static final Lock lock2 = new ReentrantLock();
    
        public static void main(String[] args) {
            Thread t1 = new Thread(() -> {
                try {
                    if (lock1.tryLock() && lock2.tryLock()) {
                        System.out.println("Thread 1: Successfully acquired both locks");
                        // Do some work...
                    } else {
                        System.out.println("Thread 1: Couldn't acquire both locks, retrying...");
                    }
                } finally {
                    lock1.unlock();
                    lock2.unlock();
                }
            });
    
            Thread t2 = new Thread(() -> {
                try {
                    if (lock2.tryLock() && lock1.tryLock()) {
                        System.out.println("Thread 2: Successfully acquired both locks");
                        // Do some work...
                    } else {
                        System.out.println("Thread 2: Couldn't acquire both locks, retrying...");
                    }
                } finally {
                    lock1.unlock();
                    lock2.unlock();
                }
            });
    
            t1.start();
            t2.start();
        }
    }
    

    在这个例子中,tryLock() 会尝试获取锁,如果获取不到就不会一直阻塞,从而避免了死锁。

4. 如何排查和解决死锁?

4.1. 死锁排查

死锁问题在生产环境中可能较难发现和重现,以下是常用的排查方法:

  1. 查看线程堆栈信息

    • 可以通过 jstackThread.getAllStackTraces() 获取线程的堆栈信息。死锁的线程通常会显示出等待锁的相关信息。

  2. 使用 JVM 的 -XX:+PrintGCDetails 参数

    • 启动时加上此参数来打印 GC 信息,死锁信息通常会出现在日志中。

  3. 使用 jconsoleVisualVM 等监控工具

    • 这些工具可以帮助你查看线程的状态,监控是否存在 BlockedWaiting 的线程,并分析死锁。

4.2. 死锁解决方法

  1. 优化锁的获取顺序

    • 保证所有线程按照相同的顺序获取锁。

  2. 使用超时机制

    • 使用 tryLock() 来避免线程长时间等待,主动退出获取锁的循环。

  3. 减少锁的粒度

    • 尽量减少加锁的代码块,避免长时间占用锁。

  4. 使用高层次并发框架

    • Java 提供了如 ExecutorService 等并发框架,它们通常提供更高级的锁机制,减少死锁的风险。


5. 总结

  • 死锁 是由线程间互相等待资源而造成的无法继续执行的情况,导致程序无法前进。

  • 死锁的 四个必要条件 是:互斥条件、占有并等待、非抢占条件和循环等待。

  • 避免死锁 的方法有:控制锁的获取顺序、使用 tryLock()、减少锁的粒度等。

  • 在生产环境中,可以通过 线程堆栈分析JVM 监控工具 等方式来 排查死锁

问题5:synchronized 关键字 

1. synchronized 关键字的作用

synchronized 关键字用于 方法或代码块 上,控制访问共享资源的线程。它的作用是 保证同一时刻只有一个线程 可以访问被 synchronized 修饰的代码区域,从而避免了 多线程并发访问共享资源时的线程安全问题

1.1. synchronized 用法:

  • 修饰实例方法: 对整个实例方法加锁,锁定当前实例对象。

  • 修饰静态方法: 对静态方法加锁,锁定的是类的 Class 对象。

  • 修饰代码块: 可以对代码块加锁,锁定指定对象。

实例方法的 synchronized

public synchronized void method() {
    // synchronized code
}
  • 锁定当前对象 (this),保证同一个实例的多个线程访问此方法时是互斥的。

静态方法的 synchronized

public synchronized static void method() {
    // synchronized code
}
  • 锁定的是 类的 Class 对象,即所有实例共享同一个锁。

代码块的 synchronized

public void method() {
    synchronized(this) {
        // synchronized block
    }
}

锁定指定的对象(例如 this 或其他对象),可以精确控制锁的范围。

2. synchronized 关键字的底层原理

2.1. 监视器锁(Monitor)

  • synchronized 底层通过 对象监视器(Monitor) 来实现锁机制。每个对象在 JVM 中都有一个与之关联的锁(称为对象的监视器)。

  • 当线程执行 synchronized 修饰的方法或代码块时,它会尝试获得相应对象的 监视器锁,如果成功获得锁,则继续执行,执行完后释放锁;如果锁被其他线程持有,则该线程会进入 阻塞状态,直到锁被释放。

2.2. 锁的粒度

  • 锁的粒度由 synchronized 修饰的 代码块方法 确定。

  • 方法级锁:锁定的是整个方法(整个方法内的代码),不能同时执行。

  • 代码块级锁:锁定的是某一段代码,可以 精确控制锁的范围,提高效率。


3. JDK 1.6 之后 synchronized 的优化

JDK 1.6 引入了对 synchronized 关键字的多项优化,目的是减少 锁的竞争上下文切换的开销,提高多线程程序的性能。主要的优化包括:

3.1. 锁的分类

JDK 1.6 之后,JVM 引入了 锁的分级(锁粗化、偏向锁、轻量级锁和重量级锁)。这些优化目的是为了减少锁竞争的开销,提高性能。

3.1.1. 偏向锁(Biased Locking)

  • 偏向锁是一种 锁优化,在没有其他线程竞争的情况下,JVM 会让第一个获得锁的线程 偏向 于该锁,其他线程尝试获取该锁时,会被直接跳过。

  • 这样避免了不必要的锁竞争,提升单线程程序性能。

3.1.2. 轻量级锁(Lightweight Locking)

  • 如果线程持有锁之后没有遇到竞争,JVM 会采用 轻量级锁(通过 CAS 操作),避免了内核级的线程切换,减少了系统的开销。

  • 轻量级锁通过 CAS(Compare And Swap) 在锁的对象头部记录锁信息,当有多个线程竞争时,JVM 会升级为 重量级锁

3.1.3. 重量级锁(Heavyweight Locking)

  • 如果多个线程竞争锁,JVM 会 升级为重量级锁,这时锁的获取变得更加复杂,涉及到 操作系统内核的调度。线程的上下文切换和线程调度开销较大。

3.1.4. 锁粗化

  • 锁粗化是指将多个临近的 同步代码块 合并成一个大范围的同步代码块。减少了锁的获取和释放次数,优化了性能。


3.2. 锁的升级过程

  • 偏向锁轻量级锁重量级锁

    • 初始状态下,锁为 偏向锁,如果没有竞争,锁保持偏向状态。

    • 如果出现竞争,锁会升级为 轻量级锁,通过 CAS 操作快速获取锁。

    • 如果竞争激烈,锁会变成 重量级锁,这种锁会导致线程的上下文切换,性能损失较大。


4. synchronizedReentrantLock 的区别

4.1. synchronizedReentrantLock 的对比

特性

synchronized

ReentrantLock

锁的粒度

只能修饰方法或代码块,比较简单。

提供了更灵活的锁管理,支持 公平锁可中断锁 等功能。

是否可重入

是,允许同一线程多次进入。

是,ReentrantLock 也是可重入的。

是否支持公平锁

不支持公平锁(线程的获取锁顺序不确定)。

支持公平锁,可以确保线程按照请求锁的顺序获得锁。

中断支持

不支持中断,线程一旦进入 synchronized 代码块,无法中断。

支持中断,线程可以在等待锁的过程中被中断。

性能

JDK 1.6 后进行了优化,但仍然可能在高竞争时影响性能。

在高并发场景下性能优于 synchronized,因为锁管理更加灵活。

锁的获取方式

隐式获取锁,Java 编译器自动处理。

显式获取锁,需要调用 lock() 方法。

锁的释放方式

隐式释放锁,在方法执行完或代码块结束后自动释放锁。

需要手动调用 unlock() 方法释放锁。


5. synchronizedvolatile 的区别

synchronizedvolatile 都与多线程编程相关,但作用不同:

5.1. synchronizedvolatile 对比

特性

synchronized

volatile

作用

用于实现线程间的同步,保证线程安全。

保证可见性,确保修改后的变量对其他线程可见。

性能开销

相对较高,涉及到锁的获取和释放。

较低,主要是通过内存屏障保证可见性,没有锁的开销。

保证的功能

原子性可见性,保证多线程环境下的同步。

仅保证 可见性,不能保证操作的原子性。

使用场景

用于控制访问共享资源,避免数据竞争。

用于确保变量的 可见性,多线程中的共享数据访问。

是否涉及上下文切换

可能会涉及到线程上下文切换。

不涉及线程上下文切换。

5.2. 区别总结

  • synchronized:主要用于保证线程的 同步,防止线程间竞争共享资源,确保 原子性可见性

  • volatile:主要用于确保 变量的可见性,即一个线程修改的变量对其他线程立即可见,但 不能保证原子性


总结

  • synchronized 是一种简单且强大的线程同步工具,保证了线程间的 互斥访问同步,但可能存在性能开销。

  • 在 JDK 1.6 后,synchronized 进行了多项优化,包括 锁的升级(偏向锁、轻量级锁、重量级锁)和 锁粗化

  • ReentrantLock 提供了更丰富的功能,如 可中断锁公平锁显式加锁与解锁,在复杂的并发场景中比 synchronized 更灵活。

  • volatile 保证了变量的 可见性,而 synchronized 不仅保证可见性,还能保证 原子性

问题6: 并发编程的三个重要特性

1. 原子性 (Atomicity)

  • 定义:原子性是指某个操作是不可分割的,线程执行的过程中不可以被中断。原子性操作要么完全执行,要么完全不执行,不会出现执行一半的情况。

  • 实际表现:在多线程环境下,如果多个线程同时操作共享资源,原子性保证了一个线程的操作不会被其他线程打断,避免了数据竞争和不一致的情况。

  • 例子

    • synchronized 关键字可以用来保证代码块或方法的原子性,确保同一时刻只有一个线程可以访问该代码块。

    • AtomicInteger 等原子类使用底层的硬件指令(如 CAS)保证操作的原子性。

AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet(); // 保证原子性

2. 可见性 (Visibility)

  • 定义:可见性是指一个线程对共享变量的修改,能够被其他线程及时看到。即,一个线程对共享变量的修改对其他线程是可见的,避免了线程间的数据不一致问题。

  • 实际表现:在多线程环境中,如果一个线程修改了共享变量,其他线程需要能够看到这个修改,确保数据一致性。

  • 例子

    • 在 Java 中,volatile 关键字可以确保变量的修改对所有线程可见。

    • 使用 synchronizedLock 可以保证对共享资源的修改对其他线程可见。

private volatile boolean flag = false; // flag 的修改对其他线程可见

. 有序性 (Ordering)

  • 定义:有序性是指程序中代码的执行顺序与预期一致。在单线程中,代码的执行顺序是按照代码书写的顺序执行的;但在多线程环境下,JVM 或 CPU 可能会对指令进行重排序,因此需要通过同步机制来确保正确的执行顺序。

  • 实际表现:有序性确保了在多线程环境下,程序的执行顺序与我们在代码中编写的顺序一致,避免了因指令重排序引发的错误。

  • 例子

    • 使用 synchronizedvolatile 保证有序性。

    • 如果多个线程在访问共享变量时存在执行顺序的问题,可以通过 LockCountDownLatch 等机制进行协调。

private volatile int a = 0;
private volatile int b = 0;

public void method1() {
    a = 1;
    b = 2;
}

public void method2() {
    if (b == 2 && a == 1) {
        // 保证有序性,避免 b 更新后 a 还未更新的情况
    }
}

 问题7:JMM(Java Memory Model,Java 内存模型)和 happens-before 原则。

JMM (Java Memory Model)happens-before 原则 是 Java 并发编程中非常重要的概念,它们用来确保在多线程环境中,线程间的共享变量的可见性和有序性。理解这些概念对于编写正确且高效的并发程序至关重要。

1. JMM (Java Memory Model,Java 内存模型)

JMM 定义了 Java 程序中变量(共享变量)如何在不同线程之间进行交互以及如何保证线程安全。它描述了线程如何与主内存(即堆内存)进行交互,并定义了对共享变量的访问规则。

  • 主内存与工作内存

    • 主内存:所有共享变量存储的地方,是所有线程共享的内存区域(一般是堆内存)。

    • 工作内存:每个线程的私有内存,它保存了该线程使用到的共享变量的副本。

  • 共享变量

    • 线程对共享变量的操作(读、写)通常不是直接操作主内存中的数据,而是操作自己的工作内存中的副本。JMM 确保线程对共享变量的操作能够在适当的时候同步到主内存中,确保线程间的数据可见性和一致性。

  • JMM 的关键点

    • 可见性:一个线程对共享变量的修改能及时反映到其他线程中。

    • 有序性:程序中指令的执行顺序与代码顺序的匹配,避免由于指令重排序造成的错误。

  • JMM 的限制

    • 缓存一致性问题:多个线程可能会拥有共享变量的副本,这就会引发缓存不一致的问题。

    • 指令重排序:为了优化程序的性能,JVM 或 CPU 可能会对指令进行重排序,这会影响程序的执行顺序。

2. happens-before 原则

happens-before 原则是 JMM 中的一条规则,规定了一个操作如何保证它之前的操作对其他线程可见。它是理解并发程序正确性的关键,确保多线程执行时,程序行为符合预期。

  • happens-before 原则的定义

    • 如果操作 A happens-before 操作 B,那么操作 A 对线程 B 是可见的,操作 A 的执行结果会对 B 起作用。即,A 必须在 B 之前完成。

  • 常见的 happens-before 规则

1.程序顺序规则:在同一个线程内,前一个操作 happens-before 后一个操作。

  • 例如,代码 a = 1; b = 2; 中,a = 1 happens-before b = 2

2.锁规则:解锁操作 happens-before 随后的加锁操作。

  • 例如,lock.unlock() happens-before lock.lock()

3.volatile 变量规则:对一个 volatile 变量的写操作 happens-before 随后的对该变量的读操作。

volatile boolean flag = false;
flag = true;  // 写操作
if (flag) {   // 读操作
    // 此时可以保证 flag 的写操作对读操作可见
}

4.线程启动规则:对一个线程的 start() 调用 happens-before 该线程的任何操作。

Thread t = new Thread(() -> {
    // 线程执行代码
});
t.start(); // t.start() happens-before t 中的代码执行

5.线程终止规则:一个线程的 join() 方法发生在它所启动线程的执行之前。 

  • t.join() happens-before t 执行结束。

happens-before 的重要性

  • 保证内存可见性:通过 happens-before 规则,可以确保在一个线程对共享变量的修改能够被其他线程看到。比如,某线程对 volatile 变量的写操作,另一个线程在读取该变量时,能看到最新的值。

  • 保证程序有序性:happens-before 原则还保证了线程中操作的执行顺序不会被改变,使得多线程程序的行为符合预期。

JMM 与 happens-before 的关系

  • JMM 定义了线程如何通过工作内存与主内存交互,而 happens-before 原则 提供了保证不同线程间操作的执行顺序和可见性的规则。通过这些规则,JMM 能够确保多线程环境中的共享变量按照正确的顺序进行读写,并且能够保证线程间的操作可见。

总结

  • JMM (Java Memory Model):定义了 Java 中共享变量的访问规则,确保多线程程序中的可见性和有序性。

  • happens-before 原则:是 JMM 的重要组成部分,确保在多线程环境中,线程间的操作顺序和可见性。

这些概念帮助我们理解如何在 Java 中正确地编写并发程序,避免数据竞争、内存不一致和指令重排序等问题。

问题8: volatile 关键字

在 Java 中,volatile 是一个重要的关键字,它与 Java 内存模型 (JMM)happens-before 原则 密切相关,主要用于解决多线程环境中的 可见性 问题,确保线程之间共享变量的最新值能够及时同步。

volatile 的作用

volatile 关键字主要有两个作用:

  1. 保证可见性:保证多个线程对变量的修改对其他线程可见。

  2. 禁止指令重排序:确保在某些情况下,操作的顺序与代码书写的顺序一致。

1. 保证可见性

在没有 volatile 的情况下,线程对共享变量的修改会先写入本地线程的工作内存(即 CPU 缓存),并不会立即刷新到主内存。这可能导致其他线程读取的值不是最新的,造成数据不一致的问题。

通过 volatile 关键字修饰的变量,JVM 会确保每次对该变量的读写操作都会直接访问主内存,而不是从工作内存(线程私有缓存)中读取或写入,从而保证了线程之间共享变量的可见性。

JMM 中的可见性

  • volatile 变量的写操作,在其他线程读取该变量时,能够立即看到最新的值。

  • 在多线程环境中,如果一个线程修改了 volatile 变量的值,JVM 会保证这个修改在其他线程中可见。

举例:

public class VolatileExample {
    private volatile boolean flag = false;

    public void setFlag() {
        flag = true; // 写操作
    }

    public void checkFlag() {
        if (flag) {
            // 这里能够看到 setFlag() 写入的值
            System.out.println("Flag is true");
        }
    }
}

2. 禁止指令重排序

volatile 关键字还可以防止某些特定情况下的指令重排序。JVM 和 CPU 在执行指令时,可能会为了优化执行顺序而对指令进行重排序,这可能会破坏程序的逻辑顺序。使用 volatile 可以防止变量的读写操作被重排序,从而保证程序的执行顺序。

JMM 中的有序性

  • volatile 变量的读/写操作不会被重排序,即确保写操作在所有其他线程看到之前完成,读取操作会在看到写操作之前执行。

  • 使用 volatile 可以使得 happens-before 规则生效,确保某些操作的顺序性。

举例:

public class VolatileReorderingExample {
    private volatile int a = 0;
    private volatile int b = 0;

    public void write() {
        a = 1;    // 写操作
        b = 2;    // 写操作
    }

    public void read() {
        if (b == 2 && a == 1) {
            // 此时可以确保 b = 2 happens-before a = 1
            System.out.println("a = " + a + ", b = " + b);
        }
    }
}

在没有 volatile 的情况下,JVM 或 CPU 可能会重排序 a = 1b = 2 的执行顺序,导致 read 方法中 b == 2 时,a 可能并没有被设置为 1。但在使用 volatile 后,JMM 保证了写操作的顺序性,即 b = 2 happens-before a = 1

volatile 与 happens-before 原则

  • 对一个 volatile 变量的写操作 happens-before 对该变量的任何读操作。这是由 JMM 保证的。

  • 例如,一个线程写入 volatile 变量的值后,其他线程读取该变量时,可以保证读取到的是最新的值。

总结

  • volatile 关键字用于确保变量在多个线程间的可见性,同时它还禁止了指令重排序。

  • JMM (Java Memory Model) 中,volatile 变量的值直接在主内存中进行读写,保证了线程间的可见性。

  • happens-before 原则:对一个 volatile 变量的写操作 happens-before 随后的读操作,保证了线程之间的数据同步。

问题9:ThreadLocal 关键字 

ThreadLocal 是 Java 中用于实现 线程局部存储(Thread Local Storage,TLS)的机制,它允许每个线程都有独立的变量副本,这些副本在线程之间相互隔离。ThreadLocal 的主要目的是解决多线程环境中共享数据的问题,尤其是在需要线程私有数据时。

ThreadLocal 的底层原理

ThreadLocal 在底层通过一个 ThreadLocalMap 来实现的,每个线程都有一个 ThreadLocalMap,用于存储线程局部变量的副本。线程通过 ThreadLocalMap 存取自己的 ThreadLocal 变量副本。

实现细节:

  • 每个线程通过 Thread.currentThread() 获取当前线程的实例,再通过 ThreadLocalMap 存储对应的线程局部变量。

  • ThreadLocal 本质上是通过线程的 ThreadLocalMap 来为每个线程创建一个独立的存储空间。

  • ThreadLocalMap 的实现是 弱引用WeakReference)的,这意味着当一个 ThreadLocal 变量不再被引用时,它会被垃圾回收机制回收,这可以帮助避免内存泄漏。

内存模型

  • 线程内部的 ThreadLocalMap:每个线程持有一个 ThreadLocalMap,通过该映射关系线程能够存取线程局部变量的副本。

  • 键值对ThreadLocal 变量是 ThreadLocalMap 中的键,而 ThreadLocalMap 中的值是该线程对应的线程局部变量。

内存泄漏问题

尽管 ThreadLocal 能有效地实现线程局部存储,但如果不正确使用,它也可能导致 内存泄漏 问题。

为什么会有内存泄漏?

  • ThreadLocalMap 的键是 弱引用,这意味着当 ThreadLocal 变量不再被引用时,它会被回收。

  • ThreadLocalMap 中的值(即线程局部变量)仍然是 强引用,因此即使 ThreadLocal 变量已经被回收,对应的值也不会被及时清理。长时间持有对这些值的强引用可能会导致内存泄漏,尤其在高并发的环境中,线程池中的线程会重复使用,这时不及时清理线程局部变量就可能导致内存泄漏。

如何避免内存泄漏:

  • 手动清理:通过调用 ThreadLocal.remove() 方法,显式地移除线程局部变量,防止内存泄漏。
threadLocal.remove(); // 移除线程局部变量
  •  线程池中的使用:在使用线程池时,线程池中的线程可能会复用,因此如果没有及时清理 ThreadLocal 变量,可能会导致线程持有不再需要的变量,从而导致内存泄漏。

ThreadLocal 的应用场景

1.数据库连接: 在多线程环境中,常常需要为每个线程提供独立的数据库连接。通过 ThreadLocal 可以避免线程之间共享同一数据库连接,减少加锁带来的性能损耗。

private static ThreadLocal<Connection> connectionHolder = ThreadLocal.withInitial(() -> {
    return createDatabaseConnection();
});

public Connection getConnection() {
    return connectionHolder.get();
}

2.用户会话信息: 例如,在 Web 应用程序中,每个请求可能对应一个线程,而每个请求会有用户的会话信息。通过 ThreadLocal 可以存储线程私有的会话信息。

private static ThreadLocal<UserSession> userSession = new ThreadLocal<>();

public static UserSession getSession() {
    return userSession.get();
}

public static void setSession(UserSession session) {
    userSession.set(session);
}

 3.日志上下文: 在多线程日志记录中,可以通过 ThreadLocal 存储与线程相关的上下文信息(如日志级别、日志ID等),从而避免线程间的日志信息冲突。

线程池中的 ThreadLocal 使用

在使用线程池时,由于线程池中的线程会被复用,因此线程局部变量可能会被复用。为了避免内存泄漏问题,必须确保在线程完成任务后清除线程局部变量。通常在线程池任务完成后,调用 ThreadLocal.remove() 方法来清除线程局部变量。

ExecutorService executorService = Executors.newFixedThreadPool(10);

ThreadLocal<Integer> threadLocal = new ThreadLocal<>();

executorService.submit(() -> {
    try {
        threadLocal.set(100); // 每个线程可以存储独立的值
        // 执行任务
    } finally {
        threadLocal.remove(); // 任务完成后清理 ThreadLocal 变量
    }
});

总结

  • ThreadLocal 是 Java 提供的一种为每个线程提供独立存储的机制,适用于线程私有的数据,如数据库连接、用户会话信息、日志上下文等。

  • 底层原理:通过 ThreadLocalMap 来为每个线程提供独立的变量副本。

  • 内存泄漏问题:如果不及时清理 ThreadLocal 中的值,可能导致内存泄漏,尤其是在线程池中使用时,复用的线程可能持有不再需要的 ThreadLocal 数据。

  • 避免内存泄漏:使用 remove() 方法手动清理线程局部变量。

问题10:线程池 

线程池(Thread Pool)是一种线程管理技术,用于通过复用一组线程来提高系统的性能。使用线程池可以避免频繁地创建和销毁线程,减少系统资源消耗,提高响应速度,并能够有效地管理系统的线程。

线程池的类型

Java 中有多种不同类型的线程池,每种类型的线程池适用于不同的应用场景。常见的线程池类型由 Executors 工厂类提供:

1. FixedThreadPool(固定大小线程池)

  • 特点:线程池中的线程数量是固定的,不会发生变化。任务被提交到线程池后,线程池会分配线程来执行任务。如果所有线程都在工作,新的任务会被放入等待队列,直到有线程空闲时才会被执行。

  • 适用场景:适用于负载较均衡且线程数固定的应用场景。

  • 优点

    • 控制线程的数量,不会超出固定的线程数。

    • 性能稳定,适用于负载均衡的任务。

  • 缺点

    • 任务量较多时,可能会导致任务积压在队列中,等待线程空闲。

ExecutorService fixedThreadPool = Executors.newFixedThreadPool(4);  // 核心线程数为4

2. CachedThreadPool(缓存线程池)

  • 特点:线程池中的线程数量是动态的,线程池会根据任务的数量动态创建新的线程来处理任务。如果线程空闲时间超过一定时间,就会被销毁。

  • 适用场景:适用于执行很多短期异步任务的场景,且任务的数量和线程数目不确定。

  • 优点

    • 适用于任务量不确定的情况,线程池会根据需要创建和销毁线程。

    • 有较强的灵活性,能够适应高并发任务。

  • 缺点

    • 如果任务量很大,可能会导致创建过多的线程,增加系统负担,导致内存泄漏。

ExecutorService cachedThreadPool = Executors.newCachedThreadPool();  // 动态创建线程

3. SingleThreadExecutor(单线程化线程池)

  • 特点:线程池中只有一个线程,任务会按提交的顺序依次执行,确保任务的顺序执行。

  • 适用场景:适用于必须保证任务顺序执行,且执行任务的数量有限的场景。

  • 优点

    • 保证任务顺序执行。

    • 线程资源消耗低,避免了多线程管理的复杂性。

  • 缺点

    • 如果某个任务的执行时间过长,可能会影响后续任务的执行。

ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor();  // 仅有一个线程

4. ScheduledThreadPoolExecutor(定时线程池)

  • 特点:支持定时任务和周期性任务。可以定期或延迟地执行任务。

  • 适用场景:适用于周期性执行任务(例如定时清理缓存、定期发送邮件等)。

  • 优点

    • 可以定期执行任务,支持延迟任务和周期性任务的执行。

  • 缺点

    • 线程池中的线程数量是固定的,如果任务过多会阻塞其他任务的执行。

ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(2);  // 固定大小线程池
scheduledExecutorService.scheduleAtFixedRate(task, initialDelay, period, TimeUnit.SECONDS);  // 周期性任务

线程池的重要参数

  1. corePoolSize(核心线程数):

    • 核心线程池数量,即线程池在没有任务执行时,仍会保持的线程数量。

    • 如果线程池中有空闲的核心线程,这些线程将被复用,而不是新建线程。

  2. maximumPoolSize(最大线程数):

    • 线程池允许创建的最大线程数。当任务数量超过核心线程数时,线程池会创建新线程直到达到最大线程数。

  3. keepAliveTime(线程存活时间):

    • 非核心线程在空闲时可以存活的最大时间。超过这个时间没有新任务提交时,非核心线程会被销毁。

  4. BlockingQueue(任务队列):

    • 用于存储等待执行的任务。当线程池中的线程都在工作时,新的任务会进入队列等待。常见的队列有 ArrayBlockingQueueLinkedBlockingQueueSynchronousQueue 等。

  5. ThreadFactory(线程工厂):

    • 用于创建线程,可以通过 ThreadFactory 提供自定义的线程创建策略(例如设置线程名字、优先级等)。

  6. RejectedExecutionHandler(拒绝策略):

    • 当线程池达到最大线程数且任务队列已满时,线程池会根据指定的拒绝策略来处理新提交的任务。常见的拒绝策略有:

      • AbortPolicy(默认):丢弃任务并抛出异常。

      • CallerRunsPolicy:调用者线程处理任务(阻塞当前线程)。

      • DiscardPolicy:丢弃任务,不抛出异常。

      • DiscardOldestPolicy:丢弃队列中最旧的任务。

线程池的执行流程

  1. 提交任务:任务被提交到线程池。

  2. 任务排队:如果有空闲线程,线程池会直接分配线程执行任务。如果没有空闲线程且队列未满,任务将进入队列等待。

  3. 线程创建:当线程池中的线程数未达到最大线程数且队列已满时,线程池会创建新的线程来处理任务。

  4. 任务执行:线程池中的线程会从队列中取出任务并执行,直到任务执行完成。

  5. 线程回收:线程池中的非核心线程在空闲超时后会被回收。核心线程会一直保留,直到线程池关闭。

线程池的饱和策略

当线程池的线程数量达到 maximumPoolSize 且任务队列也已满时,线程池将采取以下几种饱和策略:

  1. AbortPolicy(默认):直接抛出 RejectedExecutionException 异常。

  2. CallerRunsPolicy:让调用者线程自己执行当前任务,阻塞当前线程直到任务完成。

  3. DiscardPolicy:直接丢弃当前任务,不抛出异常。

  4. DiscardOldestPolicy:丢弃任务队列中最旧的任务,然后尝试提交当前任务。

如何设置线程池的大小

线程池的大小需要根据实际情况进行设置:

  1. 核心线程数 (corePoolSize):一般情况下,设置为 CPU 核心数的大小或稍微大一些,可以根据业务场景进行微调。

  2. 最大线程数 (maximumPoolSize):设置为 CPU 核心数的 2 到 4 倍,避免过度创建线程导致资源浪费。对于 CPU 密集型任务,建议使用较小的线程池;对于 IO 密集型任务,线程池大小可以适当增加。

  3. 任务队列的选择:选择合适的任务队列也很重要,对于大量短小任务,SynchronousQueue 可能更合适,而对于长时间阻塞任务,LinkedBlockingQueue 更适合。

  4. KeepAliveTime:对于长时间不执行任务的线程,建议设置适当的 keepAliveTime,避免资源浪费。

int processors = Runtime.getRuntime().availableProcessors();  // 获取 CPU 核心数
ExecutorService threadPool = new ThreadPoolExecutor(
    processors,   // corePoolSize
    processors * 2,  // maximumPoolSize
    60L,  // keepAliveTime
    TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(100),  // 队列大小
    new ThreadPoolExecutor.CallerRunsPolicy()  // 饱和策略
);

 问题11:ReentrantLock 和 AQS

ReentrantLock 和 AQS

ReentrantLock 是 Java 提供的一个显式锁,它实现了 Lock 接口,提供了比 synchronized 关键字更高级的锁机制。它是基于 AbstractQueuedSynchronizer(AQS)框架实现的。

AQS 提供了一种框架,能够帮助开发者创建自己的同步器(如锁、信号量等),并且提供了一些常见的同步器所需要的功能,比如获取和释放锁、管理线程的排队等待、线程的中断支持等。

ReentrantLock 的特性

  1. 可重入性ReentrantLock 是可重入的,即同一个线程在获取锁后,可以再次获取该锁而不会发生死锁。它通过维护一个计数器来实现这一特性。当线程获取锁时,计数器加 1,当释放锁时,计数器减 1,直到计数器为 0 时,锁才真正被释放。

  2. 公平性和非公平性

    • 公平锁:ReentrantLock 可以通过构造方法指定是否为公平锁。公平锁保证了等待时间最长的线程会先获得锁,避免了线程饥饿的情况。

    • 非公平锁:如果不指定公平性,则默认使用非公平锁。非公平锁可能会导致某些线程长时间得不到执行,但能提高系统的吞吐量。

  3. 支持中断ReentrantLock 提供了 lockInterruptibly() 方法,允许线程在等待获取锁的过程中被中断,这对于响应中断的任务非常有用。

  4. 可查询状态ReentrantLock 提供了 isLocked()isHeldByCurrentThread() 方法,可以查询锁是否被占用,以及当前线程是否持有锁。

  5. 支持条件变量(Condition)ReentrantLock 提供了 newCondition() 方法,可以通过 Condition 对象来实现等待/通知机制,比 Object.wait()Object.notify() 更加灵活。

  6. 锁的公平性:通过构造函数的参数,可以选择公平锁还是非公平锁。

    • 公平锁(true):线程会按照请求锁的顺序排队。

    • 非公平锁(false,默认):新来的线程可能会抢先获取锁。

ReentrantLock 的实现原理

ReentrantLock 的实现基于 AQS(AbstractQueuedSynchronizer)。AQS 是一个抽象类,提供了一个通用的同步框架,可以帮助开发者实现不同类型的同步器。AQS 本质上是一个队列同步器,利用 FIFO 队列 来管理等待获取锁的线程。

AQS 的核心是 acquire()release() 方法,这两个方法负责线程的获取和释放资源。ReentrantLock 基于 AQS 实现了其获取和释放锁的功能。

AQS 的核心原理

AQS 的设计是基于 FIFO 队列锁状态 的。它的基本概念包括:

  1. 同步状态:AQS 维护一个整数值,代表锁的状态,通常用来表示锁的占用情况。不同的同步器可以根据自己的需求定义不同的同步状态。

  2. 队列:AQS 使用一个 CLH 队列(或称为 FIFO 队列)来管理线程。在一个线程等待获取锁时,它会被加入队列,直到它有机会获得锁。

  3. CAS(Compare-And-Swap):AQS 使用 CAS 操作来保证同步状态的原子性,确保对同步状态的更新是线程安全的。

  4. 状态获取与释放

    • tryAcquire(int acquires):尝试获取锁,如果当前锁的状态允许,返回 true,否则返回 false。通常会更新锁的状态。

    • tryRelease(int releases):尝试释放锁,更新锁的状态,唤醒等待队列中的其他线程。

    • acquire(int arg)release(int arg):分别是被 AQS 子类(如 ReentrantLock)调用来获取和释放资源的方法。

ReentrantLock 和 AQS 的工作流程

  1. 锁的获取

    • 当线程调用 lock() 时,它会调用 AQSacquire() 方法,尝试获取锁。

    • 如果当前没有其他线程持有锁,线程会成功获取锁并修改 AQS 中的同步状态。

    • 如果有其他线程持有锁,则当前线程会被加入到 AQS 的队列中,等待锁的释放。

  2. 锁的释放

    • 当线程调用 unlock() 时,它会调用 AQSrelease() 方法。

    • 当当前线程释放锁时,会更新锁的状态并通知队列中的下一个线程,使其有机会获取锁。

ReentrantLock 的主要方法

  1. lock()

    • 获取锁,如果锁被其他线程持有,则当前线程会被阻塞,直到获取到锁。

  2. unlock()

    • 释放锁,允许其他线程获取锁。

  3. lockInterruptibly()

    • lock() 不同,lockInterruptibly() 方法允许在等待锁时响应中断。如果线程在等待锁时被中断,方法会抛出 InterruptedException

  4. tryLock()

    • 尝试获取锁,立即返回,不会阻塞。如果锁可用,返回 true;如果不可用,返回 false。

  5. tryLock(long time, TimeUnit unit)

    • 尝试在指定的时间内获取锁。如果在指定的时间内能获取到锁,返回 true;否则返回 false。

  6. newCondition()

    • 创建一个 Condition 对象,可以用于线程的等待和通知机制。

ReentrantLock 与 synchronized 的比较

特性

ReentrantLock

synchronized

是否可重入

公平性

可选(公平/非公平)

无法控制(非公平)

中断支持

支持 lockInterruptibly()

不支持

多条件支持

支持通过 Condition

不支持

锁的获取/释放

显式调用 lock()/unlock()

隐式调用

性能(在高并发情况下)

一般较好,特别是在竞争较激烈时

较低

总结

  • ReentrantLock 是一个可重入的显式锁,它基于 AQS 实现了线程同步的功能。通过 AQS,ReentrantLock 可以控制锁的获取与释放、支持中断、条件变量、并提供公平锁和非公平锁的选择。

  • AQS 提供了一个框架,可以帮助开发者实现不同类型的同步器。通过 FIFO 队列和 CAS 操作,AQS 实现了线程的排队和状态管理,确保多线程环境下的同步操作能够安全、高效地进行。

  • synchronized 相比,ReentrantLock 提供了更灵活的控制方式,如中断响应、条件变量和公平性控制等。

问题12: 乐观锁和悲观锁的区别

乐观锁悲观锁是两种常见的锁策略,主要用于控制并发环境下对共享资源的访问。它们的区别在于对并发冲突的处理方式不同。

悲观锁 (Pessimistic Lock)

悲观锁是一种假设会发生冲突的策略,在操作共享资源之前,认为其他线程很可能会干扰当前线程的操作,因此在访问数据时,会对共享资源加锁,直到操作完成后释放锁。

特点:

  • 假设资源冲突:悲观锁的核心思想是“假设会发生冲突”,因此每次对共享资源进行修改时,都需要加锁。

  • 锁粒度大:因为加锁是防止冲突发生的一种手段,所以可能会导致较长时间的锁定,降低并发性能。

  • 阻塞:当一个线程获取锁时,其他线程必须等待直到锁释放。

  • 传统锁机制:比如 synchronizedReentrantLock 都是悲观锁的实现。

使用场景:

适用于冲突频繁的场景,例如银行账户操作(例如转账),多个线程对同一账户操作时,需要避免数据竞争。

synchronized (lockObject) {
    // 对共享资源进行修改
}

乐观锁 (Optimistic Lock)

乐观锁是一种假设不会发生冲突的策略。在进行数据操作时,乐观锁不加锁,而是通过某些机制(如版本号、时间戳等)来保证数据的安全性,只有在提交更新时才检查是否发生了冲突。如果发生冲突,则回滚或重试。

特点:

  • 假设资源不会冲突:乐观锁的核心思想是“假设不会发生冲突”,因此在操作资源时不加锁,只有在提交时才检测是否有其他线程修改过数据。

  • 锁粒度小:因为没有加锁,乐观锁允许较高的并发性能,适合资源冲突较少的场景。

  • 不阻塞:乐观锁不会阻塞其他线程,而是通过检查和更新来确保数据一致性。

  • 回滚/重试机制:如果在更新时发现资源被修改过,乐观锁会采取回滚或重试的方式来确保数据一致性。

使用场景:

适用于冲突较少的场景,如大量读操作,或者资源更新冲突不频繁的场景。例如数据库中的 select for update 语句、AtomicInteger 的 CAS 操作等。

// 使用版本号进行乐观锁控制
while (currentVersion == expectedVersion) {
    // 执行操作
    if (updateSuccess) {
        break;
    }
    // 重新获取版本号进行重试
}

两者的主要区别

特性悲观锁乐观锁
假设冲突假设会发生冲突假设不会发生冲突
加锁方式显式加锁不加锁,使用版本号、时间戳等机制检测冲突
性能性能较低,因为会有阻塞和锁竞争性能较高,因为不会阻塞,适合读多写少的场景
适用场景高冲突的场景,例如银行转账、库存扣减等低冲突的场景,例如缓存更新、批量处理等
实现synchronizedReentrantLock基于版本号、CAS 操作等

总结

  • 悲观锁:假设会发生冲突,每次操作时都加锁,确保数据的完整性,适用于冲突频繁的场景,但性能较低。
  • 乐观锁:假设不会发生冲突,通过版本号、时间戳等机制在提交时检测冲突,适用于冲突较少的场景,性能较高。

问题13:CAS 了解么?原理?什么是 ABA 问题?ABA 问题怎么解决?

CAS(Compare-And-Swap)原理

CAS(比较并交换)是一种原子操作,用于在并发编程中保证对共享变量的更新是线程安全的。CAS 是无锁的,它通过比较并更新内存中的值来避免锁的开销,从而提高并发性能。

CAS 操作涉及三个参数:

  1. 内存位置 V:需要更新的变量的内存地址。

  2. 期望值 A:当前内存位置的预期值。

  3. 新值 B:更新内存位置的值。

CAS 的操作步骤如下:

  • 如果内存位置 V 的当前值与期望值 A 相等,那么 CAS 会将 V 的值更新为新值 B。

  • 如果 V 的当前值与期望值 A 不相等,那么 CAS 不会执行更新操作,并返回当前 V 的值。

CAS 是原子的,这意味着在执行操作时不会被中断。CAS 被广泛用于并发编程中,特别是基于无锁数据结构的实现。

CAS 的优缺点

优点

  • 无锁:CAS 是无锁的操作,不需要加锁,因此可以避免因加锁造成的线程阻塞和上下文切换开销。

  • 高效:CAS 的性能通常优于传统的加锁方法,尤其在数据冲突较少的情况下。

缺点

  • ABA 问题:CAS 只会检查当前值与期望值是否一致,但它无法知道中间值是否变化过。

  • 自旋:如果 CAS 操作失败,线程会反复尝试(自旋),在高并发情况下可能会导致 CPU 的高开销。

ABA 问题

ABA 问题是 CAS 操作中的一个典型问题。它描述的是这样一种情况:假设一个线程执行 CAS 操作时,变量的值发生了变化,但是这些变化在最终执行时被忽略了,导致 CAS 错误地认为值没有变化,从而执行了更新操作。

具体来说:

  • 假设一个线程 A 读取了变量 X 的值为 A,执行了 CAS 操作,并且期望 X 的值为 A。

  • 但是,在这个过程中,另一个线程 B 修改了 X 的值,将它从 A 更新为 B,再将它恢复为 A。

  • 此时,线程 A 仍然认为 X 的值没有发生变化,因为它的值最终又变回了 A,但实际上 X 已经发生了变化。

这个问题的根本原因是 CAS 只关心变量的当前值,而不关心变量的历史变动。

ABA 问题的解决方法

为了解决 ABA 问题,常见的做法是引入 版本号时间戳,以便在 CAS 操作中能够检测到变量值的变化。

1. 引入版本号(版本控制)

  • 通过为每个共享变量引入一个版本号来解决 ABA 问题。每次变量更新时,不仅更新变量的值,还更新版本号。

  • 在 CAS 操作时,除了检查值是否一致外,还要检查版本号是否一致。如果版本号不同,说明变量经历了变化。

实现方法:使用 AtomicStampedReference 类,它将数据值和版本号结合起来,通过版本号的改变来防止 ABA 问题。

示例

AtomicStampedReference<Integer> reference = new AtomicStampedReference<>(100, 0);
int[] stamp = new int[1];
Integer currentValue = reference.get(stamp);
if (reference.compareAndSet(currentValue, 200, stamp[0], stamp[0] + 1)) {
    // 成功更新
}

2. 使用时间戳

  • 每个更新操作都附带一个时间戳,CAS 操作时也检查时间戳。如果时间戳不同,说明变量发生了变化。

3. 使用 AtomicMarkableReference

  • 这个类通过一个布尔标记来表示变量是否发生了变化。每次更新操作时,不仅更新数据值,还更新标记,CAS 操作时通过比较标记来防止 ABA 问题。

实现方法:使用 AtomicMarkableReference,它封装了值和一个标记,标记位用于表示是否发生了变化。

CAS 的实际应用

  1. java.util.concurrent.atomic 包中的类

    • Java 提供了多种基于 CAS 的类,比如 AtomicIntegerAtomicLongAtomicReference 等。

    • 这些类使用 CAS 来保证原子性,例如,AtomicInteger 类的 incrementAndGet() 方法就使用了 CAS 操作。

  2. ConcurrentHashMap

    • ConcurrentHashMap 使用 CAS 来保证并发修改时的线程安全。在 putremove 操作中,当多个线程尝试更新同一个槽时,ConcurrentHashMap 使用 CAS 来检查槽的值并确保正确更新。

  3. CopyOnWriteArrayList

    • CopyOnWriteArrayList 是一种线程安全的集合,它通过 CAS 操作来确保在更新元素时不发生冲突。

总结

  • CAS(Compare-And-Swap) 是一种高效的原子操作,它通过比较当前值与期望值是否一致,来决定是否进行更新。

  • ABA 问题是 CAS 操作中的一个潜在问题,指的是中间的值发生了变化,但最终值变回原来的值,导致 CAS 错误地认为值没有变化。

  • 解决 ABA 问题的方法包括使用版本号(通过 AtomicStampedReference)、时间戳、或者标记(如 AtomicMarkableReference)来检测数据变化。

  • CAS 被广泛应用于并发编程中,尤其是在 Java 的 java.util.concurrent.atomic 包中的类,如 AtomicIntegerConcurrentHashMap,都通过 CAS 来保证线程安全。


http://www.kler.cn/a/534952.html

相关文章:

  • 10. k8s二进制集群之Kube Scheduler部署
  • Docker 国内最新可用镜像源20250205
  • 2025开源DouyinLiveRecorder全平台直播间录制工具整合包,多直播同时录制、教学直播录制、教学视频推送、简单易用不占内存
  • Linux 设备驱动分类(快速理解驱动架构)
  • Linux 安装 RabbitMQ
  • 014-STM32单片机实现矩阵薄膜键盘设计
  • 算法设计与分析三级项目--管道铺设系统
  • css-根据不同后端返回值返回渲染不同的div样式以及公共组件设定
  • Spring JDBC模块解析 -深入SqlParameterSource
  • 论文解读 | NeurIPS'24 Spotlight ChronoMagic-Bench 评估文本到视频生成的质变幅度评估基准...
  • B站自研的第二代视频连麦系统(上)
  • 拧紧“安全阀”,AORO-P300 Ultra防爆平板畅通新型工业化通信“大动脉”
  • .net的一些知识点3
  • Windows本地部署DeepSeek-R1大模型并使用web界面远程交互
  • 网络面试题(第一部分)
  • 7.攻防世界 wzsc_文件上传
  • 深度学习与搜索引擎优化的结合:DeepSeek的创新与探索
  • Excel中对单列数据进行去重筛选
  • npx tailwindcss init报错npm error could not determine executable to run
  • Langchain教程-1.初试langchain
  • Spring 核心技术解析【纯干货版】- X:Spring 数据访问模块 Spring-Orm 模块精讲
  • Golang: 对float64 类型的变量进行原子加法操作
  • ESP32开发学习记录---》GPIO
  • 第四十六天|动态规划|子序列|647. 回文子串,5.最长回文子串, 516.最长回文子序列,动态规划总结篇
  • Mac 终端命令大全
  • 记录 | WPF创建和基本的页面布局