深度解析Java中的同步机制:Synchronized、Lock与AQS

2024-08-05 09:06:58 浏览数 (1)

前言

由于线程执行的过程是不可控的,所以需要采用同步机制来协同对对象可变状态的访问。加锁的主要目的是为了防止多个线程同时对共享资源进行修改,从而避免数据不一致的问题。在多线程环境中,如果没有适当的同步机制,一个线程可能在另一个线程正在访问共享资源时对其进行修改,导致不可预测的结果。

Java中的锁机制分为两种主要类型:显示锁和隐式锁。显示锁需要程序员显式地进行加锁和解锁操作,例如使用ReentrantLocklock()unlock()方法。相比之下,隐式锁则由JVM内部管理,其中最常见的是synchronized关键字,它负责对象级别的加锁和解锁。

除了这两种基本的锁机制外,Java还提供了其他方式来管理并发访问:使用volatile关键字可以确保变量的可见性,synchronized关键字不仅可以用于方法级别的同步,还可以用于同步块。此外,Java并发包(java.util.concurrent)提供了更高级别的同步工具,如SemaphoreCountDownLatchCyclicBarrierExchanger等,这些工具可以更灵活地管理多线程的并发访问。

Synchronized关键字

每个Java对象在创建后都会拥有一个Monitor(监视器锁),它的底层实现依赖于系统的Mutex Lock(互斥锁),这是一种重量级锁。然而,从Java 1.6版本开始,JVM内置锁经历了多项优化措施,以提升并发性能和减少锁竞争的影响。这些优化措施包括锁粗化、锁消除、偏向锁、轻量级锁和重量级锁等。

当Java中的synchronized关键字编译成字节码后,会发现其底层实现依赖于monitorentermonitorexit指令,这两条指令负责在JVM层面进行加锁和解锁操作。具体而言,monitorenter用于在进入同步块或方法时获取对象的监视器(Monitor),而monitorexit则用于退出同步块或方法时释放该监视器。

对象的内存布局

当我们使用synchronized关键字对一个对象进行加锁时,实际上会在该对象的对象头(Mark Word)中记录锁状态。对象的内存布局包括对象头、实例数据和对齐填充,其中对象头是存储对象自身运行时数据的一部分,被用来存储关于对象的元数据信息,如哈希码、GC信息以及锁状态等。

具体而言,Java对象的对象头在不同的JVM实现中会有所不同,但通常包括一些标记位和指向方法区中类元数据的指针。当一个线程尝试获取对象的锁时,它会检查对象头中的锁状态信息。对于synchronized锁来说,主要有以下几种状态:

  1. 无锁状态(无锁标记):对象刚被创建时的初始状态,此时对象头中的锁标记为无锁状态。
  2. 偏向锁状态:当只有一个线程访问对象时,会将对象头设置为偏向锁,记录获取偏向锁的线程ID。这样做是为了减少多线程情况下的竞争。
  3. 轻量级锁状态:当有多个线程访问同一个对象时,会尝试使用CAS(Compare and Swap)操作将对象头设置为轻量级锁。这时,线程会尝试使用自旋来获取锁,而不是阻塞。
  4. 重量级锁状态:如果自旋超过一定次数或者有多个线程竞争同一个对象时,对象头会被设置为重量级锁状态,此时线程会阻塞,而不是自旋等待。

通过这种方式,Java的锁系统能够根据对象的访问情况和并发需求,动态调整锁的状态,以提供最佳的性能和线程安全性。

在HotSpot虚拟机中,Java对象的内存布局可以细分为三个主要部分:对象头(Header)、实例数据(Instance Data)、以及对齐填充(Padding)。

对象头(Header):对象头是存储在对象内存中的元数据区域,包含了多种信息,如哈希码、对象所属的年代(用于垃圾回收)、锁状态及标志(用于多线程同步)、偏向锁的线程ID、偏向时间戳等。对象头的大小在不同情况下会有所变化,通常占据一定字节大小,例如在64位的HotSpot虚拟机中,对象头通常会占用16个字节。

实例数据(Instance Data):实例数据是对象中存储的成员变量、实例方法等具体数据,它们占据了对象的主要部分。这些数据根据对象的定义和类的结构而定,它们决定了对象的功能和行为。

对齐填充(Padding):由于对象在内存中的起始地址必须是8字节的整数倍,为了满足这一要求,可能会在实例数据和对象头之间填充一些无用的空间,这部分空间称为对齐填充。对齐填充的目的是为了提升访问效率和处理器的缓存命中率。

AQS具备特性

在Java中,并发编程的核心框架之一是AbstractQueuedSynchronizer(AQS),它具备多种特性和功能,如阻塞等待队列、公平与非公平性、可重入性、共享与独占模式以及允许中断等。这些特性使得AQS成为Java并发工具包(java.util.concurrent)中诸如锁(Lock)、闭锁(Latch)、屏障(Barrier)等同步器的基础。

在实现上,通常会通过定义AQS的子类(如内部类Sync),并重写其中的方法来实现具体的同步器功能。以下是对AQS各个特性的详细描述:

  1. 阻塞等待队列:AQS使用FIFO队列管理等待获取同步状态的线程,确保公平性或非公平性的调度策略。
  2. 公平/非公平:可以根据需要选择公平或非公平的获取同步状态方式。公平模式下,等待时间最长的线程优先获取同步状态;非公平模式则允许插队。
  3. 可重入:支持同一线程重复获取同步状态,避免死锁并简化编程模型。
  4. 共享/独占:AQS可以管理不同类型的同步状态,允许多个线程共享同步状态或者只允许一个线程独占。
  5. 允许中断:线程在等待同步状态时可以响应中断,提高程序的健壮性和响应能力。

在实现锁、闭锁、屏障等同步器时,一般会定义一个继承自AQS的内部类(通常命名为Sync),并在内部类中重写AQS的核心方法,如获取状态(acquire)、释放状态(release)等,来实现具体的同步逻辑和控制策略。

代码语言:java复制
static final class NonfairSync extends Sync {
        private static final long serialVersionUID = 7316153563782823691L;

        /**
         * Performs lock.  Try immediate barge, backing up to normal
         * acquire on failure.
         */
        final void lock() {
            if (compareAndSetState(0, 1))
                setExclusiveOwnerThread(Thread.currentThread());
            else
                acquire(1);
        }

        protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }
    }

NonfairSync作为ReentrantLock内部的一个类,默认实现了非公平锁的逻辑。公平锁和非公平锁的主要区别在于锁的获取策略,特别是在当前持有锁的线程释放锁时,下一个获取锁的线程是如何确定的。我们可以通过详细分析公平锁的lock()方法来更好地理解这一差异。

代码语言:java复制
static final class FairSync extends Sync {
        private static final long serialVersionUID = -3000897897090466540L;

        final void lock() {
            acquire(1);
        }

        /**
         * Fair version of tryAcquire.  Don't grant access unless
         * recursive call or no waiters or is first.
         */
        protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                if (!hasQueuedPredecessors() &&
                    compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c   acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }
    }

非公平锁在尝试获取锁时,首先会尝试设置锁的状态(state),这个状态是存储在节点(Node)中的属性。初始状态下,当state为0时表示当前没有线程持有该锁,任何线程都可以尝试获取。每次成功获取锁后,state会通过CAS(Compare and Swap)操作增加,因为这是可重入锁,所以每次获取锁都会使state增加。相应地,每次释放锁时,state会减少,直到减至0,这时其他等待线程才有机会抢锁。我们可以通过深入分析acquire(1)方法来更好地理解这一过程。

代码语言:java复制
 public final void acquire(int arg) {
         if (!tryAcquire(arg) &&
             acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
             selfInterrupt();
     }

在非公平锁中,tryAcquire(arg)方法首先尝试获取锁,并将锁的状态从0增加到 1。如果这一步失败,说明锁已经被其他线程抢占,接下来的操作是调用addWaiter()方法。

addWaiter()方法的作用是创建一个基于双向链表结构的等待队列。具体来说,它以Node.EXCLUSIVE模式创建节点,用于表示独占模式的等待。

接下来的acquireQueued()方法则负责让第一个等待节点去争取获取锁。如果这一尝试失败,它会将当前线程阻塞,直到获取到锁为止。如果当前节点的前一个节点是头结点(即第一个节点已经获取了锁),那么当前节点将会成为新的头结点,并返回获取锁的标志。

在公平锁的实现中,与非公平锁不同的是,tryAcquire(arg)方法多了一个判断步骤:它会检查同步队列(即等待队列)中是否还有其他等待节点。如果存在其他等待节点,公平锁会优先让这些等待的线程去获取锁,而不是直接让当前新的线程尝试获取。

释放锁的逻辑对于公平锁和非公平锁是相似的,都需要正确地更新状态和唤醒等待的线程,以确保锁的正确释放和下一个线程的获取。

代码语言:java复制
public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }

当state变为0时,还会涉及到exclusiveOwnerThread属性。exclusiveOwnerThread指向当前持有锁的线程。当state释放为初始状态时,exclusiveOwnerThread将被置为null,并且会唤醒头结点来取消阻塞。公平与非公平的区别在于获取锁时的竞争策略不同。

BlockingQueue实现原理

我们以ArrayBlockingQueue为例来探讨为什么AbstractQueuedSynchronizer(AQS)需要同时使用同步队列和条件队列这两种队列结构。

代码语言:java复制
public ArrayBlockingQueue(int capacity) {
        this(capacity, false);
    }
 public ArrayBlockingQueue(int capacity, boolean fair) {
        if (capacity <= 0)
            throw new IllegalArgumentException();
        this.items = new Object[capacity];
        lock = new ReentrantLock(fair);
        notEmpty = lock.newCondition();
        notFull =  lock.newCondition();
    }

在源码中,我们创建阻塞队列时,通常需要指定初始容量大小,并且默认使用非公平锁。具体实现上,我们会看到使用了独占锁(如ReentrantLock)以及两个条件队列(Condition)作为底层支持。

代码语言:java复制
public void put(E e) throws InterruptedException {
        checkNotNull(e);
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            while (count == items.length)
                notFull.await();
            enqueue(e);
        } finally {
            lock.unlock();
        }
    }

当我们向阻塞队列中添加元素时,会将元素放入队列的底层数组中,并且可能唤醒等待在条件队列上的阻塞线程。然而,当数组已满时,当前线程将会阻塞,这时候就需要观察一下await方法的实现。

代码语言:java复制
public final void await() throws InterruptedException {
            if (Thread.interrupted())
                throw new InterruptedException();
            Node node = addConditionWaiter();
            int savedState = fullyRelease(node);
            int interruptMode = 0;
            while (!isOnSyncQueue(node)) {
                LockSupport.park(this);
                if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
                    break;
            }
            if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
                interruptMode = REINTERRUPT;
            if (node.nextWaiter != null) // clean up if cancelled
                unlinkCancelledWaiters();
            if (interruptMode != 0)
                reportInterruptAfterWait(interruptMode);
        }

看起来你想让我帮助你优化这段文本,让它更加流畅和易于理解。让我们重新组织和完善一下:

在这段代码解析中,我们可以看到几个关键方法的作用:

首先,addConditionWaiter方法负责在条件队列中添加当前线程的节点。它从条件队列的末尾开始遍历并删除不再需要的节点,然后将当前线程的节点添加进去,并返回该节点的引用。

接着是fullyRelease方法,它会完全释放当前线程持有的锁。如果当前的独占线程被置为null,它会唤醒同步队列的头节点,尽管当前队列可能还没有阻塞节点,这一步只是为了释放锁的控制权。

isOnSyncQueue方法用于检查当前节点是否已经在同步队列中。

checkInterruptWhileWaiting方法则检查当前节点在等待期间是否被中断。如果没有中断,它将当前节点添加到同步队列中去。

acquireQueued方法则是同步队列中头节点重新获取锁并返回的过程。

最后,unlinkCancelledWaiters方法用于清除条件队列中被中断的节点。

观察这些方法的执行流程,我们可以发现一个重要的逻辑:如果当前节点无法成功添加到同步队列,它会首先被添加到条件队列中。随后,当前线程会释放它持有的独占锁,并检查是否已经成功加入同步队列。如果尚未加入,线程将在此处阻塞等待。一旦成功释放了锁,线程会立即将自己添加到同步队列中,并执行接下来的处理步骤。

代码语言:java复制
public E take() throws InterruptedException {
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            while (count == 0)
                notEmpty.await();
            return dequeue();
        } finally {
            lock.unlock();
        }
    }

这里的await操作与前述步骤基本相同,主要区别在于它会释放先前阻塞的线程,并允许它加入同步队列。

总结

Java中的多线程编程为开发者提供了灵活而强大的工具,但也伴随着复杂的同步和并发管理挑战。在面对多线程同时访问共享资源可能引发的数据不一致问题时,合理的同步机制显得尤为关键。本文从Java锁机制、对象的内存布局到AbstractQueuedSynchronizer(AQS)的特性进行了深入探讨和分析。

Java中的锁机制主要分为显示锁和隐式锁两大类。显示锁如ReentrantLock通过程序员显式地控制加锁和解锁操作,而隐式锁如synchronized关键字则由JVM隐式管理,提供更便捷的同步方式。除此之外,Java还提供了volatile关键字来确保变量的可见性,以及java.util.concurrent包下丰富的同步工具。

对象的内存布局对于理解Java锁机制至关重要。对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)构成了Java对象在内存中的存储结构,对象头中的锁状态信息直接影响着多线程的同步操作效率和线程安全性。

在锁的实现中,AbstractQueuedSynchronizer(AQS)作为Java并发编程的核心,提供了阻塞等待队列、公平性选择、可重入性、共享与独占模式以及中断响应等关键特性。通过AQS的灵活应用,开发者可以构建出各种高效且安全的同步器,如锁、闭锁和屏障,从而更好地应对复杂的多线程并发场景。


我是努力的小雨,一名 Java 服务端码农,潜心研究着 AI 技术的奥秘。我热爱技术交流与分享,对开源社区充满热情。同时也是一位掘金优秀作者、腾讯云内容共创官、阿里云专家博主、华为云云享专家。

0 人点赞