Java篇:Java线程安全知识要点详解和大厂高频面试题

在字节跳动、腾讯等大厂校招时,出现Java线程安全面试题是非常高频的。牢固学习好相关知识要点,熟背好相关八股文,面试时才容易脱颖而出。

一、线程安全概念

1.1 线程安全定义

在多线程编程的复杂领域中,线程安全是一个核心且关键的概念。简单来说,线程安全指的是在多线程环境下,代码或者数据结构能够正确无误地处理并发访问,进而确保数据的一致性和完整性。

想象一下,在一个繁忙的火车站售票大厅里,多个售票窗口同时为旅客售票。这里的车票库存就好比是共享资源,而每个售票窗口就如同一个线程。如果没有一套合理的售票机制,就可能出现多个窗口同时卖出同一张车票的情况,这显然是不合理的。在多线程编程中,这种情况就被称为线程不安全。

为了确保线程安全,开发者需要采用合适的同步措施。这些措施就像是火车站售票系统中的规则,用来规范各个售票窗口的售票行为。例如,使用锁机制,就如同在某一时刻只允许一个售票窗口进行售票操作,其他窗口需要等待;volatile关键字则可以保证数据的可见性,就像让所有售票窗口都能实时看到车票库存的变化;原子操作则确保了某些操作的不可分割性,类似于一次完整的售票过程不能被中断。

但是,在采取这些同步措施时,开发者需要谨慎权衡。因为过度的同步会带来性能上的损耗,就像如果每个售票窗口售票时都要进行繁琐的核对流程,那么整个售票效率就会大大降低。所以,在多线程环境中,实现线程安全需要在保证数据正确性的同时,兼顾系统的性能。

1.2 线程不安全示例

1.2.1 示例 1:售票系统

class TicketSeller {
    private int tickets = 10;

    public void sell() {
        if (tickets > 0) {
            System.out.println("Sold ticket: " + tickets--);
        }
    }
}

在这个简单的售票系统示例中,我们创建了一个TicketSeller类,它有一个表示车票数量的private成员变量tickets,初始值为 10。sell方法用于模拟售票操作,当车票数量大于 0 时,打印售出的车票信息并将车票数量减 1。

然而,在多线程环境下,这个简单的代码会出现问题。假设现在有两个线程 A 和 B 同时执行sell方法。可能会出现以下情况:线程 A 读取到tickets的值为 1(此时还未执行减 1 操作),线程 B 也读取到tickets的值为 1(因为线程 A 还未修改成功),然后线程 A 执行减 1 操作,tickets变为 0,接着线程 B 也执行减 1 操作,tickets变为 -1 。这显然不符合实际情况,出现了超售的问题,这就是典型的竞态条件。

1.2.2 示例 2:银行账户

class BankAccount {
    private double balance = 0.0;

    public void deposit(double amount) {
        balance += amount;
    }

    public void withdraw(double amount) {
        balance -= amount;
    }
}

在这个银行账户的示例中,BankAccount类有一个表示账户余额的private成员变量balance,初始值为 0.0。deposit方法用于存款,withdraw方法用于取款。

当多个线程同时操作这个银行账户时,就可能出现问题。比如,线程 A 读取到balance的值为 100.0(假设),线程 B 也读取到balance的值为 100.0。然后线程 A 执行存款操作,增加 50.0,此时balance应该变为 150.0,但还未写回内存。接着线程 B 执行取款操作,减少 30.0,由于它读取的是旧值 100.0,所以操作后balance变为 70.0。最后线程 A 将计算后的 150.0 写回内存,覆盖了线程 B 的操作结果,导致最终余额出现错误。这是因为线程之间对共享变量balance的操作存在内存可见性问题,一个线程对变量的修改,其他线程不能及时看到。

1.2.3 示例 3:ArrayList 操作

List<String> list = new ArrayList<>();

for (int i = 0; i < 10000; i++) {
    new Thread(() -> {
        list.add(Thread.currentThread().getName());
    }).start();
}

System.out.println(list.size());

在这个示例中,我们创建了一个普通的ArrayList,然后启动 10000 个线程,每个线程都向这个ArrayList中添加一个元素,元素值为当前线程的名称。最后打印ArrayList的大小。

由于ArrayList本身不是线程安全的,在多线程环境下,多个线程同时执行add方法时,可能会出现问题。例如,多个线程同时尝试修改ArrayList的内部结构,可能导致元素添加失败或者数据被覆盖。最终打印出的list.size()的值可能会小于 10000,这就是集合类在多线程环境下的线程安全性问题。

通过以上这些示例,我们可以清楚地看到在多线程环境中,如果不处理好线程安全问题,程序的行为将变得不可预测,可能会导致数据错误、程序崩溃等严重后果。因此,深入理解线程安全问题的原因并掌握相应的解决方法是非常必要的。

二、线程安全问题原因

2.1 竞态条件

竞态条件是多线程编程中引发线程安全问题的一个主要原因。它就像一场没有规则的赛跑,多个线程在几乎相同的时间内访问和修改同一个资源,由于它们的操作顺序和时机充满不确定性,最终导致数据状态变得不符合我们的预期。

竞态条件常常出现在临界区中,所谓临界区,就是那些多个线程可能同时访问和修改共享资源的代码区域。我们可以把临界区想象成一个狭窄的通道,多个线程都想通过这个通道去访问和修改共享资源,但是如果没有合理的调度,就会造成混乱。

以计数器问题为例,假设有如下代码:

class Counter {
    private int count = 0;

    public void increment() {
        count++;
    }
}

这段代码看似简单,但是在多线程环境下却隐藏着竞态条件的风险。假设现在有两个线程 A 和 B 同时执行increment方法。线程 A 读取count的值为 10(假设),就在它还没来得及执行加 1 操作时,线程 B 也读取了count的值,同样是 10。然后线程 A 执行加 1 操作,count变为 11,接着线程 B 也执行加 1 操作,由于它读取的是旧值 10,所以最终count变为 11,而不是我们期望的 12。这就是因为竞态条件,两个线程在对count进行操作时,都没有考虑到对方的操作,导致结果出现偏差。

竞态条件不仅仅局限于简单的算术运算,在复杂的业务逻辑中也可能出现。比如在一个电商系统中,多个用户同时抢购同一件商品,商品的库存数量就是共享资源,如果对库存的修改操作没有处理好,就可能出现超卖的情况。

为了应对竞态条件,我们可以采取多种措施。比如使用同步机制,像synchronized关键字或者显式锁(ReentrantLock),它们就像是在临界区的入口设置了一个关卡,同一时刻只允许一个线程进入临界区,从而避免多个线程同时操作共享资源。另外,还可以使用细粒度锁,将大的锁范围拆分成多个小的锁,这样可以减少锁的竞争,提高并发性能。同时,我们也可以使用原子操作,例如java.util.concurrent.atomic包中的AtomicInteger类,它提供的方法是线程安全的,可以避免竞态条件的发生。

2.2 内存可见性

内存可见性问题是多线程编程中另一个重要的线程安全隐患,它的根源在于 Java 内存模型(JMM)中主内存和工作内存的独特架构。

在 JMM 中,主内存就像是一个公共的仓库,存储着所有线程共享的变量。而每个线程都有自己的工作内存,就好比是线程的私人仓库,线程在操作共享变量时,并不会直接在主内存中进行,而是先将变量从主内存复制到自己的工作内存中,然后在工作内存中进行操作,操作完成后再将结果写回主内存。

这种架构虽然在一定程度上提高了程序的执行效率,但也带来了内存可见性的问题。具体来说,当一个线程对共享变量进行修改后,它只是将修改后的值存储在了自己的工作内存中,此时其他线程的工作内存中的变量副本还是旧的值,如果其他线程没有及时从主内存中读取最新的值,就会导致数据不一致。

我们来看一个简单的示例:

class VolatileDemo {
    private boolean started = false;

    public void startSystem() {
        started = true;
    }

    public void checkStartes() {
        if (started) {
            System.out.println("system is running");
        } else {
            System.out.println("system is not running");
        }
    }
}

在这个例子中,假设有两个线程,线程 A 负责调用startSystem方法将started设置为true,线程 B 负责调用checkStartes方法检查系统是否启动。由于内存可见性问题,可能会出现这样的情况:线程 A 将started设置为true后,这个修改还没有来得及同步到主内存,而线程 B 此时从自己的工作内存中读取started的值,仍然是false,所以它会输出 “system is not running”,这显然与我们的预期不符。

为了解决内存可见性问题,JMM 引入了一些重要的机制。首先是volatile关键字,当一个变量被声明为volatile时,它会强制线程在对该变量进行写操作时,立即将修改后的值同步到主内存中,并且在读取该变量时,直接从主内存中获取最新的值,从而保证了变量的可见性。其次,synchronized关键字和Lock接口也可以解决内存可见性问题,它们在进入和退出同步块时,会自动刷新和读取主内存,确保了变量的一致性。另外,final关键字也能确保被其修饰的变量在初始化完成后对其他线程的可见性。

2.3 指令重排序

指令重排序是现代处理器和编译器为了优化程序执行效率而采用的一种技术,它在单线程环境下能够有效地提高程序的运行速度,然而在多线程环境中却可能引发严重的线程安全问题。

指令重排序主要包括编译器优化重排、指令级并行重排和内存系统重排这三种类型。编译器为了优化代码,可能会对指令的执行顺序进行调整;在处理器中,为了提高指令的并行度,也会对指令进行重排;同时,内存系统为了提高访问效率,也可能会对内存访问指令进行重排。这些重排操作在单线程环境下通常不会有问题,因为它们都是在遵循一定规则的前提下进行的,并且最终的执行结果与代码的顺序执行结果是一致的。

但是在多线程环境中,情况就变得复杂了。指令重排序可能会导致原本有序的操作变得混乱,从而引发数据不一致和竞态条件等问题。例如,有如下代码:

int x = 0;
boolean flag = false;

// 线程1执行
x = 1;
flag = true;

// 线程2执行
if (flag) {
    System.out.println(x);
}

在这个例子中,按照代码的逻辑,当线程 2 执行if (flag)时,如果flagtrue,那么x应该已经被赋值为 1,所以应该输出 1。然而,由于指令重排序的存在,线程 1 中的x = 1flag = true这两个操作可能会被重排,flag = true可能会先执行,此时线程 2 执行if (flag)时,flagtrue,但x还没有被赋值为 1,所以可能会输出 0,这就导致了程序的逻辑错误。

为了解决指令重排序带来的问题,Java 内存模型(JMM)引入了一系列机制。其中,volatile关键字起到了重要的作用,它通过在volatile变量的读写操作前后插入内存屏障,禁止编译器和处理器对相关指令进行重排序,从而保证了变量的可见性和有序性。另外,synchronized关键字也能保证在进入和退出同步块时,变量的操作是按照正确的顺序进行的,避免了指令重排序带来的问题。

通过深入理解竞态条件、内存可见性和指令重排序这三个导致线程安全问题的主要原因,我们能够更好地认识到多线程编程中的复杂性和潜在风险,从而在编写多线程程序时,采取更加有效的措施来确保程序的正确性和稳定性。

三、实现线程安全的机制

3.1 同步关键字 synchronized

synchronized关键字是 Java 语言中实现线程安全的基础且重要的机制之一,它为我们在多线程环境下保护共享资源提供了有力的手段。

synchronized的实现原理基于 Monitor 机制。在 Java 虚拟机(HotSpot)中,每个对象都关联着一个监视器(Monitor)。可以把 Monitor 想象成一个房间,而共享资源就是房间里的物品。当一个线程想要访问共享资源时,就需要进入这个房间,也就是获取对象的 Monitor 锁。只有成功获取到锁的线程才能进入同步代码块执行操作,当线程执行完毕后,会释放 Monitor 锁,其他线程才有机会获取锁并进入同步代码块。

synchronized的使用方式非常灵活,主要有以下几种:

  • 修饰实例方法:当synchronized修饰实例方法时,它锁定的是当前对象实例。
  • 修饰静态方法:当synchronized修饰静态方法时,它锁定的是整个类(Class 对象)。
  • 修饰代码块:通过synchronized修饰代码块,我们可以指定锁对象。

在 Java SE 1.6 及以后的版本中,引入了锁升级机制,这一机制使得synchronized的性能得到了显著提升。锁升级过程包括四种锁状态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。

  • 无锁状态:这是最开始的状态,线程在访问同步代码块时不需要获取锁。
  • 偏向锁状态:当一个线程频繁地访问同步代码块时,会进入偏向锁状态。此时,锁会偏向于这个线程,以后该线程再次访问同步代码块时,不需要再次获取锁,只需要检查一下锁的偏向标识即可。
  • 轻量级锁状态:当有其他线程尝试访问偏向锁保护的同步代码块时,偏向锁会升级为轻量级锁。此时,多个线程会通过 CAS(比较并交换)操作来竞争锁,而不是直接阻塞。
  • 重量级锁状态:如果轻量级锁竞争失败,就会升级为重量级锁。此时,其他线程会被阻塞,进入等待队列,等待获取锁。

这种锁升级机制根据实际的竞争情况动态调整锁的状态,从而减少了线程获得锁和释放锁带来的性能消耗,提高了程序的并发性能 。

然而,在使用synchronized关键字时,开发者也需要注意一些问题:

  • 避免死锁:死锁是多线程编程中一个非常棘手的问题,当两个或多个线程相互等待对方释放锁时,就会发生死锁。例如,线程 A 持有锁 1,等待获取锁 2,而线程 B 持有锁 2,等待获取锁 1,这样两个线程就会永远等待下去,造成死锁。为了避免死锁,我们需要合理设计锁的获取和释放顺序,确保不会出现循环等待的情况。

  • 性能考量:在高并发场景下,由于synchronized是一种独占锁,同一时刻只有一个线程能够获取锁并执行同步代码块,其他线程需要等待,这可能会导致性能瓶颈。尤其是当同步代码块中的代码执行时间较长时,对性能的影响会更加明显。所以在这种情况下,可以考虑使用ReentrantLock等更高级的锁机制作为替代方案。
  • 锁的粒度:锁的粒度指的是锁所保护的代码范围。尽量减小锁的粒度,也就是缩小同步代码块的范围,可以提高并发性能。因为锁的粒度越小,线程持有锁的时间就越短,其他线程等待的时间也就越短,从而提高了系统的并发能力。

3.2 显式锁 Lock

在 Java 的并发编程中,除了synchronized关键字这种内置的同步机制外,Lock接口为我们提供了一种更灵活、功能更强大的显式锁方式。与synchronized相比,Lock接口提供了更多的控制选项,使其在处理复杂同步需求时表现得更加出色。

Lock接口具有以下几个主要优点:

  • 可中断的锁获取Lock接口允许线程在等待锁的过程中响应中断信号。当一个线程调用lockInterruptibly()方法获取锁时,如果在等待过程中该线程被中断,它会抛出InterruptedException异常并停止等待,而不是像synchronized那样一直阻塞等待。这一特性在某些场景下非常有用,比如当一个线程在执行一个长时间的任务,并且需要在外部可以随时中断它时,使用Lock的可中断锁获取功能就可以实现这一需求。
  • 超时机制Lock接口提供了尝试在指定时间内获取锁的能力。通过tryLock(long timeout, TimeUnit unit)方法,线程可以尝试在指定的时间内获取锁,如果在规定时间内成功获取到锁,则返回true;如果超时仍未获取到锁,则返回false。这种超时机制增加了操作的可控性,避免了线程因为长时间等待锁而造成的资源浪费和程序无响应的情况。
  • 公平锁Lock接口支持公平锁的实现。公平锁确保线程按照请求锁的顺序依次获取锁,避免了某些线程长时间得不到锁的 “饥饿” 问题。在默认情况下,ReentrantLock是非公平锁,它的性能通常比公平锁更好,但可能会导致某些线程等待时间过长。当我们需要保证公平性时,可以通过ReentrantLock的构造函数创建一个公平锁,如ReentrantLock lock = new ReentrantLock(true);
  • 条件变量Lock接口通过Condition对象支持更精细的线程调度。Condition可以看作是一种更强大的等待 / 通知机制,它允许在一个锁上创建多个条件变量,每个条件变量可以独立地进行等待和唤醒操作。这对于实现复杂的线程协作场景非常有帮助,比如在生产者 - 消费者模型中,我们可以使用Condition来实现更灵活的生产和消费控制。

ReentrantLockLock接口最常用的实现类,它提供了与synchronized类似的语义,同时增加了更多的高级功能。以下是一个使用ReentrantLock的示例:

import java.util.concurrent.locks.ReentrantLock;

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

    public void safeOperation() {
        lock.lock();
        try {
            // 执行临界区代码
            // 这里可以进行对共享资源的操作
        } finally {
            lock.unlock();
        }
    }
}

在这个示例中,我们创建了一个ReentrantLock对象lock,并在safeOperation方法中使用它来保护临界区代码。在进入临界区之前,通过lock.lock()方法获取锁,执行完临界区代码后,在finally块中使用lock.unlock()方法释放锁。将锁的释放操作放在finally块中是非常重要的,这样可以确保无论临界区代码是否发生异常,锁都能被正确释放,避免死锁的发生。

在使用ReentrantLock时,还需要注意以下几点:

  • 锁的可重入性ReentrantLock支持可重入性,这意味着同一个线程可以多次获取同一把锁。当一个线程获取到锁后,它可以再次调用被该锁保护的方法,而不会被阻塞。每获取一次锁,锁的持有计数就会增加 1,每次释放锁,持有计数就会减 1,当持有计数为 0 时,锁才会被真正释放。
  • 公平性选择:如前所述,ReentrantLock的构造函数接受一个布尔参数来决定是否为公平锁。在选择公平锁还是非公平锁时,需要根据具体的应用场景来权衡。如果应用程序对公平性要求较高,比如某些资源分配场景,需要确保每个线程都能公平地获取资源,那么可以选择公平锁;如果更注重性能,并且对公平性要求不高,那么非公平锁通常是更好的选择,因为非公平锁在高并发情况下的性能表现通常优于公平锁。

通过合理使用Lock接口和ReentrantLock,开发者可以获得更精细的线程控制能力,有效地解决复杂的并发问题,同时提高程序的性能和可维护性。尤其是在需要高级同步功能的场景中,Lock接口的优势更加明显,能够满足开发者对并发编程的各种需求。

3.3 volatile 关键字

volatile关键字在 Java 的并发编程中是一种轻量级的同步机制,虽然它不像synchronized关键字或Lock接口那样提供全面的锁机制,但在保证共享变量的可见性和禁止指令重排序方面发挥着重要作用。

volatile关键字的主要功能是保证共享变量的可见性。当一个变量被声明为volatile时,JVM 会对该变量的读写操作施加特殊的内存语义。具体来说,对volatile变量的写操作会触发内存屏障,这会强制将修改后的值立即刷新到主内存中,使得其他线程能够立即看到这个最新的值。而在读取volatile变量时,线程会直接从主内存中读取,而不是从自己的工作内存中读取旧值,从而保证了数据的可见性。

为了更深入地理解volatile的工作原理,我们需要关注其底层实现。在编译阶段,JVM 会在生成的字节码中为volatile变量的操作插入特定的内存屏障指令。这些内存屏障指令确保了以下几个关键属性:

  • 禁止指令重排序:在volatile变量的读写操作前后,会插入内存屏障,这有效地防止了编译器和处理器对相关指令进行重排序。例如,对于一个volatile变量的写操作,在写操作之前的指令不会被重排到写操作之后;对于读操作,读操作之后的指令不会被重排到读操作之前。这样就保证了volatile变量的操作顺序符合程序的逻辑。
  • 强制刷新缓存volatile写操作后插入的StoreLoad屏障确保了修改后的值被立即刷新到主内存中。这意味着当一个线程对volatile变量进行写操作后,其他线程能够立即看到这个最新的值,而不会读取到旧的缓存值。
  • 保证可见性:通过内存屏障机制,volatile变量的修改能够立即对其他线程可见。这是volatile关键字的核心功能之一,它解决了多线程环境中由于工作内存和主内存之间的数据不一致问题导致的内存可见性问题。

然而,volatile关键字也有其明显的局限性:

  • 不保证复合操作的原子性volatile只能保证单个变量的读写操作的原子性,对于像i++这样的复合操作,它并不提供原子性保证。i++操作实际上包含了读取i的值、将值加 1 以及将结果写回i这三个步骤,在多线程环境下,如果多个线程同时执行i++操作,可能会出现数据不一致的情况。
  • 无法替代锁机制:对于复杂的业务逻辑,volatile无法提供足够的保护。例如,在需要对多个共享变量进行协同操作,或者需要实现复杂的同步逻辑时,volatile就显得力不从心,这时仍然需要使用synchronizedLock等锁机制来保证线程安全。

在实际应用中,volatile常用于以下场景:

  • 标志位管理:在一些情况下,我们需要使用一个标志位来控制线程的行为。例如,在实现一个单例模式时,可以使用volatile来修饰单例对象的引用,通过双重检查锁定机制来确保单例对象的唯一性和线程安全。
  • 状态标记:当需要在线程之间共享一个状态标记,并且只需要保证状态的可见性时,volatile是一个很好的选择。比如,一个线程负责监控某个任务的状态,其他线程根据这个状态来决定是否继续执行某些操作,此时使用volatile修饰状态变量可以确保各个线程能够及时获取到最新的状态。
  • 发布 - 订阅模式:在发布 - 订阅模式中,发布者更新一个数据,多个订阅者需要读取这个数据。使用volatile修饰被更新的数据,可以保证订阅者能够及时看到最新的数据变化。

使用volatile时,开发者需要特别注意以下几点:

  • 仅适用于单一写多读场景:由于volatile不保证复合操作的原子性,所以在多写场景下,它可能无法提供足够的保护,容易导致数据不一致问题。因此,volatile更适合用于只有一个线程进行写操作,而多个线程进行读操作的场景。
  • 不能保证复杂表达式的原子性:即使是简单的数学运算,如i = i + 1,也不被视为原子操作,在多线程环境下使用volatile修饰i并不能保证操作的原子性。如果需要保证原子性,应该使用AtomicInteger等原子类。
  • 性能考量:虽然volatile比锁机制轻量,但它仍然有一定的开销。在某些对性能要求极高的场景中,如果过度使用volatile,可能会对性能产生一定的影响。因此,在使用volatile时,需要根据具体的性能需求进行权衡。

通过合理使用volatile关键字,开发者可以在多线程环境中实现高效的资源共享和通信,同时避免复杂的锁机制带来的性能开销。然而,在使用时需要谨慎评估其适用性,确保不会因为其局限性而引入新的线程安全问题。

3.4 线程安全的集合类

在 Java 的多线程编程中,处理多线程对集合类的并发访问是一个常见的问题。为了满足这一需求,Java 标准库提供了一系列线程安全的集合类,这些集合类专门设计用于在多线程环境下确保数据的一致性和完整性,大大简化了开发者的工作。

Java.util.concurrent 包中包含了多种线程安全的集合类,以下是一些常见的线程安全集合类及其特点:

  • ConcurrentHashMapConcurrentHashMap是线程安全的哈希映射,它采用了分段锁技术来提高并发性能。在ConcurrentHashMap中,整个哈希表被划分为多个段(segment),每个段都有自己独立的锁。当多个线程同时访问ConcurrentHashMap时,如果它们访问的是不同的段,就可以同时进行操作,而不会相互阻塞。只有当多个线程访问同一个段时,才会发生锁竞争。这种设计使得ConcurrentHashMap在高并发环境下具有较好的性能表现,特别适合于需要频繁进行读写操作的场景。
  • ConcurrentLinkedQueueConcurrentLinkedQueue是线程安全的链表队列,它支持高效的并发插入和删除操作。ConcurrentLinkedQueue采用了无锁的算法,通过使用 CAS(比较并交换)操作来实现线程安全。这种设计使得它在高并发情况下能够提供较好的性能,因为无锁算法避免了传统锁机制带来的上下文切换和线程阻塞的开销。ConcurrentLinkedQueue适用于需要在多线程环境下实现高效的队列操作的场景,比如在生产者 - 消费者模型中作为消息队列。
  • CopyOnWriteArrayListCopyOnWriteArrayList是线程安全的动态数组实现。它的实现原理是在进行写操作(如添加、删除元素)时,先复制一份原数组,然后在新的数组上进行操作,操作完成后再将新数组赋值给原数组。而读操作则直接读取原数组,不需要加锁。这种写时复制(copy - on - write)的技术使得CopyOnWriteArrayList对于读操作非常友好,因为读操作不会被写操作阻塞,并且可以保证线程安全。但是,由于写操作需要复制数组,所以在写操作频繁的场景下,可能会带来较大的性能开销。因此,CopyOnWriteArrayList更适合于读多写少的场景。

除了上述专门的线程安全集合类外,Java 还提供了Collections.synchronizedXxx()方法,可以将普通的集合转换为线程安全的版本。例如,Collections.synchronizedList(new ArrayList<>())可以将一个普通的ArrayList转换为线程安全的列表。这种方式虽然能够满足基本的线程安全需求,但它的性能通常不如专门的线程安全集合类高,因为它是通过在方法调用前后添加synchronized同步块来实现线程安全的,这会导致在高并发情况下锁竞争较为激烈。

在选择使用哪种线程安全集合类时,需要根据具体的应用场景和性能需求来进行权衡。如果是高并发的读写场景,ConcurrentHashMap可能是一个较好的选择;如果是需要高效的队列操作,并且对数据一致性要求较高,ConcurrentLinkedQueue比较合适;如果是读多写少的场景,CopyOnWriteArrayList则能提供较好的性能。同时,在使用线程安全集合类时,也需要注意它们的特性和适用范围,以充分发挥它们的优势,避免出现不必要的性能问题或线程安全隐患。

四、线程安全的设计模式

4.1 不可变对象

不可变对象在多线程编程中是一种强大的实现线程安全的设计模式,它通过独特的设计理念从根本上消除了竞态条件和数据不一致的风险,为开发者提供了一种简洁而高效的解决方案。

不可变对象的核心特征在于其状态在创建之后就不再发生变化。一旦一个不可变对象被创建,它的所有字段的值都将固定下来,不会再被修改。这种特性确保了多个线程可以同时访问这些对象,而无需担心数据竞争或同步问题,因为对象的状态不会因为多线程的并发访问而发生改变。

具体来说,不可变对象具有以下几个特点:

  • 所有字段均为 final 类型:在不可变对象中,所有的字段都被声明为finalfinal关键字确保了字段在对象构造完成后不能再被重新赋值,从而保证了对象状态的不可变性。例如:

public final class ImmutablePoint {
    private final int x;
    private final int y;

    public ImmutablePoint(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() {
        return x;
    }

    public int getY() {
        return y;
    }
}

在这个ImmutablePoint类中,xy字段都被声明为final,并且在构造函数中进行初始化,之后就不能再被修改。

  • 不提供修改状态的方法:不可变对象通常不会提供任何修改其内部状态的方法,比如常见的setter方法。这就从接口层面上限制了对象状态的改变,进一步保证了对象的不可变性。以ImmutablePoint类为例,它只提供了获取xy值的getter方法,而没有提供修改xy值的方法。
  • 正确构造过程:确保对象在构造期间不会处于不完整或无效状态是创建不可变对象的关键。在构造函数中,应该正确地初始化所有的字段,并且避免在构造过程中泄露this引用,以免其他线程在对象尚未完全构造完成时就访问到它。例如,在ImmutablePoint的构造函数中,直接对xy进行赋值,保证了对象在创建时就处于一个完整且有效的状态。

不可变对象的线程安全性体现在多个方面:

  • 消除竞态条件:由于不可变对象的状态是固定的,不存在多个线程同时修改同一状态的风险,因此从根本上消除了竞态条件。多个线程可以同时访问不可变对象,而不会因为并发访问导致数据不一致的问题。
  • 简化并发访问:无需额外的同步机制,多个线程就可以安全地共享和访问不可变对象。这大大简化了多线程编程中的同步操作,降低了代码的复杂性。
  • 提高性能:不可变对象避免了同步操作带来的性能开销,因为不需要加锁来保护共享资源。同时,由于对象状态不变,也减少了死锁和活锁的可能性,从而提高了系统的整体性能。
  • 简化测试和调试:不可变对象的行为是可预测的,因为其状态不会改变。这使得在单元测试和多线程环境下的调试工作变得更加容易。

4.2 线程本地存储

在多线程编程中,ThreadLocal是一种独特且强大的线程本地存储机制,它为解决多线程环境下的线程安全问题提供了一种优雅而有效的方案。

ThreadLocal的核心思想是为每个线程创建独立的变量副本,使得每个线程都可以安全地访问和修改自己的变量副本,而不必担心其他线程的干扰。这就好比为每个线程都提供了一个私人的存储空间,每个线程只能访问和操作自己空间里的变量,从而彻底消除了竞态条件。

具体来说,ThreadLocal的工作原理可以概括为以下几个关键点:

  • 内部实现ThreadLocal类本身并不直接存储变量值,而是通过Thread类的内部ThreadLocalMap来实现变量的存储。每个线程都有一个名为threadLocalsThreadLocalMap属性,用于存储本线程的变量副本。ThreadLocalMap是一个定制化的哈希表,它的键是ThreadLocal对象本身,值是对应线程的变量副本。当一个线程通过ThreadLocalset方法设置变量值时,实际上是将ThreadLocal对象和变量值存储到了该线程的ThreadLocalMap中;当通过get方法获取变量值时,也是从该线程的ThreadLocalMap中查找对应的ThreadLocal对象并返回其关联的值。
  • set () 和 get () 方法ThreadLocalset方法用于将当前线程与指定的值关联起来。当调用set方法时,ThreadLocal会首先获取当前线程的ThreadLocalMap,如果ThreadLocalMap不存在,则创建一个新的。然后将ThreadLocal对象作为键,指定的值作为值存储到ThreadLocalMap中。get方法则用于返回当前线程关联的值。它会先获取当前线程的ThreadLocalMap,然后在ThreadLocalMap中查找与当前ThreadLocal对象对应的键值对,如果找到则返回对应的值,否则返回null(或者通过initialValue方法返回默认值,后面会提到)。例如:

public class ThreadLocalExample {
    private static ThreadLocal<Integer> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            threadLocal.set(10);
            System.out.println("Thread 1: " + threadLocal.get());
        });

        Thread thread2 = new Thread(() -> {
            threadLocal.set(20);
            System.out.println("Thread 2: " + threadLocal.get());
        });

        thread1.start();
        thread2.start();
    }
}

在这个例子中,thread1thread2分别设置和获取自己的threadLocal变量副本,它们之间不会相互干扰。

  • 内存泄漏风险:由于ThreadLocalMap使用弱引用作为键,如果ThreadLocal实例被垃圾回收,但其值仍然存在于ThreadLocalMap中,就可能会导致内存泄漏。这是因为弱引用在对象没有其他强引用指向它时,会被垃圾回收器回收。当ThreadLocal实例被回收后,ThreadLocalMap中对应的键就变成了null,但是值仍然占用内存空间。如果不及时清理这些无效的键值对,随着时间的推移,ThreadLocalMap会越来越大,从而导致内存泄漏。因此,建议在不再需要使用ThreadLocal时调用remove方法,手动删除ThreadLocalMap中对应的键值对,以避免潜在的内存泄漏问题。
  • 初始化值ThreadLocal提供了initialValue方法来指定变量的初始值。开发者可以通过重写这个方法或使用ThreadLocal.withInitial构造函数来设置初始值。这对于需要默认值的场景非常有用,比如在一个 Web 应用中,每个线程可能需要一个默认的用户会话对象,通过重写initialValue方法可以为每个线程初始化一个默认的会话对象。

在实际应用中,ThreadLocal特别适用于需要在线程间隔离数据的场景。例如,在 Web 应用中,可以使用ThreadLocal来存储当前用户的会话信息。每个请求都在自己的线程上下文中处理,通过ThreadLocal可以为每个线程创建独立的会话信息副本,确保不同线程之间的会话信息不会相互干扰。这不仅可以避免复杂的线程间同步问题,还能提高系统的整体性能。

然而,使用ThreadLocal时也需要注意一些潜在的问题:

  • 过度使用可能导致性能下降:因为每个线程都需要维护一个额外的ThreadLocalMap,如果在一个应用中大量使用ThreadLocal,会增加每个线程的内存开销,并且在访问ThreadLocal变量时也会有一定的性能损耗。因此,应该避免不必要的ThreadLocal使用,只在真正需要线程间数据隔离的场景下使用。
  • 不当使用可能导致内存泄漏:如前面提到的,如果不及时清理不再需要的ThreadLocal实例,就可能导致内存泄漏。所以在使用ThreadLocal时,一定要养成在使用完毕后调用remove方法的好习惯。
  • 不应在静态方法中使用 ThreadLocal:静态方法不属于任何特定线程,在静态方法中使用ThreadLocal可能会导致意外的结果。因为静态方法的调用可能来自不同的线程,如果在静态方法中使用ThreadLocal,可能会混淆不同线程的数据,破坏线程本地存储的隔离性。

通过合理使用ThreadLocal,开发者可以在多线程环境中实现高效而安全的数据隔离,有效解决许多常见的线程安全问题。然而,正如任何工具一样,ThreadLocal也需要谨慎使用,以充分发挥其优势,同时避免潜在的陷阱。

4.3 线程安全的单例模式

在多线程编程中,单例模式是一种常用的设计模式,它确保一个类在整个应用程序中只有一个实例。然而,在多线程环境下实现单例模式需要特别注意线程安全问题,否则可能会创建出多个实例,违背单例模式的初衷。为了确保单例模式在多线程环境下的线程安全性,主要有以下两种常用方法:

4.3.1 双重检查锁定(DCL)

双重检查锁定(Double - Checked Locking,简称 DCL)是一种广泛使用的实现线程安全单例模式的方法。它结合了volatile关键字和synchronized块的优势,既能保证实例的唯一性,又能提高性能。

DCL 的核心思想是在实例化对象时进行两次null检查。具体实现步骤如下:

  1. 首先,在获取单例实例的方法中,先进行第一次null检查。这一步的目的是在大多数情况下,避免不必要的同步开销。因为如果单例实例已经被创建,那么直接返回该实例,不需要进入同步代码块。
  2. 如果第一次null检查发现单例实例为null,则进入synchronized同步块。在同步块中,再次进行null检查。这是因为在多线程环境下,可能有多个线程同时通过了第一次null检查,所以需要在同步块中再次确认单例实例是否已经被创建。
  3. 如果第二次null检查仍然发现单例实例为null,则创建单例实例。

下面是一个使用双重检查锁定实现的线程安全单例模式的示例代码:

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,这是非常关键的一点。volatile关键字的作用是保证instance变量的可见性和禁止指令重排序。如果不使用volatile关键字,可能会出现一个线程在创建instance对象时,由于指令重排序,导致在对象还未完全初始化完成时,就被其他线程获取到了这个未初始化完全的对象,从而引发错误。

4.3.2 静态内部类

静态内部类是另一种实现线程安全单例模式的优雅方法。它利用了 Java 的类加载机制来保证单例的线程安全性。

具体实现方式是将单例实例放在一个静态内部类中。当外部类被加载时,静态内部类并不会立即被加载。只有当第一次调用获取单例实例的方法时,JVM 才会加载静态内部类,并且在加载过程中,JVM 会确保静态内部类只会被加载一次,从而保证了单例的唯一性和线程安全性。

以下是使用静态内部类实现的线程安全单例模式的示例代码:

public class Singleton {
    private Singleton() {
        // 私有构造函数,防止外部实例化
    }

    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

在这个示例中,SingletonHolderSingleton类的静态内部类,它持有一个Singleton类型的静态常量INSTANCE,并且在类加载时就会创建Singleton的实例。当外部调用getInstance方法时,实际上是返回SingletonHolder.INSTANCE,由于类加载机制的保证,SingletonHolder只会被加载一次,所以INSTANCE也只会被创建一次,从而实现了单例模式的线程安全性。

这两种方法各有优缺点,开发者可根据具体应用场景选择合适的方式。DCL 适合需要延迟加载单例的情况,因为它只有在真正需要使用单例实例时才会创建,能够节省系统资源。而静态内部类则更适合需要立即加载单例的场景,因为它在类加载时就创建了单例实例,相对来说实现更加简洁,并且由于类加载机制的保证,也不需要额外的同步操作。

五、线程安全性能优化

5.1 细粒度锁

细粒度锁是一种优化线程安全性能的有效策略,它的核心思想是将锁的范围尽可能地缩小到最小必要程度,以此来显著提升系统的并发性能。

在传统的锁机制中,当一个锁的保护范围过大时,会导致多个线程在访问共享资源时频繁地竞争同一把锁,从而造成线程阻塞和等待,降低了系统的并发度。而细粒度锁则通过将大型锁分解为多个小型锁,使得不同的线程可以同时访问不同的共享资源部分,减少了锁的竞争和等待时间。

以双向链表为例,假设我们有一个双向链表,并且需要在多线程环境下对其进行操作。如果使用传统的粗粒度锁,可能会对整个链表加锁,这样当一个线程在操作链表的某个节点时,其他线程即使想要操作链表的其他节点也需要等待锁的释放。但如果采用细粒度锁,我们可以为每个节点分配单独的锁。当一个线程需要操作某个节点时,只需要锁定该节点对应的锁,而其他线程可以同时操作其他节点,互不干扰。

例如,我们可以定义一个双向链表节点类Node,并为每个节点添加一个锁:

class Node {
    int value;
    Node prev;
    Node next;
    final Object lock = new Object();

    public Node(int value) {
        this.value = value;
    }
}

然后在对链表进行操作的方法中,使用这些细粒度锁:

class DoublyLinkedList {
    private Node head;

    public void addNode(int value) {
        Node newNode = new Node(value);
        if (head == null) {
            head = newNode;
            return;
        }
        Node current = head;
        while (current.next!= null) {
            current = current.next;
        }
        synchronized (current.lock) {
            synchronized (newNode.lock) {
                current.next = newNode;
                newNode.prev = current;
            }
        }
    }
}

在这个例子中,addNode方法在添加节点时,只对当前节点和新节点加锁,而不是对整个链表加锁,这样大大提高了并发度。

然而,实施细粒度锁时也需要谨慎。虽然细粒度锁可以提高并发性能,但过多的锁也可能会带来一些负面影响。例如,每个锁都需要一定的内存空间来存储其状态信息,过多的锁会增加内存开销。此外,在获取和释放多个细粒度锁时,也会带来一定的时间开销,如果锁的粒度划分不合理,可能会导致性能反而下降。因此,在使用细粒度锁时,需要根据具体场景仔细权衡锁的粒度和性能之间的关系,找到一个最佳的平衡点。

5.2 非阻塞算法

非阻塞算法是实现线程安全的一种高效方法,它在多线程编程中越来越受到关注,特别适用于那些需要频繁进行细粒度操作的场景。

非阻塞算法的核心思想是通过比较并交换(CAS,Compare - And - Swap)操作来实现数据的原子更新,从而避免了传统锁机制带来的性能开销和潜在的死锁问题。

CAS 操作是一种硬件级别的原子指令,它利用处理器提供的特殊指令来实现。CAS 操作包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。在执行 CAS 操作时,处理器会将内存位置 V 处的值与预期原值 A 进行比较,如果它们相等,就将内存位置 V 处的值更新为新值 B,并且返回true;如果不相等,就不进行更新,并且返回false。这个过程是原子性的,即在执行过程中不会被其他线程干扰。

Java 平台通过Unsafe类提供了对 CAS 操作的支持,开发者可以利用Unsafe类中的方法来实现高效的并发算法。例如,AtomicInteger类就是基于 CAS 操作实现的一个线程安全的整数类。AtomicInteger类提供了一系列方法,如incrementAndGet(自增并返回新值)和compareAndSet(比较并设置值)等,这些方法都是通过 CAS 操作来保证线程安全的。

以下是一个简单的使用 AtomicInteger类的示例:

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicIntegerExample {
    private static AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                count.incrementAndGet();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                count.incrementAndGet();
            }
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Final count: " + count.get());
    }
}

在这个示例中,两个线程同时对AtomicInteger类型的count进行自增操作。由于AtomicInteger内部使用了 CAS 操作,所以能够保证在多线程环境下count的自增操作是线程安全的,最终输出的结果是正确的累加值。

非阻塞算法特别适合于需要频繁进行简单数值操作的场景,如计数器或序列生成器等。在这些场景中,使用非阻塞算法能够在保证线程安全的同时,显著提高系统的并发性能。因为它避免了传统锁机制中线程获取锁、释放锁以及线程阻塞和唤醒等带来的开销,使得线程能够更加高效地执行任务。

然而,非阻塞算法也并非完美无缺。在某些情况下,由于 CAS 操作可能会失败(当预期原值与内存中的实际值不相等时),需要进行重试操作,这可能会导致一定的性能开销。而且,对于复杂的操作,实现非阻塞算法可能会比较困难,代码的可读性和维护性也可能会受到影响 。所以在选择使用非阻塞算法时,需要根据具体的应用场景和需求来综合考虑。

5.3 并发容器的选择

在多线程编程中,选择合适的并发容器对于提高系统的并发性能和稳定性至关重要。不同的并发容器具有不同的特点和适用场景,开发者需要根据具体的需求来进行权衡和选择。

以下是几种常见并发容器的特点和适用场景:

  • ConcurrentHashMap:特点:ConcurrentHashMap是线程安全的哈希映射,采用了分段锁技术(在 Java 8 及之后的版本中,采用了 CAS 操作和 synchronized 关键字相结合的方式来实现更高效的并发控制)。它将整个哈希表划分为多个段(segment),每个段都有自己独立的锁。在高并发情况下,多个线程可以同时访问不同段的数据,从而大大提高了并发读写性能。适用场景:非常适合在高并发环境下需要频繁进行读写操作的场景。例如,在一个大型的电商系统中,商品的库存信息、用户的购物车信息等都可以使用ConcurrentHashMap来存储和管理。因为在高并发的购物高峰期,大量的用户可能同时进行商品查询、添加商品到购物车等操作,ConcurrentHashMap能够很好地应对这种高并发的读写需求。
  • ConcurrentSkipListMap:特点:ConcurrentSkipListMap是基于跳跃表实现的线程安全的有序映射。它提供了弱一致性的迭代器,即迭代器遍历的结果可能不是实时最新的,但在大多数情况下能够满足需求。跳跃表的结构使得它在大规模数据操作时具有较好的性能,并且能够保持数据的有序性。适用场景:适用于大规模数据操作,并且对数据的一致性要求不是特别严格,但需要保持数据有序的场景。比如,在一个分布式的日志系统中,需要对大量的日志记录按照时间顺序进行存储和查询,ConcurrentSkipListMap就可以作为一个很好的选择。
  • CopyOnWriteArrayList:特点:CopyOnWriteArrayList是线程安全的动态数组实现。它的实现原理是在进行写操作(如添加、删除元素)时,先复制一份原数组,然后在新的数组上进行操作,操作完成后再将新数组赋值给原数组。而读操作则直接读取原数组,不需要加锁。这种写时复制的技术使得CopyOnWriteArrayList对于读操作非常友好,因为读操作不会被写操作阻塞,并且可以保证线程安全。适用场景:特别适合读多写少,并且对数据一致性要求较高的场景。例如,在一个配置信息管理系统中,配置信息通常在系统启动时加载,之后很少进行修改,但在系统运行过程中会被频繁读取。使用CopyOnWriteArrayList来存储配置信息,可以保证在高并发读取的情况下,数据的一致性和线程安全,同时由于读操作不需要加锁,性能也能够得到保障。
  • BlockingQueue:特点:BlockingQueue是一个支持阻塞操作的队列。当队列满时,向队列中添加元素的操作会被阻塞,直到队列中有空间;当队列空时,从队列中获取元素的操作会被阻塞,直到队列中有元素。它提供了多种实现类,如ArrayBlockingQueue(基于数组实现的有界阻塞队列)、LinkedBlockingQueue(基于链表实现的可选有界阻塞队列)等。适用场景:非常适合用于实现生产者 - 消费者模式。在这种模式中,生产者线程将数据放入BlockingQueue中,如果队列满了,生产者线程会被阻塞等待;消费者线程从BlockingQueue中取出数据,如果队列空了,消费者线程会被阻塞等待。这种机制能够很好地协调生产者和消费者之间的速度差异,保证数据的正确处理。例如,在一个消息处理系统中,消息生产者将消息发送到BlockingQueue中,消息消费者从队列中取出消息进行处理,通过BlockingQueue可以有效地实现消息的缓冲和处理。

选择合适的并发容器可以显著提高系统的并发性能和稳定性。在实际应用中,我们需要根据具体的业务需求、数据规模、并发访问模式等因素来综合考虑选择哪种并发容器。同时,还需要注意并发容器的特性和限制,以确保在使用过程中不会出现性能问题或线程安全隐患。

六、大厂高频面试题

6.1 具体讲一下 synchronized 和 reentrantlock。

synchronized 是 Java 语言内置的关键字,用于实现多线程环境下的同步。它的主要特点如下:

简单易用:对于初学者来说,很容易理解和使用。例如,要对一个方法进行同步,只需要在方法声明前添加synchronized关键字即可。如果是对一个代码块进行同步,使用synchronized (对象) {代码块}的形式。

自动加锁和解锁:当一个线程进入synchronized修饰的方法或者代码块时,会自动获取锁;当线程退出方法或者代码块时,会自动释放锁。这个过程对程序员来说是透明的,减少了出错的可能性。

多种优化机制:JVM 对synchronized进行了多种优化。如前面提到的偏向锁、轻量级锁和重量级锁的转换机制。在低竞争环境下,偏向锁可以减少获取锁的开销;在一定程度的竞争环境下,轻量级锁可以通过自旋等待的方式来提高性能;只有在高竞争环境下,才会使用重量级锁。

可重入性:synchronized 是可重入锁。这意味着一个线程可以多次获取同一个锁。例如,一个方法调用了另一个也被synchronized修饰的方法,同一个线程可以顺利进入被调用的方法,而不会出现自己阻塞自己的情况。

ReentrantLock 是java.util.concurrent.locks包中的一个类,它提供了比synchronized更灵活的锁机制。

class MyClass {
    // 同步方法
    synchronized void myMethod() {
        // 方法体内容
    }
    void anotherMethod() {
        // 同步代码块
        synchronized (this) {
            // 代码块内容
        }
    }
}

手动加锁和解锁:使用ReentrantLock需要程序员手动调用lock方法来获取锁,并且在合适的时候调用unlock方法来释放锁。这增加了灵活性,但也增加了出错的风险,如果忘记释放锁,可能会导致死锁等问题。

import java.util.concurrent.locks.ReentrantLock;
class MyLockClass {
    private ReentrantLock lock = new ReentrantLock();
    void myLockedMethod() {
        lock.lock();
        try {
            // 方法体内容
        } finally {
            lock.unlock();
        }
    }
}

公平性选择:ReentrantLock可以通过构造函数来选择是否是公平锁。公平锁保证了按照线程请求锁的顺序来分配锁,非公平锁则允许插队的情况。在公平锁的情况下,性能可能会稍差一些,因为它需要维护一个等待队列来保证顺序;而非公平锁在高并发情况下可能会有更好的性能,因为它减少了线程等待的时间。

可中断性:ReentrantLock提供了可中断的锁获取机制。一个线程在等待获取ReentrantLock时,可以通过interrupt方法来中断等待状态,这在一些需要及时响应外部事件的场景中非常有用。而synchronized在等待获取锁时是不可中断的。

条件变量支持:ReentrantLock可以和Condition接口一起使用,通过newCondition方法可以创建多个条件变量。这在实现更复杂的线程同步逻辑时非常有用,比如生产者 - 消费者模型。例如,生产者线程和消费者线程可以共享一个ReentrantLock,并且通过不同的条件变量来等待和通知对方,实现高效的数据生产和消费。

使用场景方面:

synchronized:更适合简单的、代码结构相对固定的并发场景,比如在普通的类方法或者代码块中实现基本的同步需求,由于其语法简洁,使用方便,对于初学者或者对并发控制要求不是特别复杂精细的情况较为适用。例如,在一个简单的多线程访问共享资源的类中,直接在方法前添加synchronized关键字就能实现同步。

ReentrantLock:在需要更灵活控制锁的获取和释放顺序、对公平性有要求、需要可中断锁获取等复杂场景下更有优势。比如在实现复杂的生产者 - 消费者模型中,如果想要精确控制线程等待锁的顺序,通过构造公平锁的ReentrantLock来保证按照请求顺序分配锁;或者在一些长时间等待锁可能需要响应外部中断的场景中,利用它可中断的特性来及时处理外部情况。

6.2 公平锁和非公平锁底层是怎么实现的?

公平锁的实现主要是基于队列来保证获取锁的顺序。当一个线程请求获取公平锁时,锁会检查等待队列。如果队列中有等待时间更长的线程,那么当前请求锁的线程会被放入队列的尾部,等待前面的线程依次获取并释放锁之后,自己才能获取锁。这就像是在排队,先来先得。

例如,在ReentrantLock的公平锁实现中,它内部维护了一个AbstractQueuedSynchronizer(AQS)的同步队列。当一个线程请求锁时,会通过 AQS 的机制来检查队列中是否有等待的线程。如果有,就会将当前线程封装成一个节点放入队列的尾部。然后线程会在队列中等待,直到前面的节点全部完成对锁的操作,自己才会被唤醒获取锁。

非公平锁在实现上不会去严格检查等待队列。当一个线程请求非公平锁时,它会首先尝试去获取锁,不管队列中是否有等待的线程。如果此时锁没有被其他线程持有,那么这个线程就可以直接获取锁,跳过了排队的过程。只有在锁已经被其他线程持有的情况下,它才会像公平锁一样将自己放入等待队列中等待。

还是以ReentrantLock为例,在非公平锁的实现中,当一个线程请求锁时,会先尝试通过CAS(比较并交换)操作来获取锁。如果成功获取,就可以直接进入临界区执行代码。如果获取失败,就会像公平锁一样将自己放入 AQS 的同步队列中等待被唤醒。这种实现方式使得非公平锁在某些情况下可能会有更高的性能,因为它减少了线程等待的时间,但是可能会导致某些线程长时间等待不到锁,从而出现 “饥饿” 现象。

6.3 AQS 原理详细介绍一下?

AbstractQueuedSynchronizer(AQS)是一个用于构建锁和同步器的框架。它的核心是一个 FIFO(先进先出)的队列,用于管理等待获取同步状态的线程。

AQS 维护了一个同步状态(state),这个状态可以用来表示锁是否被获取,获取了几次等信息。例如,在ReentrantLock中,state 的值可以表示锁被重入的次数。如果 state 为 0,表示锁未被获取;如果 state 大于 0,表示锁已经被获取,且 state 的值就是获取锁的次数。

在 AQS 内部,有一个双向链表来实现等待队列。当一个线程请求获取同步状态但无法获取时,会被封装成一个节点(Node)加入到这个队列中。节点中包含了线程的引用、节点的状态(比如等待、取消等状态)等信息。

当一个线程释放同步状态时,AQS 会检查等待队列。如果队列中有等待的节点,就会按照一定的规则唤醒队列头部的节点所代表的线程。这个唤醒过程是通过LockSupport类来实现的,它可以让线程进入等待状态或者唤醒等待状态的线程。

AQS 的获取同步状态(acquire)方法通常是一个模板方法,它定义了获取同步状态的基本流程。首先,线程会尝试通过CAS操作来获取同步状态,如果获取成功,就表示获取了锁,可以进入临界区执行代码。如果获取失败,就会将自己封装成一个节点加入到等待队列中,并且可能会通过自旋的方式不断检查自己是否可以获取同步状态。

AQS 还提供了共享模式和独占模式。在独占模式下,只有一个线程可以获取同步状态,就像ReentrantLock一样。在共享模式下,多个线程可以同时获取同步状态,例如CountDownLatch和Semaphore等同步工具就是基于共享模式的 AQS 来实现的。共享模式下的获取和释放同步状态的操作与独占模式有所不同,它需要考虑多个线程同时获取和释放的情况。

AQS 通过这种抽象的机制,为各种锁和同步器的实现提供了一个基础。开发者可以通过继承 AQS,并重写它的部分方法来实现自己的锁和同步器,比如自定义公平性、自定义同步状态的含义等。

6.3 violate 关键字的作用?

在 Java 中没有 “violate” 关键字,你可能是想说 “volatile” 关键字。volatile 关键字主要用于解决多线程环境下变量的可见性问题。

当一个变量被声明为 volatile 时,它具有以下特性。首先是可见性,在多线程环境下,一个线程对 volatile 变量的修改,其他线程能够立即看到这个修改。这是因为 volatile 变量的每次读写操作都会直接与主内存进行交互,而不是使用线程的本地缓存。

例如,假设有两个线程 A 和 B,它们都访问一个共享的 volatile 变量flag。线程 A 修改了flag的值,那么线程 B 在下一次读取flag时,会读取到线程 A 修改后的最新值,而不会使用自己缓存中的旧值。

其次是禁止指令重排序。在不使用 volatile 关键字的情况下,编译器和处理器为了优化性能,可能会对指令进行重排序。但是对于 volatile 变量,这种重排序是被禁止的。这可以保证在多线程环境下,代码的执行顺序符合程序员的预期。

例如,在一个简单的单例模式中,如果使用双重检查锁定(DCL)来实现单例对象的创建,就需要使用 volatile 关键字来修饰单例对象的引用变量。因为在没有 volatile 关键字的情况下,可能会出现指令重排序,导致一个线程在对象还没有完全初始化的情况下就获取到了这个对象的引用,从而出现错误。

但是,volatile 关键字并不能解决多线程环境下的所有问题。它不能保证原子性,例如,对于一个 volatile 变量的自增操作(++),它不是原子性的,在多线程环境下可能会出现数据不一致的情况。如果需要保证原子性,还需要使用其他的同步机制,如synchronized关键字或者java.util.concurrent.atomic包中的原子类。

6.4 ReentrantLock 的 Reentrant 是什么意思?阐述可重入锁的概念,以及 synchronized 是可重入锁吗(详细解释可重入锁相关概念及针对 synchronized 是否为可重入锁进行说明)?

Reentrant 的含义:“Reentrant” 意思是可重入的。对于 ReentrantLock 来说,可重入意味着同一个线程可以多次获取同一个锁,而不会造成死锁等异常情况。例如,一个线程在已经获取了某个 ReentrantLock 锁的情况下,它再次尝试获取这个锁,是能够成功获取的,并且会记录相应的重入次数,在释放锁的时候,也需要按照获取的次数来进行对应次数的释放操作,就好像可以多次进入同一个被锁住的区域一样。

可重入锁的概念:可重入锁就是允许同一个线程多次获取该锁的一种锁机制。在多线程编程中,当一个线程进入一个被可重入锁保护的代码块或者方法后,如果在这个代码块或者方法内部又调用了另一个同样被该锁保护的代码块或者方法,那么这个线程可以顺利进入,不会被自己持有的锁阻挡在外。这主要是通过在锁内部维护一个计数器之类的机制来记录锁被获取的次数实现的,每次获取锁,计数器加 1,每次释放锁,计数器减 1,当计数器为 0 时,表示锁完全被释放,其他线程可以获取。

synchronized 是否为可重入锁:synchronized 是可重入锁。例如,在一个类中有两个方法,方法 A 和方法 B,都被synchronized修饰,并且方法 A 中调用了方法 B。当一个线程进入方法 A 获取了对应的锁后,在它调用方法 B 时,由于是同一个线程,它可以再次获取这个锁进入方法 B 执行代码,执行完方法 B 后返回方法 A 继续执行,最后等方法 A 执行完再释放锁。这就是因为 synchronized 机制在底层实现中会识别当前请求锁的线程是否已经持有该锁,如果已经持有,就允许再次进入,从而保证了在复杂的代码嵌套调用场景下,同一个线程能够顺利执行被其保护的多个代码段,避免了自己阻塞自己的情况。

6.5 请介绍 hashmap 非线程安全,1.8 改尾插法的原因?

HashMap 在 Java 中是非线程安全的,主要是因为在多线程环境下,它的内部结构可能会被破坏。

在 HashMap 的内部,数据是通过数组和链表(在 Java 8 之后还有红黑树)来存储的。当多个线程同时对 HashMap 进行操作时,比如同时进行put操作,可能会出现数据不一致的情况。例如,两个线程同时对同一个位置进行插入操作,可能会导致链表的结构混乱,或者在计算哈希值、扩容等操作时也可能出现问题。

在 Java 8 中,HashMap 将链表插入方式从原来的头插法改为尾插法,主要是为了避免在多线程环境下出现循环链表的问题。

在旧版本的头插法中,如果在resize(扩容)过程中有多个线程同时操作,可能会导致链表形成循环。因为在头插法中,新节点会插入到链表头部,在扩容时,会先将旧链表的节点迁移到新链表,当多个线程同时进行这个操作时,可能会导致节点的引用关系混乱,最终形成循环链表。

而尾插法是将新节点插入到链表的尾部。在扩容过程中,这种方式可以保证链表的顺序性,即使在多线程环境下,虽然数据可能会不一致,但不会出现循环链表这种严重的问题。这样在遍历链表时,就不会出现死循环的情况,提高了程序的稳定性。

6.6 请介绍有什么线程安全的类替代 hashmap 吗?

在 Java 中,有一些可以替代非线程安全的 HashMap 的线程安全类。

Hashtable:它是 Java 早期就存在的一个线程安全的哈希表类。它和 HashMap 类似,也是通过哈希表来存储数据。Hashtable 中的方法基本都是同步方法,例如put、get等操作都是通过synchronized关键字来保证线程安全的。但是,由于它对所有方法都进行了同步,在高并发场景下性能可能会受到影响。

ConcurrentHashMap:这是在 Java 5 之后引入的一个高效的线程安全的哈希表类。它在设计上采用了分段锁的机制。在 Java 8 之前,它将内部的数据结构分为多个段(Segment),每个段都有自己独立的锁,不同段之间的操作可以并发进行,只有在同一个段内的操作才需要获取锁,这样就大大提高了并发性能。在 Java 8 中,它的结构进行了优化,不再使用分段锁,而是采用了 CAS(比较并交换)操作和volatile关键字等方式来保证线程安全。它的put、get等操作在多线程环境下可以高效地执行,同时保证数据的准确性和一致性。

例如,在多线程频繁地进行读写操作的场景中,使用 ConcurrentHashMap 可以更好地发挥其优势。它可以允许多个线程同时进行读操作,而在写操作时,也能通过高效的锁机制或者 CAS 操作来保证数据的正确更新,不会出现像 HashMap 在多线程环境下可能出现的数据混乱问题。

6.7 ArrayList 是线程安全的吗?它是如何扩容的?

ArrayList 不是线程安全的。在多线程环境下,多个线程同时对 ArrayList 进行操作,例如一个线程正在对 ArrayList 进行插入操作,另一个线程同时对其进行删除或者读取操作,可能会导致数据不一致、数组越界或者其他不可预期的错误。

ArrayList 的扩容机制如下:ArrayList 内部是通过一个数组来存储元素的。当向 ArrayList 中添加元素时,会先检查当前数组是否还有足够的空间来存储新元素。如果有足够的空间,新元素会被直接添加到数组的下一个空闲位置。

如果数组已经没有足够的空间,就需要进行扩容。扩容的具体操作是创建一个新的、更大容量的数组。在 Java 中,ArrayList 的扩容策略通常是将原数组的容量增加为原来容量的 1.5 倍。例如,原数组容量是 10,当需要扩容时,新数组的容量会变为 15。当然,这只是一般情况,具体的扩容倍数在不同的 Java 版本或者实现细节中可能会有所调整。

然后,将原数组中的所有元素通过循环逐个复制到新的数组中。这个复制过程是一个比较耗时的操作,特别是当数组中的元素数量较多时。完成复制后,新元素就可以添加到新数组的合适位置。最后,原来的数组会被丢弃,ArrayList 内部的引用会指向新的、已经扩容后的数组。

这种扩容机制在一定程度上保证了 ArrayList 能够动态地适应元素数量的增加,但也需要注意在频繁插入元素的情况下,可能会因为频繁的扩容操作而影响性能。而且,由于 ArrayList 不是线程安全的,在多线程环境下使用时,需要通过额外的同步机制,如使用synchronized关键字包裹对 ArrayList 的操作代码块,或者使用Collections.synchronizedList方法将 ArrayList 转换为线程安全的列表来保证数据的正确性。

6.8 HashMap 是线程安全的吗?HashSet 如何保证唯一性?

HashMap 不是线程安全的。在多线程环境下,当多个线程同时对 HashMap 进行操作时,可能会出现数据不一致的情况。例如,在进行put操作时,多个线程可能会同时计算相同的哈希值并尝试插入到相同的桶位置,这可能导致数据丢失或者链表结构混乱。在扩容过程中,如果多个线程同时操作,也可能导致死循环或者数据丢失等严重问题。

HashSet 是基于 HashMap 来实现的,它通过 HashMap 的键的唯一性来保证元素的唯一性。具体来说,HashSet 内部有一个 HashMap 实例。当向 HashSet 中添加元素时,实际上是将这个元素作为键添加到内部的 HashMap 中,而值则是一个固定的虚拟值(通常是一个Object类型的常量)。

因为 HashMap 本身会保证键的唯一性,当向 HashSet 中添加元素时,如果这个元素作为键在内部的 HashMap 中已经存在,那么这个元素就不会被再次添加。在计算哈希值方面,HashSet 也是利用了 HashMap 的哈希算法。它会根据元素的hashCode方法返回的值来计算哈希值,然后根据这个哈希值来确定元素在内部 HashMap 中的存储位置。

当从 HashSet 中获取元素时,实际上是遍历内部的 HashMap 的键,因为 HashSet 本身没有直接暴露获取元素的方法,而是通过一些方法来检查元素是否存在,例如contains方法,这个方法会在内部的 HashMap 中查找指定的键是否存在,从而判断元素是否在 HashSet 中。

所以,HashSet 的唯一性是通过其内部的 HashMap 的键的唯一性机制来保证的,而 HashMap 的这种键的唯一性是通过哈希算法和键的equals方法来实现的。如果两个元素的hashCode相同并且equals方法返回true,那么在 HashMap(以及 HashSet)中就会认为这两个元素是相同的,只会存储一个。

6.9 ConcurrentHashMap 怎样实现线程安全?

ConcurrentHashMap 在 Java 8 之前主要通过分段锁来实现线程安全。它将内部的数据结构划分为多个段(Segment),每个段就像是一个独立的小哈希表,并且每个段都有自己独立的锁。

当多个线程对 ConcurrentHashMap 进行操作时,不同段之间的操作可以并发进行。例如,一个线程在对某一个段进行put操作,另一个线程可以同时对其他段进行get或put操作,只要它们操作的不是同一个段。只有当多个线程对同一个段进行操作时,才需要获取这个段的锁来保证线程安全。这种分段锁机制大大减少了锁的竞争,提高了并发性能。

在 Java 8 之后,ConcurrentHashMap 的实现发生了变化。它采用了一种更加精细的节点锁机制,结合了 CAS(比较并交换)操作和volatile关键字。在内部,它仍然是基于哈希表结构,但是在处理并发操作时有所不同。

对于put操作,当插入一个新节点时,会先通过哈希算法定位到对应的桶位置。如果桶位置为空,会尝试使用 CAS 操作来插入新节点。如果 CAS 操作成功,就直接插入新节点;如果失败,说明可能有其他线程已经插入了节点,此时会进入一个循环来处理这种情况,可能会根据节点的类型(链表节点或者红黑树节点)来进行不同的操作。

对于get操作,由于在数据结构中的节点使用了volatile关键字,保证了可见性,所以线程在读取数据时能够获取到最新的值。并且在遍历链表或者红黑树节点时,不会对节点进行加锁,因为节点的状态通过volatile关键字来保证是最新的,这样可以支持高并发的读取操作。

在处理扩容操作时,也有相应的并发机制。它不会像传统的 HashMap 那样在扩容时完全阻塞其他操作,而是通过一些标记位和渐进式的操作来允许在扩容过程中继续进行部分读写操作,从而提高了在数据结构动态变化时的并发性能。

6.10 ConcurrentHashMap 它的并发度大小是怎样的?ConcurrentHashMap 的 get 方法是否上锁?

在 Java 8 之前的 ConcurrentHashMap,并发度与它的分段数量有关。它内部被划分为多个段(Segment),每个段有自己的锁,理论上并发度可以达到段的数量。例如,如果有 16 个段,那么最多可以有 16 个线程同时对不同的段进行操作而不会产生锁竞争。不过,实际的并发度还会受到数据分布、操作类型等因素的影响。

在 Java 8 之后,虽然不再使用分段锁的结构,但通过 CAS 操作和volatile关键字等机制,并发度得到了进一步提高。它允许在多个桶位置同时进行并发操作,具体的并发度没有一个固定的数值,而是取决于数据结构的状态、操作的频率和类型等多种因素。在高负载的多线程环境下,能够高效地处理多个线程的并发读写操作。

ConcurrentHashMap 的get方法在 Java 8 及以后的版本中通常是不上锁的。这是因为它在内部数据结构的设计上使用了volatile关键字来保证数据的可见性。当一个线程对节点进行修改后,其他线程能够立即看到这个修改后的结果。在进行get操作时,线程只是简单地根据哈希值定位到桶位置,然后遍历链表或者红黑树节点来获取值。由于节点的volatile属性,保证了获取到的数据是最新的,所以不需要通过加锁来保证数据的一致性。

这种无锁的get操作设计大大提高了读取的效率,使得 ConcurrentHashMap 在多线程环境下非常适合高并发的读取场景,能够在保证数据准确性的同时,快速地响应多个线程的读取请求。

6.11 多个线程访问账户金额,怎么保证金额数据一致(结合具体的账户金额场景,说明确保数据一致性的思路、方法等)。

在账户金额这个具体场景中,数据一致性至关重要。

一种方法是使用synchronized关键字。假设我们有一个Account类,里面有一个balance变量表示账户余额。如果有一个withdraw方法用于取款操作,可以将这个方法声明为synchronized。例如:

class Account {
    private double balance;
    public synchronized void withdraw(double amount) {
        if (balance >= amount) {
            balance -= amount;
        }
    }
}

这样,当多个线程同时调用withdraw方法时,同一时刻只有一个线程能够执行这个方法,从而避免了多个线程同时修改余额导致数据不一致的情况。

也可以使用ReentrantLock。例如:

import java.util.concurrent.locks.ReentrantLock;
class Account {
    private double balance;
    private ReentrantLock lock = new ReentrantLock();
    public void withdraw(double amount) {
        lock.lock();
        try {
            if (balance >= amount) {
                balance -= amount;
            }
        } finally {
            lock.unlock();
        }
    }
}

通过手动获取和释放锁,确保在取款操作过程中,只有一个线程能够修改余额。

如果只是简单地对账户余额进行读取操作,为了保证读取到的是最新的数据,可以使用volatile关键字来修饰balance变量。这样,当其他线程修改了余额后,读取余额的线程能够及时获取到更新后的值。

在更复杂的场景中,比如涉及到转账操作,可能需要同时对两个账户进行操作。可以将转账操作封装在一个方法中,并使用同步机制来保证整个转账过程的原子性。例如,有一个transfer方法用于从一个账户向另一个账户转账:

class Account {
    private double balance;
    public synchronized static void transfer(Account from, Account to, double amount) {
        from.withdraw(amount);
        to.deposit(amount);
    }
    public synchronized void withdraw(double amount) {
        if (balance >= amount) {
            balance -= amount;
        }
    }
    public synchronized void deposit(double amount) {
        balance += amount;
    }
}

这里通过synchronized关键字修饰方法,保证了在转账过程中,无论是取款账户还是存款账户的操作,都不会被其他线程干扰,从而确保金额数据的一致性。

另外,还可以考虑使用数据库的事务机制来保证账户金额数据的一致性。如果账户金额数据存储在数据库中,当多个线程通过应用程序对账户进行操作时,在数据库层面开启事务。在事务中进行账户金额的更新操作,只有当所有操作都成功时,才提交事务;如果在操作过程中出现异常,就回滚事务,这样可以保证数据的完整性和一致性。

6.12 解释一下线程安全的概念(准确清晰地解释线程安全在 Java 编程语境下的含义)。

在 Java 编程语境下,线程安全是指在多线程环境中,一个类、对象或者方法能够正确地运行,并且能够保证数据的完整性和一致性。

具体来说,当多个线程同时访问一个共享资源(如对象的属性、数据结构等)时,如果没有适当的控制机制,可能会出现数据竞争的情况。例如,一个线程正在对一个共享变量进行写操作,而另一个线程同时对这个变量进行读或写操作,可能会导致读取到错误的值或者数据被错误地修改。

一个线程安全的程序应该能够避免这些问题。这意味着,在多线程环境下,无论有多少个线程同时访问一个对象或者执行一个方法,其最终的结果应该与在单线程环境下顺序执行这些操作的结果相同,并且对象的状态始终是正确的。

例如,对于一个线程安全的集合类,如ConcurrentHashMap,当多个线程同时对其进行put、get等操作时,这个集合类能够保证内部的数据结构不会被破坏,每个操作都能正确地执行,不会出现因为并发访问导致的数据丢失、错误或者不一致的情况。

从更细的层面看,线程安全涉及到原子性、可见性和有序性。原子性是指一个操作要么全部执行成功,要么全部不执行,不会出现中间状态被其他线程看到的情况。例如,通过synchronized关键字或者原子类可以保证操作的原子性。可见性是指在多线程环境下,一个线程对共享变量的修改能够被其他线程及时看到。使用volatile关键字可以保证变量的可见性。有序性是指在多线程环境下,程序的执行顺序应该符合程序员的预期,不会因为编译器或者处理器的优化(如指令重排序)而导致不可预期的结果。通过适当的同步机制和volatile关键字也可以在一定程度上保证有序性。

17年+码农经历了很多次面试,多次作为面试官面试别人,多次大数据面试和面试别人,深知哪些面试题是会被经常问到,熟背八股文和总结好自己项目经验,将让你在面试更容易拿到Offer。 在多家企业从0到1开发过离线数仓实时数仓等多个大型项目,详细介绍项目架构等企业内部秘不外传的资料,介绍踩过的坑和开发干货,分享多个拿来即用的大数据ETL工具,让小白用户快速入门并精通,指导如何入职后快速上手。

全部评论

相关推荐

评论
点赞
1
分享
牛客网
牛客企业服务