Java 中的各种类锁简单了解


共计 4111 个字符,预计需要花费 11 分钟才能阅读完成。

提到并发编程,就不得不了解锁,因为使用锁的类型不一样,结果也就不一样。

1. 公平锁/非公平锁

  • 公平锁:多个线程按照申请锁的顺序来获取锁。
  • 非公平锁:不按照顺序获取锁,有可能后申请锁的线程先获取到锁。非公平锁有可能造成优先级反转或线程饥饿现象。

对于 ReentrantLock 而言,线程在放入等待队列阻塞之前会多次尝试获取锁,如下图所示:

Java 中的各种类锁简单了解

/>

public ReentrantLock() {
    sync = new NonfairSync();
}

public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

ReentrantLock 默认使用的是非公平锁,需要指定有参构造才会使用公平锁。

2. 可重入锁/不可重入锁

  • 可重入锁:可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁。(前提得是同一个对象或者class)
  • 不可重入锁:不可递归调用,递归调用就发生死锁。

例如 synchronized 为可重入锁,所以调用 testA() 方法不会出现死锁的情况:

public synchronized void testA(){
    System.out.println("A");
    testB();
}

public synchronized void testB(){
    System.out.println("B");
}

而不可重入锁容易造成死锁,如下案例:

class UnreentrantLock {
    private AtomicReference<Thread> owner = new AtomicReference<>();

    public void lock() {
        Thread current = Thread.currentThread();

        // 自旋锁:不断尝试获得锁
        for (;;) {
            // 如果锁当前没有被任何线程持有 (owner为null),就将当前线程设置为持有者
            if (owner.compareAndSet(null, current)) {
                return;
            }
        }
    }

    public void unlock() {
        Thread current = Thread.currentThread();

        // 释放锁:确保只有持有锁的线程才能释放锁
        owner.compareAndSet(current, null);
    }
}

class TestSynchronized {

    UnreentrantLock unreentrantLock = new UnreentrantLock();

    public void testA(){
        unreentrantLock.lock();
        System.out.println("A");
        testB();
        unreentrantLock.unlock();
    }

    public void testB(){
        unreentrantLock.lock();
        System.out.println("B");
        unreentrantLock.unlock();
    }
}

可以修改为可重入:

public class ReentrantLock {
    private AtomicReference<Thread> owner = new AtomicReference<>();
    private int state = 0;  // 记录锁的重入次数

    // 获取锁
    public void lock() {
        Thread current = Thread.currentThread();

        // 如果当前线程已经持有锁,则增加锁的重入计数器
        if (current == owner.get()) {
            state++;
            return;
        }

        // 自旋锁,直到当前线程成功获得锁
        for (;;) {
            if (owner.compareAndSet(null, current)) {
                state = 1;  // 成功获取锁时,初始化重入计数
                return;
            }
        }
    }

    // 释放锁
    public void unlock() {
        Thread current = Thread.currentThread();

        // 确保只有持有锁的线程才能解锁
        if (current == owner.get()) {
            if (state > 1) {
                // 如果锁重入次数大于1,说明还可以继续递归调用,不释放锁
                state--;
            } else {
                // 否则,将锁释放,设置状态为0
                owner.compareAndSet(current, null);
                state = 0;
            }
        }
    }
}

3. 独享锁/共享锁

  • 独享锁:该锁每一次只能被一个线程所持有。
  • 共享锁:该锁可被多个线程共有,典型的就是 ReentrantReadWriteLock 里的读锁,它的读锁是可以被共享的,但是它的写锁确每次只能被独占。读锁的共享可保证并发读是非常高效的,但是读写和写写,写读都是互斥的。

独享锁与共享锁也是通过 AQS 来实现的,通过实现不同的方法,来实现独享或者共享。对于synchronized 而言,当然是独享锁。

4. 乐观锁/悲观锁

  • 悲观锁:总是假设最坏的情况,每次访问共享资源都加锁,synchronizedReentrantLock 属于悲观锁。
  • 乐观锁:假设数据不会被修改,只有在更新数据时检查是否有其他线程修改过数据。java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式,使用 CAS 实现的。

5. 互斥锁

  • 互斥锁:在访问共享资源之前对进行加锁操作,在访问完成之后进行解锁操作。加锁后,任何其他试图再次加锁的线程会被阻塞,直到当前进程解锁。

其实,独享锁在许多情况下就是互斥锁的一种表现形式,但独享这一说法强调了锁的独占性质。

6. 读写锁

读写锁既是互斥锁,又是共享锁,读模式是共享,写模式是互斥(排它锁)的。

读写锁有三种状态:读加锁状态、写加锁状态和不加锁状态。

只有一个线程可以占有写状态的锁,但可以有多个线程同时占有读状态锁,这也是它可以实现高并发的原因。当其处于写状态锁下,任何想要尝试获得锁的线程都会被阻塞,直到写状态锁被释放;如果是处于读状态锁下,允许其它线程获得它的读状态锁,但是不允许获得它的写状态锁,直到所有线程的读状态锁被释放;为了避免想要尝试写操作的线程一直得不到写状态锁,当读写锁感知到有线程想要获得写状态锁时,便会阻塞其后所有想要获得读状态锁的线程。所以读写锁非常适合资源的读操作远多于写操作的情况。

7. 分段锁

分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。

容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

比如:在ConcurrentHashMap中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第(N mod 16)个锁来保护。假设使用合理的散列算法使关键字能够均匀的分部,那么这大约能使对锁的请求减少到越来的1/16。也正是这项技术使得ConcurrentHashMap支持多达16个并发的写入线程。

8. 偏向锁/轻量级锁/重量级锁

  • 偏向锁:指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
  • 轻量级锁:当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
  • 重量级锁:当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。

这四种状态都不是Java语言中的锁,而是 Jvm 为了提高锁的获取与释放效率而做的优化(使用synchronized时)。

锁的状态:

1.无锁状态

2.偏向锁状态

3.轻量级锁状态

4.重量级锁状态

锁的状态是通过对象监视器在对象头中的字段来表明的。四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级。

9. 自旋锁

  • 自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。

优点:

  1. 自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
  2. 非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。(线程被阻塞后便进入内核调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)

缺点:

  1. 如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。
  2. 自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在线程饥饿问题。

其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名。

自旋锁通过 CAS 的方式保证线程安全,CAS 是英文单词 Compare and Swap(比较并交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。

参考文章:常见的Java锁总结:公平锁,独享锁,互斥锁,乐观锁,分段锁,偏向锁,自旋锁等等

Tips:清朝云网络工作室

阅读剩余
THE END