并发编程-锁🔒篇(必看👍)

小龙coding大约 18 分钟

JUC常用关键字及类?

Java的并发包java.util.concurrent(JUC)提供了一系列并发工具类,这些类可以帮助我们更好地处理多线程并发问题。以下是一些常用的关键字和类:

  1. Executor框架:包括Executor、Executors、ExecutorService、ThreadPoolExecutor等类,用于管理和控制线程的运行。
  2. Fork/Join框架:包括ForkJoinPool、ForkJoinTask、RecursiveTask等类,用于实现分治任务的并发执行。
  3. 并发集合:包括ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue等类,这些是线程安全的集合类。
  4. 同步工具类:包括CountDownLatch、CyclicBarrier、Semaphore等类,用于协调多个线程之间的同步关系。
  5. Lock接口:包括Lock、ReentrantLock、Condition等类,提供了比synchronized关键字更灵活的线程同步机制。
  6. 原子操作类:包括AtomicInteger、AtomicLong、AtomicReference等类,用于实现无锁的线程安全操作。
  7. Future接口:包括Future、FutureTask、Callable等类,用于表示异步计算的结果。
  8. 阻塞队列:包括ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue、SynchronousQueue等类,这些都是线程安全的队列,用于在生产者和消费者之间进行线程间通信。

以上这些类和接口都是为了解决多线程编程中的各种问题,如线程间通信、数据共享、数据安全等问题。

4. Java中常见的锁有哪些,其特点简单说说?

Java中的锁主要有以下几种:

  1. Synchronized:Synchronized是Java中最基本的同步互斥机制,它可以保证同一时刻只有一个线程可以访问被synchronized修饰的代码块或方法。Synchronized在JVM层面实现,属于重量级锁。
  2. ReentrantLock:ReentrantLock是Java并发包java.util.concurrent.locks中的一个类,它提供了与synchronized相同的并发性和内存语义,但是添加了类似锁投票、定时锁等候和可中断锁等候的一些特性。ReentrantLock被称为可重入锁,是因为同一个线程可以多次获取同一个锁。
  3. ReadWriteLock:ReadWriteLock是一个读写锁接口,它维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。只要没有 writer,读取锁可以由多个 reader 线程同时持有,写入锁是独占的。
  4. StampedLock:StampedLock 是 Java8 引入的一种新的所机制,可以看做是读写锁的一个改进版本,它通过乐观读来解决读写锁的"写饥饿"问题。
  5. SpinLock(自旋锁):自旋锁是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。
  6. CountDownLatch/CyclicBarrier/Semaphore:这些并不是锁,但是它们提供的机制在某些情况下可以实现类似锁的效果,用于控制并发线程的执行。

可以看出它们各有各的特点和使用场景,因此需要根据具体的需求来选择使用哪种锁,按照其不同特点来区分,可以大致分为如下几类:

img

synchronized 和 Lock 区别?

synchronized和Lock都是Java中用于实现线程同步的机制,但它们在使用方式和功能上有一些区别:

  1. 使用方式:synchronized是Java的关键字,使用起来更简单。它可以直接用于方法或代码块,无需显式地获取和释放锁。而Lock是一个接口,需要通过java.util.concurrent.locks.Lock的实现类(如ReentrantLock)来使用。使用Lock时,需要显式地调用lock()方法获取锁,然后在finally块中调用unlock()方法释放锁,这样可以保证锁一定会被释放。
  2. 等待可中断:synchronized不支持等待的中断,也就是说,当一个线程获取不到锁时,它只能等待,不能被中断。而Lock支持等待的中断,可以通过lockInterruptibly()方法实现。
  3. 公平锁:synchronized不支持公平锁,也就是说,当多个线程同时尝试获取锁时,哪个线程能够获取到锁是不确定的。而Lock支持公平锁,可以通过构造函数传入true来创建一个公平锁,这样当多个线程同时尝试获取锁时,等待时间最长的线程将优先获取到锁。
  4. 锁绑定多个条件:synchronized不支持绑定多个条件,只能通过wait()和notify()方法进行线程的等待和唤醒。而Lock可以通过newCondition()方法创建多个Condition对象,实现对锁的精确控制。

总的来说,synchronized和Lock各有优缺点,需要根据实际情况选择使用。对于简单的同步需求,可以使用synchronized,因为它使用起来更简单。对于复杂的同步需求,如需要支持等待的中断、公平锁或绑定多个条件等,可以使用Lock。

在并发量特别高的情况下是使用 synchronized 还是 ReentrantLock

在并发量特别高的情况下,一般推荐使用 ReentrantLock,原因如下:

  1. 更高的性能:在Java 1.6之前,synchronized 的性能一般比 ReentrantLock 差一些。虽然在 Java 1.6 及之后的版本中,synchronized进行了一些优化,如偏向锁、轻量级锁等,但在高并发情况下,ReentrantLock 的性能通常会优于 synchronized。
  2. 更大的灵活性:ReentrantLock 比 synchronized 有更多的功能。例如,ReentrantLock 可以实现公平锁和非公平锁(synchronized只能实现非公平锁);ReentrantLock 提供了一个 Condition 类,可以分组唤醒需要唤醒的线程(synchronized 要么随机唤醒一个线程,要么唤醒所有线程);ReentrantLock 提供了 tryLock 方法,可以尝试获取锁,如果获取不到立即返回,不会像synchronized 那样阻塞。
  3. 更好的可控制性:ReentrantLock可以中断等待锁的线程(synchronized无法响应中断),也可以获取等待锁的线程列表,这在调试并发问题时非常有用。

但是,虽然 ReentrantLock 在功能上比 synchronized 更强大,但也更复杂,使用不当容易造成死锁。而 synchronized 由 JVM 直接支持,使用更简单,不容易出错。所以,在并发量不高,对性能要求不高的情况下,也可以考虑使用 synchronized。

说一下 ConcurrentHashMap 中并发安全的实现

ConcurrentHashMap 在 Java 1.7 和 Java 1.8 中的实现方式有所不同,但它们的共同目标都是提供高效的并发更新操作。下面我将分别介绍这两个版本的实现方式。

  1. Java 1.7:在Java 1.7中,ConcurrentHashMap 内部使用一个 Segment 数组来存储数据。每个Segment 对象包含一个 HashEntry 数组,每个 HashEntry 对象就是一个键值对。Segment 对象是可锁定的,每个Segment对象都可以看作是一个独立的 HashMap。在更新数据时,只需要锁定相关 Segment 对象,而不需要锁定整个HashMap,这样就可以实现真正的并发更新。Segment 的数量默认为 16,这意味着 ConcurrentHashMap 最多支持 16 个线程的并发更新。
  2. Java 1.8:在 Java 1.8 中,ConcurrentHashMap 的实现方式进行了改进。它取消了 Segment 数组,直接使用 Node 数组来存储数据。每个Node对象就是一个键值对。在更新数据时,使用 CAS 操作和 synchronized 来保证并发安全。具体来说,如果更新操作发生在链表的头节点,那么使用 CAS 操作;如果发生在链表的其他位置,或者发生在红黑树节点,那么使用synchronized。这样,ConcurrentHashMap可以支持更多线程的并发更新。

总的来说,ConcurrentHashMap 通过分段锁(Java 1.7)或 CAS+synchronized(Java 1.8)的方式,实现了高效的并发更新操作,从而保证了并发安全

你说高并发下 ReentrantLock 性能比 synchronized 高,那为什么 ConcurrentHashMap 在 JDK 1.7 中要用 ReentrantLock,而 ConcurrentHashMap 在 JDK 1.8 要用 Synchronized ?

这是一个很好的问题。首先,我们需要明确一点,虽然 ReentrantLock 在某些情况下的性能优于synchronized,但这并不意味着在所有情况下都是这样。

实际上,synchronized 在JDK 1.6 及以后的版本中已经进行了大量的优化,例如偏向锁、轻量级锁等,使得在竞争不激烈的情况下,synchronized 的性能已经非常接近甚至超过 ReentrantLock。

在 JDK 1.7的 ConcurrentHashMap中,使用 ReentrantLock(Segment)是为了实现分段锁的概念,即将数据分成多个段,每个段独立加锁,从而实现真正的并发更新。这种设计在当时是非常先进和高效的。

然而,在 JDK 1.8 的 ConcurrentHashMap 中,为了进一步提高并发性能,引入了更复杂的数据结构(如红黑树)和更高效的并发控制机制(如CAS操作)。在这种情况下,使用 synchronized 比ReentrantLock 更加简单和高效。首先,synchronized 可以直接与JVM进行交互,无需用户手动释放锁,减少了出错的可能性。

其次,synchronized 在 JDK 1.6及以后的版本中已经进行了大量的优化,性能已经非常接近 ReentrantLock。最后,synchronized 可以与其他 JVM 特性(如偏向锁、轻量级锁、锁消除、锁粗化等)更好地配合,进一步提高性能。

总的来说,选择使用 ReentrantLock 还是 synchronized,需要根据具体的需求和使用场景来决定。在 JDK 1.8 的 ConcurrentHashMap中,使用 synchronized 是一种更加合理和高效的选择。

有哪些并发安全的实现方式

  1. synchronized关键字:这是最基本的并发安全实现方式,它可以保证同一时刻最多只有一个线程执行该段代码,从而保证了类的实例状态的线程安全。
  2. volatile关键字:volatile关键字可以保证变量的可见性和禁止指令重排序,但不能保证复合操作的原子性。它通常用于标记状态变量。
  3. Lock接口和其实现类:例如ReentrantLock、ReentrantReadWriteLock等。相比于synchronized,Lock接口提供了更强大的功能,例如尝试获取锁、可中断的获取锁以及获取公平锁等。
  4. 原子类:例如AtomicInteger、AtomicLong、AtomicReference等。这些类通过CAS(Compare and Swap)操作实现了原子性更新。
  5. 并发集合:Java提供了一些线程安全的集合类,例如ConcurrentHashMap、CopyOnWriteArrayList等。
  6. ThreadLocal类:ThreadLocal可以为每个线程创建一个单独的变量副本,每个线程都只能操作自己的副本,从而实现了线程隔离,保证了线程安全。
  7. Semaphore信号量:Semaphore可以控制同时访问特定资源的线程数量,通过acquire()获取一个许可,如果没有就等待,通过release()释放一个许可。
  8. CountDownLatch:CountDownLatch允许一个或多个线程等待其他线程完成操作。
  9. CyclicBarrier:CyclicBarrier让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被阻塞的线程才会继续运行。
  10. Phaser:Phaser是JDK 1.7引入的一个用于解决控制多个线程分阶段共同完成任务的类。

以上就是 Java 中常见的一些并发安全的实现方式。

5、乐观锁了解吗?有哪些实现方式?

乐观锁认为数据在一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测。

乐观锁的实现一般有两种方式:

  1. 版本号机制:在数据表中加入一个版本号字段,每次读取数据时,将版本号一并读出,数据每更新一次,对应的版本号加1。当我们提交更新的时候,判断数据库表对应记录的当前版本号与我们读出来的版本号进行比对,如果数据库表当前版本号与我们读出来的版本号相等,则予以更新,否则认为是过期数据。
  2. CAS算法:CAS(Compare And Swap)算法涉及到三个操作数,分别是内存值V、预期原值A、新值B。当且仅当预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做并返回false。CAS是一种无锁算法,可以避免多线程的互斥等待,提高系统的并发性。

6、悲观锁有哪些?有哪些实现方式?

悲观锁和乐观锁恰好相反,它假设最坏的情况,认为数据在并发处理过程中一定会发生修改,所以在数据处理前就会先加锁,确保数据处理的过程中不会被其他线程修改。

悲观锁的实现方式主要有以下几种:

  1. 数据库锁:数据库本身就提供了一些锁机制,包括行锁、表锁、页锁等,可以在查询语句后加 FOR UPDATE来实现悲观锁。
  2. 共享锁和排他锁:共享锁(S锁)是允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁。排他锁(X锁)是允许获取排他锁的事务更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁。
  3. Java中的synchronized和ReentrantLock:另外可以使用 synchronized 关键字或ReentrantLock 来实现悲观锁,保证同一时刻只有一个线程可以执行某个方法或代码块。

乐观锁和悲观锁?分别在什么时候用?

乐观锁和悲观锁是处理并发操作时常用的两种锁策略:

  1. 乐观锁:乐观锁假设数据通常情况下不会造成冲突,所以在数据进行提交更新时才会正式对数据的冲突与否进行检测。如果发现冲突,就让返回错误信息,让用户决定如何去做。乐观锁适用于读多写少的应用场景,能够提高系统的并发性能。在Java中,乐观锁一般是通过使用版本号机制或CAS操作实现。

例如,一个电商网站的库存管理系统,如果每次读取数据都加锁会导致性能问题,因此可以在更新数据时使用乐观锁,只在提交更新时检查数据是否有冲突。

  1. 悲观锁:悲观锁假设数据会造成冲突,所以在数据进行任何处理之前都会先加锁,这样只有获得锁的处理器才能够处理数据,处理完后再解锁。悲观锁适用于写多读少的应用场景。在Java中,悲观锁就是synchronized和ReentrantLock等锁机制。

例如,银行转账操作,A账户转账给B账户,这个过程需要保证数据的一致性和原子性,所以在操作前需要加锁,操作后解锁,这就是典型的悲观锁的应用场景。

总的来说,选择乐观锁还是悲观锁,需要根据实际业务场景和需求来决定。

详细说说锁升级机制?

syncronized 是可重入的吗,是轻量级还是重量级?

synchronized 关键字在Java中是可重入的。所谓的“可重入”,就是指一个线程可以多次获取同一把锁。例如,一个线程在获取了某个对象的锁并进入同步块后,该线程可以再次获取该对象的锁并进入另一个同步块。

关于synchronized是轻量级还是重量级,实际上synchronized可以在两种状态之间转换:

  1. 轻量级锁:当锁是偏向锁,且被线程获取时,如果不存在竞争,JVM会把这个锁升级为轻量级锁。轻量级锁能够在没有多线程真正竞争的情况下,减少不必要的对象锁使用,提高性能。
  2. 重量级锁:当锁被不同的线程争用,即存在竞争,这时锁就会膨胀为重量级锁。重量级锁通过操作系统的互斥量(Mutex Lock)实现,Java线程映射到操作系统的原生线程之上,线程阻塞就会进入操作系统的调度中。

所以synchronized既可以是轻量级的,也可以是重量级的,具体取决于锁的竞争情况。

如果不可重入会有什么问题?

如果锁不是可重入的,那么就可能会导致死锁。所谓死锁,就是指两个或者多个操作系统的进程在执行过程中,因争夺资源而造成的一种相互等待的现象,若无外力干涉那他们都将无法推进下去。

具体到Java中,如果一个线程已经获取了某个锁,然后在锁的保护下再次请求同一把锁,如果锁不可重入,那么这个线程就会因为等待自己已经获取的锁而被阻塞,从而产生死锁。

例如,考虑以下代码:

public class Lock {
    public synchronized void method1() {
        method2();
    }

    public synchronized void method2() {
        // do something
    }
}

在这个例子中,如果synchronized不是可重入的,那么当一个线程执行method1方法获取了锁之后,再次调用method2方法时,就会因为等待自己已经获取的锁而被阻塞,从而产生死锁。

因此,为了避免这种情况,Java的synchronized锁设计为可重入的。

轻量级锁升级为重量级锁的过程?

进程线程的区别?线程死锁的原因?有哪四个必要条件?

进程和线程的区别主要体现在以下几个方面:

  1. 定义:进程是操作系统资源分配的基本单位,是能独立运行的基本单位;线程是操作系统调度的基本单位,是程序执行的最小单位。
  2. 资源分配:每个进程都有独立的代码和数据空间(进程上下文),进程间的切换会有较大的开销;线程是共享进程中的数据,线程间切换的开销小。
  3. 通信方式:进程间通信IPC通常包括管道、消息队列、信号量等,而线程间可以直接通过读写同一进程中的数据进行通信。
  4. 影响范围:一个进程崩溃后,在保护模式下不会对其他进程产生影响;一个线程崩溃整个进程会死掉。

线程死锁的原因通常是多个线程各自占有一部分资源并且同时等待对方的资源,由于每个线程都在等待对方释放资源,所以都停止执行,这就是死锁。

线程死锁的四个必要条件,也被称为哲学家就餐问题,包括:

  1. 互斥条件:一个资源每次只能被一个线程使用。
  2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:线程已获得的资源在未使用完之前,不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要预防这四个条件的发生,就可以防止死锁。

⚠️:关于死锁,结合场景更完整的解析请移步场景分析篇

JUC.lock咋实现的?AQS的原理?

⚠️:简易版回答,详细解析请移步 AQS

Java并发包(java.util.concurrent,简称JUC)中的Lock接口提供了比synchronized关键字更强大的锁定机制。Lock接口的主要实现类是ReentrantLock,它是基于Java的AQS(AbstractQueuedSynchronizer,抽象队列同步器)框架实现的。

AQS是JUC的核心框架,许多同步类都是基于AQS实现的,如ReentrantLock、Semaphore(信号量)、CountDownLatch(倒计时门闩)等。

AQS的核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,其中的节点Node用来保存等待线程和等待状态,当有其他线程释放锁或者当前线程超时,线程会从队列中唤醒,然后尝试获取资源。

AQS通过内部的FIFO队列来管理线程。线程首先会尝试获取资源,如果失败,就会加入到队列中,当线程释放资源后,会唤醒队列中的其他线程,这些线程会再次尝试获取资源。这个过程会一直重复,直到所有的线程都获取到资源