Java 并发编程 - 锁

1. synchronized 和 ReentrantLock 的应用,以及他们的区别

代码示例

synchronized代码实现

1. 同步代码块

public void func() {
    synchronized (this) {
        // 锁资源为当前实例,作用于同一个对象
    }
}
public void func() {
    synchronized (SynchronizedExample.class) {
        // 锁资源为类,作用于整个类
    }
}

2. 同步方法

public synchronized void func () {
    // 同步实例方法,作用于同一个对象
}
public static synchronized void func () {
    // 同步静态方法,作用于整个类
}

ReentrantLock代码实现

public class LockExample {
    private Lock lock = new ReentrantLock();
    public void func() {
        lock.lock();
        try {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        } finally {
            lock.unlock(); // 确保释放锁,从而避免发生死锁。
        }
    }
}

synchronized和ReentrantLock两者区别

  1. 锁的实现 synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的
  2. 等待可中断 ReentrantLock 可中断(放弃等待),而 synchronized 不行
  3. 公平锁 synchronized是非公平锁,而ReentrantLock可以实现公平锁或非公平锁
  4. 灵活性  ReentrantLock可以绑定多个condition对象用于线程协作,如生产消费模型

总结使用场景

  1. 简单场景下优先使用synchronized
  2. 等待时长限制、公平性要求、需要更多灵活性的场景使用ReentrantLock

2. synchronized的工作原理(重点)

synchronized关键字是Java虚拟机(JVM)提供的。它的工作原理涉及到对象头中的锁标记位监视器对象(monitor)

1. 重要对象

1. 对象头中markword的锁标记位

  • 在Java对象的内存布局中,对象头中的一部分用来存储锁相关的信息,其中就包括了一个锁标记位
  • 当对象被创建时,锁标记位被初始化为未锁定状态
  • 当线程获得对象的锁时,锁标记位被设置为锁定状态;当释放锁时,锁标记位被设置回未锁定状态

2. 监视器对象(monitor)

  • 每个Java对象都与一个监视器对象相关联,它用于实现对象的同步。
  • 监视器对象包含一个等待队列和一个通知队列,用于管理等待获取对象锁的线程以及通知等待的线程。

2. synchronized工作流程

1. 进入同步代码块

  1. 当一个线程尝试进入一个synchronized代码块时,它首先尝试获取对象的锁
  2. 如果对象的锁标记位处于未锁定状态,那么该线程获得锁,并且锁标记位被设置为锁定状态,允许该线程执行同步代码块
  3. 如果对象的锁标记位处于锁定状态,那么该线程被阻塞,直到获取到锁

2. 退出同步代码块

  1. 当线程执行完synchronized代码块后,会释放对象的锁,将锁标记位设置为未锁定状态
  2. 如果是重量级锁状态,有其他线程在等待获取对象的锁,它们将会被唤醒并尝试获取锁(非公平 随机唤醒)

3. synchronized锁升级的流程

synchronized锁是JVM虚拟机在运行时会根据锁的竞争情况自动进行状态的升级,以达到最佳的性能和吞吐量

升级流程: 无锁状态 --> 偏向锁状态 --> 轻量级锁状态 --> 重量级锁状态

1. 无锁状态 --> 偏向锁状态

  1. 当一个线程尝试进入synchronized代码块时,如果这个锁处于无锁状态,线程会尝试使用CAS操作将对象头中Mark Word锁标记位设置为锁定状态,并记录线程ID,表示当前线程持有该锁,这样锁的状态就升级为偏向锁
  2. 偏向锁状态下,如果该线程再次获取该锁时,可以检查对象头中的线程ID是不是自己,如果是就不需要再竞争资源,直接进入同步代码块

2. 偏向锁状态 --> 轻量级锁状态

  1. 如果有其他线程尝试获取偏向锁状态下的锁,偏向锁就会失效,锁会升级为轻量级锁状态。

3. 轻量级锁状态 --> 重量级锁状态

  1. 轻量级锁状态下,线程会使用CAS操作来尝试获取锁,如果成功,线程就可以顺利进入同步代码块。如果失败,线程会自旋再次尝试获取锁
  2. 如果自旋10次后线程还是没有获取到锁资源,锁会升级为重量级锁状态(也叫做锁膨胀)。
  3. 锁膨胀流程:
    1. 创建互斥量:JVM会为锁对象分配一个操作系统的监视器对象(Monitor),这是重量级锁的基础结构。
    2. 升级锁标志位:锁对象的对象头中的锁标志位会从轻量级锁状态更改为重量级锁状态,指向Monitor
    3. 锁记录转换:将线程的锁记录和对象的锁状态转换为指向Monitor,其他竞争线程将被阻塞在互斥量上。
    4. 阻塞竞争线程:竞争线程将进入互斥量的等待队列,进入阻塞状态,等待被唤醒和获取锁。

偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。

4. 谈谈你对AQS的理解

  • AQS是多线程同步器,他是JUC包中多个组件的底层实现,比如说像ReetrantLock、CountDownlatch、Semaphore都用到了AQS。
  • AQS提供了两种锁的机制,分别是排它锁共享锁
    • 排它锁就是多个线程竞争同一共享资源的时候,同一时刻只允许一个线程去获得锁资源。比如lock中的ReentrantLock重入锁
    • 共享锁的话也可以称为读锁,就是同一时刻只允许多个线程去获得 锁资源,比如CountDownlatch、Semaphore都用到了AQS中共享锁的功能。

AQS是这样设计的:

  1. 通过一个int类型的数值state,用到记录锁竞争的状态,0代表没有任何线程竞争锁资源,大于等于1表示有线程正在持有该资源。一个线程来竞争锁资源的时候,首先会判断state是否等于0,如果是则更新为1,但是这个过程中如果有多个线程同时操作就会有线程安全问题,AQS是采用了CAS机制来保证更新操作的原子性。
  2. 未获取到锁的线程,通过Unsafe类中的park方法去进行阻塞,把阻塞的线程按FIFO的原则加入到双向链表的队列中,当获得锁资源的线程释放锁之后,会从双向链表的头部唤醒下一个等待的线程,再去竞争锁
  3. 最后关于锁的公平性和非公平性问题,AQS提供了两种机制,公平锁会去判断阻塞队列是否有阻塞的线程,如果有则加入到尾部。非公平锁则不管阻塞队列是否有阻塞的线程,直接尝试修改state竞争锁资源,如果尝试失败则也加入到阻塞队列,但是假设在一个临界点资源释放时刚好该线程竞争到锁资源,便可以直接执行,这个过程就是非公平的

5. Java中的锁有哪些

  • 是否锁住同步资源
    • 锁住:悲观锁  适合写多的场景
    • 不锁住:乐观锁 适合查多的场景
  • 竞争锁是否排队
    • 排队 公平锁
    • 先尝试插队,失败了再排队 非公平锁
  • 多个线程能不能共享锁
    • 共享锁
    • 不能 排它锁
  • synchronized锁升级
    • 偏向锁:一个线程同步资源时自动获取资源
    • 轻量级锁:获取不到锁,自旋等待锁释放
    • 自旋锁:自旋锁是一种特殊的锁,当获取锁失败时,线程不会阻塞而是循环等待直到获取到锁为止
    • 重量级锁:获取不到锁,阻塞等待唤醒
  • 可重入锁:获取到锁资源后可以重复获取 ReentrantLock
  • 读写锁:读时不加锁,写时加锁  ReentrantReadWriteLock
  • 分段锁: concurrentHashMap

6. ReentrantLock加锁、解锁流程

1. ReentrantLock加锁流程

1. ReentrantLock 的 lock() 方法,会委托给内部抽象类Sync来实现具体的加锁操作,Sync类继承自AQS(AbstractQueuedSynchronizer)

// ReentrantLock lock方法
private final Sync sync;
public void lock() {
    sync.lock();
}

// Sync继承自AbstractQueuedSynchronizer
abstract static class Sync extends AbstractQueuedSynchronizer {
    abstract void lock();
}

2. 内部抽象类Sync有两个子类,分别为公平锁实现FairSync 和非公平锁实现NonfairSync

        公平锁(主打排队):在锁资源空闲时也不争抢,而是先判断队列是否有前驱结点,没有的话才去获取锁,其他情况下都是排队

        非公平锁(主打插队):lock方法先尝试CAS修改锁状态,又在tryAcquire又再尝试修改锁状态,都失败了才排队

3. acquireQueued():将当前线程加入同步队列中等待获取锁,这个过程中通过监控前驱节点的状态进行park阻塞和被前驱节点唤醒,最终的结果是获取到锁或者中断退出

public final void acquire(int arg) {
    // 获取不到锁、将当前线程加入队列尾部,等待获取锁
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        // 如果满足条件
        selfInterrupt();
}

final boolean acquireQueued(final Node node, int arg) {
    // 1. 首先将 failed 标志设置为 true,准备进入循环尝试获取锁
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            // 通过循环获取前驱节点 p,如果前驱节点是头节点(head) 并且可以尝试获取锁
            // 则将当前节点设置为头节点,断开前驱节点连接,并将 failed 标志设置为 false,从循环中返回
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            // 获取不到锁,shouldParkAfterFailedAcquire方法根据条件判断是否将当前线程挂起
            // parkAndCheckInterrupt方法挂起当前线程
            if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    // 首先获取前驱节点 pred 的等待状态(waitStatus)
    int ws = pred.waitStatus;
    // 如果前驱节点的等待状态是 SIGNAL,表示前驱节点已经表示需要唤醒后继节点了,当前节点需要挂起
    if (ws == Node.SIGNAL)
        return true;
    // 如果前驱节点的等待状态大于 0,表示前驱节点已经取消或者中断等待
    // 需要将当前节点的前驱节点向前移动,直到找到一个等待状态小于等于 0 的节点
    if (ws > 0) {
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        // 如果前驱节点的等待状态为正常状态(0),则将前驱节点的等待状态设置为 SIGNAL,表示需要唤醒当前节点
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

private final boolean parkAndCheckInterrupt() {
    // 挂起当前线程,等待唤醒  在release释放锁方法时,会调用LockSupport.unpark()唤醒后续节点
    LockSupport.park(this);
    // 唤醒后检查中断标记
    return Thread.interrupted();
}

2. ReentrantLock解锁流程

Java
public void unlock() {
    sync.release(1);
}

public final boolean release(int arg) {
    // tryRelease 检查当前线程是否持有锁,并释放锁资源,修改state为0,修改占用线程为null
    if (tryRelease(arg)) {
        Node h = head;
        // 唤醒头节点的下一个线程
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

private void unparkSuccessor(Node node) {
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);
    Node s = node.next;
    if (s == null || s.waitStatus > 0) {
        s = null;
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    if (s != null)
        LockSupport.unpark(s.thread);
}

3. ReentrantLock waitstatus 状态

  1. 初始化等待节点:当一个线程请求锁时,会将线程包装成一个 Node 对象,并将其插入到等待队列中。此时,Node 的 waitStatus 会被初始化为 0
  2. 标记节点为 SIGNAL 状态:当一个节点需要被唤醒时(例如,当前节点释放了锁),会将该节点的 waitStatus 标记为 SIGNAL(-1)状态。这通常发生在释放锁时,调用 enq 方法确保队列的一致性,并调用 release 方法的操作中。
  3. 标记节点为 CANCELLED 状态:当一个节点需要被取消时(例如,线程被中断或超时),会将该节点的 waitStatus 标记为 CANCELLED(1)状态。此操作通常在超时处理或中断处理时发生。
  4. 标记节点为 CONDITION 状态:将一个节点从等待队列中转移到条件队列中时,会将其 waitStatus 状态标记为 CONDITION(-2)。这通常在条件等待操作时发生。
  5. 传播 PROPAGATE 状态:在 Shared 模式下,如果当前节点的后继节点不需要进行线程唤醒,而是应当被传播至其他节点,会将当前节点的 waitStatus 标记为 PROPAGATE(-3)状态。这可以在支持 Shared 模式下的处理中看到。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值