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对象并返回其关联的值。

剩余60%内容,订阅专栏后可继续查看/也可单篇购买

17年+码农经历了很多次面试,多次作为面试官面试别人,多次大数据面试和面试别人,深知哪些面试题是会被经常问到。 在多家企业从0到1开发过离线数仓实时数仓等多个大型项目,详细介绍项目架构等企业内部秘不外传的资料,介绍踩过的坑和开发干货,分享多个拿来即用的大数据ETL工具,让小白用户快速入门并精通,指导如何入职后快速上手。 计划更新内容100篇以上,包括一些企业内部秘不外宣的干货,欢迎订阅!

全部评论

相关推荐

评论
1
6
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务