Java 并发面试题(一)

时间:2025-04-03 08:12:55

什么是 Java 中的线程同步?

在 Java 里,线程同步是一种用于处理多线程环境下资源共享问题的机制。当多个线程同时访问和修改共享资源时,若没有合适的同步机制,就可能出现数据不一致、脏读等问题,这些问题会破坏程序的正确性和稳定性。线程同步能确保在同一时刻只有一个线程可以访问共享资源,从而避免这类问题的发生。

下面为你介绍 Java 中实现线程同步的几种常用方法:

1. synchronized 关键字

这是 Java 中最常用的同步机制,它可以修饰方法或者代码块。

  • 同步方法:在方法声明时添加 synchronized 关键字,这样同一时刻仅能有一个线程调用该方法。
class Counter {
    private int count = 0;

    // 同步方法
    public synchronized void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}
  • 同步代码块:当仅需对方法中的部分代码进行同步时,可使用同步代码块。
class Counter {
    private int count = 0;
    private final Object lock = new Object();

    public void increment() {
        // 同步代码块
        synchronized (lock) {
            count++;
        }
    }

    public int getCount() {
        return count;
    }
}

2. ReentrantLock 类

ReentrantLock 是 Java 5 引入的一个可重入锁,它提供了比 synchronized 更灵活的同步机制。

import java.util.concurrent.locks.ReentrantLock;

class Counter {
    private int count = 0;
    private final ReentrantLock lock = new ReentrantLock();

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }

    public int getCount() {
        return count;
    }
}

3. volatile 关键字

volatile 关键字主要用于保证变量的可见性,即一个线程修改了该变量的值,其他线程能立即看到修改后的值。不过,volatile 无法保证原子性,所以不能替代 synchronizedReentrantLock 来实现复杂的同步操作。

class SharedData {
    // 使用 volatile 关键字保证可见性
    private volatile int value;

    public void setValue(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }
}

通过这些同步机制,你可以在 Java 中安全地处理多线程访问共享资源的问题,确保程序的正确性和稳定性。

synchronized 关键字底层原理,真实项目实战案例使用

synchronized 关键字底层原理
在 Java 中,synchronized 关键字是实现线程同步的重要方式,它的底层实现主要依赖于 Java 对象头和 Monitor(监视器)机制。以下为你详细阐述:

  1. Java 对象头
    在 JVM 里,每个 Java 对象都有一个对象头(Object Header),其包含了一些与对象自身相关的运行时数据,像哈希码、分代年龄等。此外,对象头还会保存与锁相关的信息,这是实现 synchronized 锁的基础。对象头中的锁状态有以下几种:
    无锁状态:对象头未被任何线程锁定。
    偏向锁状态:在大多数情况下,锁总是由同一个线程多次获得,这时会使用偏向锁来提高性能。当一个线程首次访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时无需进行 CAS 操作来加锁和解锁,只需简单地测试对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。
    轻量级锁状态:当有另一个线程尝试竞争偏向锁时,持有偏向锁的线程会释放偏向锁,将对象头设置为轻量级锁状态。轻量级锁适用于线程交替执行同步块的场景,若多个线程同时访问同步块,轻量级锁会膨胀为重量级锁。
    重量级锁状态:当多个线程同时竞争同一个锁时,轻量级锁会膨胀为重量级锁。重量级锁依赖于操作系统的互斥量(Mutex)来实现,会导致线程的阻塞和唤醒,性能开销较大。
  2. Monitor(监视器)机制
    Monitor 是 Java 中实现同步的基础,它是一个同步工具,也可以理解为一种锁的实现。每个 Java 对象都可以关联一个 Monitor,当一个线程尝试访问被 synchronized 修饰的代码块或方法时,它需要先获得该对象关联的 Monitor。具体过程如下:
    进入同步块:线程尝试获取对象的 Monitor,如果 Monitor 处于空闲状态,线程会将其占用并进入同步块执行代码。
    竞争 Monitor:如果 Monitor 已经被其他线程占用,当前线程会被阻塞,进入等待队列,直到持有 Monitor 的线程释放它。
    释放 Monitor:当线程执行完同步块中的代码后,会释放 Monitor,并唤醒等待队列中的其他线程,让它们有机会竞争 Monitor。
    真实项目实战案例
    以下是一个使用 synchronized 关键字的实际项目案例,模拟一个银行账户的转账操作。在多线程环境下,多个用户可能同时对同一个账户进行转账操作,为了保证账户余额的一致性,需要使用 synchronized 关键字来实现线程同步。
class BankAccount {
    private double balance;

    public BankAccount(double initialBalance) {
        this.balance = initialBalance;
    }

    // 同步方法,保证线程安全的转账操作
    public synchronized boolean transfer(BankAccount target, double amount) {
        if (this.balance >= amount) {
            this.balance -= amount;
            target.balance += amount;
            System.out.println("转账成功:转出 " + amount + " 元,当前余额:" + this.balance + " 元");
            return true;
        } else {
            System.out.println("转账失败:余额不足");
            return false;
        }
    }

    // 同步方法,保证线程安全的查询余额操作
    public synchronized double getBalance() {
        return balance;
    }
}

public class Main {
    public static void main(String[] args) {
        BankAccount account1 = new BankAccount(1000);
        BankAccount account2 = new BankAccount(500);

        // 创建两个线程进行转账操作
        Thread thread1 = new Thread(() -> {
            account1.transfer(account2, 200);
        });

        Thread thread2 = new Thread(() -> {
            account1.transfer(account2, 300);
        });

        // 启动线程
        thread1.start();
        thread2.start();

        // 等待线程执行完毕
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 输出最终余额
        System.out.println("账户1余额:" + account1.getBalance() + " 元");
        System.out.println("账户2余额:" + account2.getBalance() + " 元");
    }
}    

代码解释
BankAccount 类:表示银行账户,包含一个 balance 字段用于存储账户余额。transfer 方法用于将当前账户的指定金额转账到目标账户,getBalance 方法用于查询当前账户的余额。这两个方法都使用了 synchronized 关键字进行修饰,确保在多线程环境下对账户余额的操作是线程安全的。
Main 类:创建了两个 BankAccount 对象,并启动两个线程分别对账户进行转账操作。通过 join 方法等待两个线程执行完毕,最后输出两个账户的最终余额。
通过使用 synchronized 关键字,确保了在多线程环境下对账户余额的操作是原子性的,避免了数据不一致的问题。

在真实项目中,synchronized关键字可能会带来哪些性能问题?如何优化?

synchronized 关键字可能带来的性能问题

1. 线程阻塞和唤醒开销

当一个线程获得 synchronized 修饰的锁时,其他试图获取该锁的线程会被阻塞。线程的阻塞和唤醒操作涉及到用户态和内核态的切换,这种切换的开销是比较大的。在高并发场景下,大量线程频繁地阻塞和唤醒会导致性能严重下降。

2. 锁的粒度问题

如果 synchronized 修饰的范围过大(锁的粒度太粗),会导致同一时间内只有一个线程能执行该范围内的代码,即使这些代码中的不同部分实际上可以并行执行。这会限制程序的并发度,降低系统的整体性能。

3. 偏向锁、轻量级锁的膨胀开销

在 Java 中,synchronized 锁存在偏向锁、轻量级锁和重量级锁的状态转换。当竞争激烈时,偏向锁和轻量级锁会膨胀为重量级锁,这个过程涉及到一系列的状态转换和操作,会带来额外的性能开销。

4. 死锁风险

使用 synchronized 关键字时,如果多个线程相互持有对方需要的锁,并且都在等待对方释放锁,就会导致死锁。死锁会使程序陷入无限等待的状态,严重影响系统的可用性和性能。

优化方案

1. 减小锁的粒度

尽量缩小 synchronized 修饰的代码范围,只对需要同步的关键代码进行加锁。这样可以减少线程之间的竞争,提高并发度。

例如,在一个电商系统中,对商品库存的更新操作需要加锁,但其他一些与库存无关的操作可以并行执行:

class Product {
    private int stock;

    public void updateStock(int quantity) {
        // 只对更新库存的关键代码加锁
        synchronized (this) {
            if (stock >= quantity) {
                stock -= quantity;
            }
        }
        // 其他与库存无关的操作,无需加锁
        doOtherThings();
    }

    private void doOtherThings() {
        // 一些与库存无关的操作
    }
}
2. 使用细粒度的锁

如果一个对象包含多个独立的状态变量,可以使用多个锁来分别保护这些变量,而不是使用一个大锁来保护整个对象。

例如,一个数据缓存类中包含两个独立的缓存:

class DataCache {
    private final Object cache1Lock = new Object();
    private final Object cache2Lock = new Object();
    private int[] cache1;
    private int[] cache2;

    public void updateCache1(int[] newData) {
        synchronized (cache1Lock) {
            this.cache1 = newData;
        }
    }

    public void updateCache2(int[] newData) {
        synchronized (cache2Lock) {
            this.cache2 = newData;
        }
    }
}
3. 锁分离

对于读写操作,可以使用读写锁(ReentrantReadWriteLock)来替代 synchronized 关键字。读写锁允许多个线程同时进行读操作,但在写操作时会独占锁,这样可以提高并发性能。

例如:

import java.util.concurrent.locks.ReentrantReadWriteLock;

class DataContainer {
    private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();
    private final ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();
    private int data;

    public int readData() {
        readLock.lock();
        try {
            return data;
        } finally {
            readLock.unlock();
        }
    }

    public void writeData(int newData) {
        writeLock.lock();
        try {
            data = newData;
        } finally {
            writeLock.unlock();
        }
    }
}
4. 无锁算法和数据结构

在某些场景下,可以使用无锁算法和数据结构来替代 synchronized 关键字,避免锁带来的性能开销。例如,使用 AtomicIntegerAtomicLong 等原子类来实现线程安全的计数操作。

import java.util.concurrent.atomic.AtomicInteger;

class Counter {
    private AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet();
    }

    public int getCount() {
        return count.get();
    }
}
5. 避免死锁

在设计多线程程序时,要合理安排锁的获取顺序,避免出现死锁的情况。可以使用资源分级的方法,确保所有线程按照相同的顺序获取锁。

例如,有两个资源 resource1resource2,所有线程都先获取 resource1 的锁,再获取 resource2 的锁:

class ResourceManager {
    private final Object resource1 = new Object();
    private final Object resource2 = new Object();

    public void operateResources() {
        synchronized (resource1) {
            synchronized (resource2) {
                // 执行操作
            }
        }
    }
}

通过以上优化方法,可以有效减少 synchronized 关键字带来的性能问题,提高程序的并发性能和响应速度。

偏向锁、轻量级锁、重量级锁分别在什么场景下使用?

偏向锁、轻量级锁和重量级锁是 Java 中 synchronized 关键字在不同场景下使用的锁状态,它们各自适用于不同的并发场景,以下为你详细介绍:

偏向锁

适用场景

偏向锁适用于只有一个线程频繁访问同步块的场景。在大多数实际应用中,很多同步块在整个生命周期内往往只被一个线程访问,此时偏向锁可以提供非常高效的同步机制。

原理与优势

当一个线程首次访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID。以后该线程在进入和退出同步块时,无需进行 CAS(Compare-And-Swap)操作来加锁和解锁,只需简单地测试对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。如果测试成功,表示该线程已经获得了锁,直接进入同步块执行代码,避免了加锁和解锁的开销,从而提高了性能。

示例场景

比如在一个单线程的 GUI 程序中,某个组件的状态更新方法被 synchronized 修饰,但在整个程序运行过程中,只有一个事件处理线程会调用这个方法,此时偏向锁就能发挥很好的作用。

轻量级锁

适用场景

轻量级锁适用于多个线程交替访问同步块的场景,即线程之间不存在锁竞争,或者锁竞争非常轻微。在这种场景下,使用轻量级锁可以避免重量级锁带来的线程阻塞和唤醒开销,提高程序的性能。

原理与优势

当线程进入同步块时,如果对象头的锁状态为无锁状态,JVM 会在当前线程的栈帧中创建一个锁记录(Lock Record),并使用 CAS 操作将对象头的 Mark Word 复制到锁记录中,同时将对象头的 Mark Word 指向锁记录的地址。如果 CAS 操作成功,线程就获得了轻量级锁;如果失败,表示有其他线程已经获得了锁,当前线程会尝试自旋等待锁的释放。轻量级锁的自旋操作避免了线程的阻塞和唤醒,减少了用户态和内核态的切换开销。

示例场景

在一个简单的生产者 - 消费者模型中,生产者线程和消费者线程交替访问共享的缓冲区,它们之间的锁竞争非常轻微,此时轻量级锁可以提供较好的性能。

重量级锁

适用场景

重量级锁适用于多个线程同时竞争同一个锁的场景,即锁竞争非常激烈的情况。当多个线程同时尝试获取锁时,轻量级锁会膨胀为重量级锁。

原理与优势

重量级锁依赖于操作系统的互斥量(Mutex)来实现。当一个线程尝试获取重量级锁时,如果锁已经被其他线程占用,该线程会被阻塞,进入等待队列,直到持有锁的线程释放锁并唤醒等待队列中的其他线程。虽然重量级锁的性能开销较大,但在高并发、锁竞争激烈的场景下,它可以保证线程的安全和正确执行。

示例场景

在一个高并发的 Web 服务器中,多个请求线程同时访问数据库连接池中的连接资源,由于连接资源有限,线程之间的锁竞争非常激烈,此时使用重量级锁可以保证连接资源的安全使用。

综上所述,偏向锁适用于单线程频繁访问同步块的场景,轻量级锁适用于线程交替访问同步块的场景,重量级锁适用于锁竞争激烈的场景。Java 的锁状态会根据实际的并发情况自动进行转换,以提供最佳的性能。

Java中的synchronized关键字和ReentrantLock有什么区别?

在 Java 里,synchronized 关键字和 ReentrantLock 都可用于实现线程同步,不过它们存在诸多区别,下面从多个方面为你详细介绍:

语法与使用方式

  • synchronized 关键字:它属于 Java 内置的关键字,使用起来较为简便。可以直接修饰方法或者代码块,无需手动释放锁,当同步方法或者同步代码块执行结束,锁会自动释放。
class SynchronizedExample {
    private int count = 0;

    // 同步方法
    public synchronized void increment() {
        count++;
    }

    public void anotherMethod() {
        // 同步代码块
        synchronized (this) {
            count++;
        }
    }
}
  • ReentrantLock:这是 java.util.concurrent.locks 包下的一个类,使用时需手动加锁和解锁。一般要在 try 块前调用 lock() 方法加锁,在 finally 块中调用 unlock() 方法释放锁,以此确保锁一定会被释放。
import java.util.concurrent.locks.ReentrantLock;

class ReentrantLockExample {
    private int count = 0;
    private final ReentrantLock lock = new ReentrantLock();

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }
}

锁的获取与释放

  • synchronized 关键字:锁的获取和释放是隐式的,由 JVM 自动处理。当线程进入同步方法或者同步代码块时,会自动获取锁;当线程执行完毕或者抛出异常时,会自动释放锁。
  • ReentrantLock:锁的获取和释放是显式的,需要手动调用 lock()unlock() 方法。这种显式的控制方式让代码的可读性和可维护性有所降低,但同时也提供了更高的灵活性。

锁的特性

  • 可重入性:两者都具备可重入性,也就是同一个线程可以多次获取同一把锁,而不会出现死锁的情况。当线程第一次获取锁时,锁的计数器会加 1;每重入一次,计数器就再加 1;每释放一次锁,计数器就减 1;当计数器为 0 时,锁才会被真正释放。
  • 公平性
    • synchronized 关键字:属于非公平锁,即线程获取锁的顺序和请求锁的顺序无关,可能后请求的线程反而先获得锁,这样虽然能提高吞吐量,但可能会导致某些线程长时间得不到锁。
    • ReentrantLock:既可以是公平锁,也可以是非公平锁。在创建 ReentrantLock 对象时,可以通过构造函数指定是否为公平锁。公平锁会按照线程请求锁的顺序来分配锁,保证每个线程都有机会获得锁,但会降低一定的吞吐量。
import java.util.concurrent.locks.ReentrantLock;

// 创建公平锁
ReentrantLock fairLock = new ReentrantLock(true);
// 创建非公平锁
ReentrantLock nonFairLock = new ReentrantLock(false);

锁的中断与超时

  • synchronized 关键字:当线程获取锁时,如果锁已经被其他线程持有,该线程会一直阻塞,直到获取到锁,并且无法被中断。
  • ReentrantLock:提供了更多的灵活性,支持锁的中断和超时。可以使用 lockInterruptibly() 方法在获取锁的过程中响应中断,使用 tryLock(long timeout, TimeUnit unit) 方法在指定的时间内尝试获取锁,如果超时仍未获取到锁,则返回 false
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

class LockInterruptiblyExample {
    private final ReentrantLock lock = new ReentrantLock();

    public void doSomething() throws InterruptedException {
        // 可中断地获取锁
        lock.lockInterruptibly();
        try {
            // 执行操作
        } finally {
            lock.unlock();
        }
    }

    public void tryLockWithTimeout() throws InterruptedException {
        // 在指定时间内尝试获取锁
        if (lock.tryLock(1, TimeUnit.SECONDS)) {
            try {
                // 执行操作
            } finally {
                lock.unlock();
            }
        } else {
            // 超时未获取到锁
        }
    }
}

条件变量

  • synchronized 关键字:配合 Object 类的 wait()notify()notifyAll() 方法来实现线程之间的等待 - 通知机制,但这些方法的使用比较复杂,并且只能关联一个条件。
  • ReentrantLock:通过 newCondition() 方法可以创建多个 Condition 对象,每个 Condition 对象都可以实现独立的等待 - 通知机制,使用起来更加灵活。
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

class ConditionExample {
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();

    public void await() throws InterruptedException {
        lock.lock();
        try {
            condition.await();
        } finally {
            lock.unlock();
        }
    }

    public void signal() {
        lock.lock();
        try {
            condition.signal();
        } finally {
            lock.unlock();
        }
    }
}

综上所述,synchronized 关键字使用简单,适合大多数简单的同步场景;而 ReentrantLock 提供了更多的高级特性,如公平锁、锁的中断和超时、条件变量等,适合对同步控制要求较高的复杂场景。

可重入锁和不可重入锁的区别是什么?

可重入锁和不可重入锁是两种不同类型的锁机制,它们在实现和使用场景上有明显的区别,下面从多个方面为你详细介绍:

定义与基本概念

  • 可重入锁:也叫递归锁,指的是同一个线程在已经持有该锁的情况下,可以再次获取该锁而不会被阻塞。每次获取锁时,锁的计数器会加 1;每次释放锁时,计数器会减 1。只有当计数器为 0 时,锁才会真正被释放。Java 中的 synchronized 关键字和 ReentrantLock 都属于可重入锁。
  • 不可重入锁:意味着同一个线程在已经持有该锁的情况下,如果再次尝试获取该锁,会被阻塞,直到锁被释放。这种锁不支持线程对同一把锁的重复获取。

代码示例对比

可重入锁示例
import java.util.concurrent.locks.ReentrantLock;

class ReentrantLockExample {
    private final ReentrantLock lock = new ReentrantLock();

    public void outerMethod() {
        lock.lock();
        try {
            System.out.println("获取外层锁"