深度思考java和OS中的各种锁

推荐

1 CountdownLatch和CyclicBarrier的区别使用场景与具体实现
2 有哪些 Java 面试题 90% 的公司都会问到?

1 OS中的各种锁

1.1 有哪些锁

在操作系统中,锁是用于控制多个进程或线程访问共享资源的基本工具。以下是几种典型的锁及其描述:

  1. 互斥锁 (Mutex Lock):

    • 互斥锁保证任何时候只有一个线程可以持有该锁,从而保证对共享资源的独占访问。
    • 用于保护临界区,防止同一时间有多个线程进入临界区。
  2. 读写锁 (Read-Write Lock):

    • 允许多个线程同时读共享资源,但在写入资源时只允许一个线程。
    • 当某个线程持有写锁时,其他线程不能获取读锁或写锁。
  3. 自旋锁 (Spinlock):

    • 当线程尝试获取自旋锁并失败时,它会不断循环尝试获取锁,而不是进入睡眠状态。这在锁被持有时间较短的情况下是有效的,因为线程可以快速获取锁而不用等待。
    • 适用于锁持有时间短和线程不想在获取锁时睡眠的场景。
  4. 信号量 (Semaphore):

    • 是一个更为通用的同步工具,可以看作是可以持有多个同类资源的锁,与互斥锁不同的是state字段是一个可以大于1的整数值,表示资源数量。
    • 信号量可以限制对资源的并发访问数,而不仅仅是一个。
  5. 条件变量 (Condition Variable):

    • 常与互斥锁结合使用,允许线程等待某个条件为真以获取锁。
    • 当某个条件不满足时,线程可以被放到休眠状态,直到另一个线程更改了条件并唤醒等待的线程。
  6. 屏障 (Barrier):

    • 用于同步一组线程,使它们在某个点上达到共同的执行点。
    • 当所有线程都到达屏障时,它们都会被释放继续执行。
  7. 读者-写者锁 (Reader-Writer Lock):

    • 类似于读写锁,但更强调“读者优先”或“写者优先”的策略。
    • “读者优先”允许多个读者并发访问资源,直到一个写者来到。而“写者优先”会让写者有更高的优先级。
  8. 递归锁 (Recursive Lock):

    • 允许一个线程多次获取同一个锁,而不会造成死锁。
    • 通常用于需要多次进入临界区的复杂函数或方法。

这些锁的实现和语义可能因操作系统和具体应用而异。选择合适的锁类型取决于具体的同步需求和使用场景。

1.2 基本实现原理

在操作系统中,锁的实现通常涉及硬件级的支持、系统调用和高效的数据结构设计。以下是几种常见的锁及其基本实现原理:

  1. 互斥锁 (Mutex Lock)

    • 基本实现: 使用原子操作,如Test-and-SetCompare-and-Swap。这些原子操作是由硬件直接支持的。
    • 当线程尝试获取锁时,它使用原子操作检查锁的状态。如果锁是空闲的,则线程获取锁;否则,线程可能被阻塞或自旋。
  2. 读写锁 (Read-Write Lock)

    • 基本实现: 包含两部分:一个读锁计数器和一个互斥锁。互斥锁用于写操作,读锁计数器用于跟踪当前的读线程数量。
    • 读取时,读锁计数器增加;写入时,首先要获得互斥锁。
  3. 自旋锁 (Spinlock)

    • 基本实现: 当锁被占用时,线程会在一个循环中不断尝试获取锁,而不进入休眠状态。
    • 也是基于原子操作实现的,如Test-and-Set
  4. 信号量 (Semaphore)

    • 基本实现: 通常包含一个整数值和一个等待队列。整数值代表可用资源的数量。
    • 当线程尝试获得资源而资源不足时,线程会被放入等待队列。当其他线程释放资源时,等待队列中的线程可能会被唤醒。
  5. 条件变量 (Condition Variable)

    • 基本实现: 与互斥锁结合使用。条件变量通常包含一个等待队列。
    • 当某个条件不满足时,线程会进入等待状态。当条件变为真时,线程会被唤醒。
  6. 屏障 (Barrier)

    • 基本实现: 屏障通常包含一个计数器和一个等待队列。计数器跟踪已到达屏障的线程数。
    • 当所有线程都到达屏障时,它们都会被释放继续执行。
  7. 读者-写者锁 (Reader-Writer Lock)

    • 基本实现: 与读写锁类似,但可能包括其他策略,如优先权策略,来决定何时允许读或写操作。
  8. 递归锁 (Recursive Lock)

    • 基本实现: 与互斥锁类似,但它还跟踪锁的拥有者和锁的持有计数。
    • 当当前持有锁的线程再次尝试获取锁时,持有计数增加而不是被阻塞。

这些锁的实现会涉及到操作系统的调度策略、上下文切换和内存管理。锁的选择和使用也会受到程序的同步模式、性能需求和应用场景的影响。

1.3 为什么线程的五种状态中,需要有就绪态,需要有就绪队列?

答:根本原因是cpu的核数小于“等待条件已经满足,随时准备上cpu执行”的线程数,所以不得不把这种就绪态的线程放到一个队列中存起来,这个队列也就取名为就绪队列。

1.4 互斥锁和信号量的关系

互斥锁和信号量都是同步原语,用于管理对共享资源的访问。但是它们在使用场景和实现上有所不同。

  • 互斥锁(Mutex): 它是一个二元锁,通常只有两种状态:锁定和解锁。它被设计用来确保在同一时间内只有一个线程可以访问某个资源或代码段。
  • 信号量(Semaphore): 它可以视为一个计数器,用来控制对一组资源的访问。它的值表示可用资源的数量。当信号量的值大于0时,线程可以访问资源并将信号量的值减一。当信号量的值为0时,线程必须等待其他线程释放资源。
    具体来说,互斥锁可以视为一个特殊的信号量,它的计数器只能为0或1。

1.5 Java中的java.util.concurrent.locks.ReentrantReadWriteLock是读者-写者锁的一个实现。它包括两个锁:一个读锁和一个写锁。读锁允许多个线程同时持有锁,而写锁则是独占的。独占的写锁为什么能排斥读锁呢?这不是两把锁吗?

ReentrantReadWriteLock中的读锁和写锁确实是两个不同的锁对象,但它们共同维护了一个锁状态变量。这个状态变量可以跟踪当前持有锁的线程和持有类型(读锁还是写锁)。因此,尽管它们是两个不同的锁对象,但它们仍然共享相同的状态信息。

读锁允许多个线程同时持有锁,因为读取操作通常不会修改共享资源的状态。但是,写操作需要对资源进行修改,因此它需要独占锁。当一个线程持有写锁时,它会阻止其他线程(无论是读还是写)获取锁,直到写锁被释放。

ReentrantReadWriteLock通过维护一个内部状态来实现这种行为,该状态可以区分读锁和写锁。当一个线程请求写锁时,它会检查锁状态,如果锁状态表示没有线程持有锁,或者只有当前线程持有写锁,那么写锁请求将被授予。否则,写锁请求将被阻塞,直到锁状态变为可获取写锁的状态。

相反,当一个线程请求读锁时,它会检查锁状态,如果锁状态表示没有线程持有写锁,那么读锁请求将被授予。否则,读锁请求将被阻塞,直到锁状态变为可获取读锁的状态。

通过这种方式,ReentrantReadWriteLock实现了独占的写锁和共享的读锁,并确保当一个线程持有写锁时,其他线程无法获取读锁或写锁。
下面是java中的读者和写者锁:

import java.util.concurrent.locks.*;

public class ReadWriteLockExample {
    private ReadWriteLock lock = new ReentrantReadWriteLock();
    private Lock readLock = lock.readLock();
    private Lock writeLock = lock.writeLock();
    private int value;

    public int readValue() {
        readLock.lock();
        try {
            return value;
        } finally {
            readLock.unlock();
        }
    }

    public void writeValue(int newValue) {
        writeLock.lock();
        try {
            value = newValue;
        } finally {
            writeLock.unlock();
        }
    }

    public static void main(String[] args) {
        ReadWriteLockExample example = new ReadWriteLockExample();

        for (int i = 0; i < 3; i++) {
            new Thread(() -> {
                System.out.println(Thread.currentThread().getName() + " read value: " + example.readValue());
            }).start();
        }

        new Thread(() -> {
            example.writeValue(42);
            System.out.println(Thread.currentThread().getName() + " wrote value: 42");
        }).start();
    }
}

1.6 条件变量的实现不需要观察线程轮询条件队列。条件变量通常与互斥锁一起使用,当某个条件不满足时,线程可以等待在条件变量上,直到其他线程更改共享资源的状态并唤醒等待线程。关键问题是唤醒线程如何找到被唤醒线程呢,这里有什么机制吗?

条件变量的实现与唤醒机制: 条件变量通常与互斥锁一起使用,允许线程在条件不满足时等待。当其他线程更改共享资源的状态并唤醒等待线程时,唤醒的线程会重新检查条件。这种机制是由条件变量和互斥锁共同实现的,不需要专门的观察线程来轮询条件队列。

条件变量通常提供两个主要操作:wait和signal(或notify)。wait操作会使当前线程等待在条件变量上,直到另一个线程调用signal来唤醒它。当线程调用signal时,操作系统的调度器会选择一个在条件变量上等待的线程(如果有的话),并将其状态更改为可运行状态。这种机制通过操作系统的调度器实现,不需要应用程序代码进行轮询。

1.7 屏障通常用于确保一组线程在一个特定的执行点上同时到达。这可以通过使用一个计数器和一个条件变量来实现。以下是一个简单的Java实现:

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public class Barrier {
    private final int totalThreads;
    private int arrivedThreads = 0;
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();

    public Barrier(int totalThreads) {
        this.totalThreads = totalThreads;
    }

    public void await() throws InterruptedException {
        lock.lock();
        try {
            arrivedThreads++;
            while (arrivedThreads < totalThreads) {
                condition.await();
            } else {
                condition.signalAll();
            }
        } finally {
            lock.unlock();
        }
    }
}

在上述代码中,我们定义了一个名为Barrier的类,其中有一个计数器arrivedThreads和一个条件变量condition。当线程调用await()方法时,它将计数器增加1,然后检查是否达到了所需的线程总数。如果没有达到,则线程将等待条件变量。当最后一个线程到达时,它会唤醒所有正在等待的线程。

1.7.1 这里为什么条件变量一定要配合锁来使用?

条件变量(Condition)通常需要与锁(Lock)配合使用,主要有以下几个原因:

  1. 原子性: 通过将锁和条件变量配合使用,我们可以确保对共享资源的访问是原子的。在条件变量的等待和通知操作过程中,锁可以确保只有一个线程能够访问共享资源和执行相关的操作。

  2. 防止竞态条件: 竞态条件是指多个线程同时访问和修改共享资源时,导致资源的状态取决于线程的执行顺序。使用锁和条件变量可以确保在检查资源状态和执行相应的操作之间没有其他线程能够修改资源状态,从而避免竞态条件。

  3. 避免虚假唤醒: 虚假唤醒是指线程在没有接收到明确的唤醒信号的情况下醒来。这可能是由于操作系统、硬件等外部因素导致的。使用锁和条件变量可以确保线程只有在接收到明确的唤醒信号后才能继续执行。

  4. 协调多个线程: 锁和条件变量一起使用可以更好地协调多个线程对共享资源的访问。例如,可以使用锁保护共享资源,然后使用条件变量对资源访问进行调度。当资源不满足线程的需求时,线程可以等待在条件变量上,直到资源满足需求后再被唤醒。

  5. 高效利用资源: 与忙等待(busy-waiting)相比,使用锁和条件变量可以更高效地利用系统资源。在忙等待中,线程不断地检查资源状态,这会浪费CPU时间。使用条件变量,线程可以在资源不满足需求时进入睡眠状态,释放CPU供其他线程使用。

总之,条件变量和锁一起使用可以更有效地管理和协调多个线程对共享资源的访问,避免竞态条件和虚假唤醒,提高系统的效率和稳定性。

1.7.2 ,执行完 condition.await();后,那这个锁是不是不能释放了,后面的线程也进不来了呢,因为有一个while循环包括在await()方法中

condition.await();执行后,条件变量会自动释放持有的锁,使其他线程能够获取该锁并进入临界区。当线程被唤醒后(无论是因为明确的唤醒信号还是虚假唤醒),它将再次尝试获取锁并继续执行。在本例中,线程将重新检查条件,如果条件仍不满足,则线程将再次调用condition.await();并释放锁。当条件满足时,线程将继续执行,并在离开临界区后释放锁。

lock.lock();
try {
    while (arrivedThreads < totalThreads) {
        condition.await();//如果没有
    }
    // 继续执行,条件满足
} finally {
    lock.unlock();
}

在这里,condition.await();在等待的同时会释放锁,所以其他线程可以获取该锁并进入临界区。当线程被唤醒并满足条件后,它将继续执行,并在离开临界区后释放锁。

1.7.3 所以await()方法让线程回退到while执行之前的状态,当被唤醒后,又开始从while开始执行竞争对吗?

不完全是这样。await()方法不会让线程回退到while循环之前的状态,而是让线程暂停执行并等待在条件变量上。当线程被唤醒后(例如通过signal()signalAll()),它会从await()方法的调用点继续执行。在本例中,该点正好在while循环内部。线程将重新检查循环条件,如果条件仍不满足,则线程将再次调用await()并等待。

当线程被唤醒并从await()方法继续执行时,它必须重新获取锁。这意味着在唤醒后,线程可能需要竞争锁。当线程成功获取锁后,它将继续执行while循环并检查条件。如果条件满足,线程将退出循环并继续执行。

在这里的情况下,唤醒后的线程不会从while循环的开始处执行,而是从await()方法的调用点继续执行,然后重新检查循环条件。

2 一些锁的基本信息

2.1 线程阻塞时是否占用资源

线程阻塞时仍会占用一些资源:

  • 内存资源:线程的栈、局部变量、引用的对象等仍然保留在内存中。
  • 系统资源:线程管理相关的资源,例如线程控制块(Thread Control Block, TCB)。
    不过,被阻塞的线程不会消耗 CPU 时间片,因此不会对 CPU 造成负担。

2.2 是不是OS层面上涉及到线程阻塞一定会涉及到切换为内核态?

通常,线程的阻塞和唤醒确实涉及系统调用,这可能会导致从用户态切换到内核态。但有些高级的同步操作可能使用 “spin-wait” 或 “busy-wait” 技术,这些操作会在用户态自旋,尝试多次获取锁,而不是直接进入阻塞状态(可能多次失败后才进入到内核),从而避免用户态到内核态的切换。

2.3 创建、销毁和执行线程都是在内核态中实现的吗:

创建和销毁线程涉及到操作系统资源的分配和回收,通常需要进行系统调用,这会涉及到用户态到内核态的切换。线程的执行主要在用户态进行,但当线程进行一些系统调用或触发某些事件(如页面错误)时,会切换到内核态。

3 java中的锁

3.1 Object#wait()和Object#notify()和AQS定义的几种锁的实现,作用上的区别

- `Object#wait()` 和 `Object#notify()` 是 Java 对象监视器的基本方法,用于线程间的通信。它们允许一个线程等待特定的条件,并允许另一个线程通知该条件的改变。
- AQS 是一个框架,用于构建具有阻塞和超时等待的同步器,如 `ReentrantLock`、`Semaphore`、`CountDownLatch` 等。

主要的差异在于:
- AQS 提供了一个更为复杂和灵活的同步工具构建框架,支持独占和共享模式,而 `Object#wait()` 和 `Object#notify()` 更为基础。
- AQS 定义的锁(如 `ReentrantLock`)允许更高的可扩展性和灵活性,例如尝试获取锁、带超时的锁等待,或者查询锁状态。
- 使用 AQS 定义的锁和同步工具,你可以控制更多的行为,而 `Object#wait()` 和 `Object#notify()` 提供的是一套比较基础的机制。

总的来说,选择哪种方式取决于你的需求。对于简单的线程间通信,Object#wait()Object#notify() 可能就足够了;但对于更复杂的同步需求,比如类似countDownLatch,AQS 提供的工具会更为合适。

3.2 java中实现的一些应用层的锁,比如AQS下的各种,会最终使用系统调用嘛,比如是否会切换到内核态?

AQS 是 Java 并发包中的一个框架,用于构建锁和同步器。其底层使用了 java.util.concurrent.locks.LockSupport 类的 park() 和 unpark() 方法。在大多数情况下,这些方法在阻塞和唤醒线程时不会触发系统调用,因为它们使用了所谓的 “spin-wait” 或 “busy-wait” 技术。只有当线程需要真正地被阻塞时,park() 才会涉及系统调用,从而可能引起用户态到内核态的切换。

3.3 synchronized 是否使用系统调用:

synchronized 的实现在 JVM 的早期版本中,当线程不能获得锁时,确实会涉及到内核态的切换。但随着 JVM 的发展和优化,现代的 JVM 采用了多种策略来优化 synchronized,如偏向锁、轻量级锁和重量级锁。

  • 偏向锁:当一个锁被线程获取,但没有竞争时,JVM 会将锁标记为偏向锁,并记录这个线程的 ID。当同一个线程再次尝试锁定时,它可以快速获得该锁。这一步不涉及系统调用。
  • 轻量级锁:当有小规模的锁竞争时,JVM 会使用自旋锁的技术,使得线程在用户态自旋等待锁释放。这也不涉及系统调用。
  • 重量级锁:当锁竞争激烈时,JVM 会将锁膨胀为重量级锁,此时涉及到线程阻塞和唤醒,可能会涉及系统调用,从而触发用户态到内核态的切换。

3.4 Object#wait()、Object#notify() 是否使用系统调用

Object#wait() 和 Object#notify() 方法在 JVM 内部都是通过内部的 ObjectMonitor 实现的。当线程调用 wait() 并需要等待时,它实际上会被阻塞。与此相似,notify() 或 notifyAll() 被调用时,会唤醒正在等待的线程。在这些场景下,涉及到线程的阻塞和唤醒,因此可能会涉及系统调用,并可能导致从用户态切换到内核态。但具体的实现细节和优化可能因 JVM 版本和平台而异。

3.5 CountdownLatch的应用场景是分布式事务嘛?

CountDownLatch是Java并发编程中的一个同步辅助类,它允许一个或多个线程等待直到一组操作执行完毕。它经常用于控制并发线程的启动和完成,但不是专门设计用于分布式事务的。

当我们谈到分布式事务时,我们通常指的是在多个不同的系统或服务中协调和执行的事务。例如,一个分布式事务可能涉及到多个微服务中的数据库操作。在这种情况下,我们通常使用两阶段提交(2PC)、三阶段提交或某种补偿机制来确保分布式事务的原子性。

然而,你可以在某些特定场景下使用CountDownLatch来模拟简单的分布式协调。以下是一个非常简化的例子,说明如何使用CountDownLatch来等待多个线程完成他们的任务(这里模拟的是多个服务的操作):

import java.util.concurrent.CountDownLatch;

public class DistributedTransactionDemo {

    public static void main(String[] args) {
        int numberOfServices = 3;
        CountDownLatch latch = new CountDownLatch(numberOfServices);

        // 模拟三个不同的服务操作
        new Thread(() -> {
            System.out.println("Service 1 is processing...");
            // 执行一些操作...
            latch.countDown();
        }).start();

        new Thread(() -> {
            System.out.println("Service 2 is processing...");
            // 执行一些操作...
            latch.countDown();
        }).start();

        new Thread(() -> {
            System.out.println("Service 3 is processing...");
            // 执行一些操作...
            latch.countDown();
        }).start();

        try {
            // 主线程等待其他三个服务操作完成
            latch.await();
            System.out.println("All services have finished processing. Committing transaction...");
        } catch (InterruptedException e) {
            e.printStackTrace();
            System.out.println("Transaction failed. Rolling back...");
        }
    }
}

需要注意的是,这只是一个简化的例子,真正的分布式事务处理要复杂得多,通常需要特定的中间件或框架(如Saga模式、TCC、两阶段提交等)来协调和处理。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值