跳到正文
hello world

12. ReentrantLock:手动加锁和释放锁

发布于阅读量 1

前面已经用 synchronized 解决了 count++ 的线程安全问题。

synchronized 的好处是简单,写在方法上或者代码块上,JVM 会自动帮我加锁和释放锁。大多数基础场景,用它就够了。

不过 Java 里还有一个比较常用的锁:ReentrantLock

它和 synchronized 的作用有点像,都是为了让某段代码同一时间只能被一个线程执行。但它的写法更“手动”一点:我要自己加锁,也要自己释放锁。

这一节先不展开太多高级用法,先把最基本的使用方式跑通。


先看 synchronized 的写法

上一节的代码大概是这样:

private static synchronized void increment() {
    count++;
}

或者:

synchronized (LOCK) {
    count++;
}

这种写法里,加锁和释放锁都是 JVM 帮我处理的。

线程进入同步方法时自动加锁,方法执行完以后自动释放锁。

如果方法里抛异常,锁也会自动释放。

所以 synchronized 用起来比较省心。


ReentrantLock 的基本写法

ReentrantLock,代码会更明确。

新建类:

com.succos.thread.ReentrantLockDemo

代码如下:

package com.succos.thread;

import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockDemo {

    private static int count = 0;

    private static final ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                increment();
            }
        }, "thread-1");

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                increment();
            }
        }, "thread-2");

        long start = System.currentTimeMillis();

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        long end = System.currentTimeMillis();

        System.out.println("最终结果:" + count);
        System.out.println("耗时:" + (end - start) + " ms");
    }

    private static void increment() {

        lock.lock();

        try {
            count++;
        } finally {
            lock.unlock();
        }
    }
}

这段代码运行后,结果也会稳定输出:

最终结果:200000

核心就是这几行:

lock.lock();

try {
    count++;
} finally {
    lock.unlock();
}

lock() 和 unlock()

lock.lock() 的意思是加锁。

如果当前没有其他线程持有这把锁,那么当前线程就能拿到锁,然后继续往下执行。

如果锁已经被其他线程拿走了,那当前线程就会等待。

lock.unlock() 的意思是释放锁。

只有释放以后,其他等待的线程才有机会继续执行。

所以执行过程大概是这样:

thread-1 调用 lock.lock()
thread-1 拿到锁
thread-1 执行 count++
thread-1 执行 unlock() 释放锁

thread-2 原来在等待
thread-1 释放锁后
thread-2 拿到锁
thread-2 执行 count++
thread-2 释放锁

这和 synchronized 的互斥效果是一样的。


为什么 unlock() 一定要放在 finally 里?

这个地方很重要。

我不能这样写:

private static void increment() {

    lock.lock();

    count++;

    lock.unlock();
}

这段代码看起来也能跑,但它有一个风险:如果 count++ 这块逻辑中间抛异常,unlock() 就不会执行。

一旦锁没有释放,其他线程就会一直等下去。

在真实业务里,这种问题很麻烦。程序不一定直接报错,而是某些请求、某些任务一直卡住。

所以标准写法应该是:

lock.lock();

try {
    // 需要保护的代码
} finally {
    lock.unlock();
}

这样不管 try 里面是正常执行完,还是抛异常,finally 都会执行。

锁就能被释放。

我现在看到 ReentrantLock,基本会形成一个固定反应:

lock() 后面必须配 try-finally;
unlock() 必须放 finally。

这个习惯比记 API 更重要。


ReentrantLock 保护的也是临界区

synchronized 一样,ReentrantLock 保护的也不是变量本身,而是代码块。

在这个例子里,需要保护的是:

count++;

所以我把它放到锁里面:

lock.lock();

try {
    count++;
} finally {
    lock.unlock();
}

如果我在别的地方绕过这个方法,直接写:

count++;

那还是不安全。

所以加锁这件事,本质上要求所有线程都遵守同一个规则:

凡是要修改 count,都必须先拿同一把锁。

如果有些代码拿锁,有些代码不拿锁,那线程安全还是无法保证。


锁对象也必须是同一个

synchronized 一样,ReentrantLock 也要求多个线程竞争的是同一把锁。

上面的写法是:

private static final ReentrantLock lock = new ReentrantLock();

这是一个静态变量,两个线程调用 increment() 时,用的都是同一个 lock 对象。

所以它们会互斥。

如果我写成这样,就有问题:

private static void increment() {

    ReentrantLock lock = new ReentrantLock();

    lock.lock();

    try {
        count++;
    } finally {
        lock.unlock();
    }
}

这段代码每次调用 increment() 都会创建一把新锁。

结果就是:

thread-1 用自己的锁;
thread-2 用自己的锁;
两个线程没有抢同一把锁。

这样当然锁不住 count++

所以不管是 synchronized 还是 ReentrantLock,都要看一个核心问题:

多个线程是不是在竞争同一把锁?

和 synchronized 的区别

从最基础的互斥效果看,synchronizedReentrantLock 都能解决 count++ 的问题。

但写法上差别很明显。

synchronized 是自动释放锁:

private static synchronized void increment() {
    count++;
}

ReentrantLock 是手动释放锁:

lock.lock();

try {
    count++;
} finally {
    lock.unlock();
}

所以 ReentrantLock 写起来更麻烦。

但它也更灵活。

比如它可以尝试获取锁:

lock.tryLock();

可以设置公平锁:

new ReentrantLock(true);

也可以配合条件队列做更复杂的线程协作。

这些后面可以再慢慢看。

这一节我先只记住基本用法。


用 tryLock() 简单看一下灵活性

ReentrantLocksynchronized 灵活的一个地方是:它可以尝试获取锁。

比如:

boolean success = lock.tryLock();

意思是:

尝试拿锁;
拿到了就返回 true;
没拿到就返回 false,不会一直等。

可以写一个简单例子:

package com.succos.thread;

import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockTryLockDemo {

    private static final ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) {

        Runnable task = () -> {

            boolean success = lock.tryLock();

            if (!success) {
                System.out.println(Thread.currentThread().getName() + " 没拿到锁,先不处理");
                return;
            }

            try {
                System.out.println(Thread.currentThread().getName() + " 拿到锁,开始处理");

                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                }

                System.out.println(Thread.currentThread().getName() + " 处理完成");

            } finally {
                lock.unlock();
            }
        };

        new Thread(task, "thread-1").start();
        new Thread(task, "thread-2").start();
    }
}

这个例子里,可能会看到:

thread-1 拿到锁,开始处理
thread-2 没拿到锁,先不处理
thread-1 处理完成

这和 synchronized 不一样。

synchronized 拿不到锁时,只能等。

tryLock() 可以让我决定:拿不到锁要不要放弃、重试,或者返回一个提示。

当然,这个例子只是为了感受一下区别,真实项目里要不要用 tryLock(),还要看业务场景。


公平锁和非公平锁先简单知道

ReentrantLock 默认是非公平锁:

new ReentrantLock();

也可以创建公平锁:

new ReentrantLock(true);

公平锁大概意思是:

等待时间更久的线程,优先拿到锁。

非公平锁则不保证严格排队。

从直觉上看,公平锁好像更合理。

但公平锁通常性能会差一点,因为它要维护更严格的排队顺序。

所以一般情况下,不需要刻意用公平锁。

我现在先简单记:

默认非公平锁;
需要严格排队时才考虑公平锁。

这部分不展开太多,不然容易偏离主线。


回到 PDF 项目里怎么用

在 PDF 水印项目里,如果每个 PDF 都是独立文件,正常情况下不应该给整个 PDF 处理过程加同一把锁。

比如我不会这样写:

lock.lock();

try {
    service.addWaterMakerOfPDF(fileItemContext);
} finally {
    lock.unlock();
}

如果所有线程都用这一把锁,效果就变成:

同一时间只能处理一个 PDF。

那多线程就没意义了。

但是如果有共享统计数据,比如成功数量、失败数量,倒是可以用锁保护。

比如:

private static int successCount = 0;

private static final ReentrantLock lock = new ReentrantLock();

private static void addSuccessCount() {

    lock.lock();

    try {
        successCount++;
    } finally {
        lock.unlock();
    }
}

不过这种计数场景,用 AtomicInteger 也很合适。

所以我在 PDF 项目里会这样判断:

处理不同 PDF 文件:一般不用锁;
修改共享统计变量:需要考虑锁或 AtomicInteger;
写同一个文件:要避免,最好从设计上保证输出路径不同;
访问共享资源:根据资源性质决定是否加锁。

锁不是为了“看起来安全”,而是为了保护真正的共享修改。


synchronized 和 ReentrantLock 怎么选

如果只是普通的互斥,synchronized 更简单。

比如:

private synchronized void updateStatus() {
    // 修改状态
}

代码短,也不容易忘记释放锁。

如果需要更灵活的能力,比如:

尝试拿锁;
拿不到锁就放弃;
需要公平锁;
需要可中断等待;
需要多个条件队列。

那就可以考虑 ReentrantLock

我现在的选择习惯是:

简单场景先用 synchronized;
需要更强控制能力时,再用 ReentrantLock。

不要为了显得高级就到处用 ReentrantLock

因为手动加锁就意味着自己要承担释放锁的责任。


这一节小结

这一节我主要记住几点:

1. ReentrantLock 也可以解决线程安全问题;
2. lock.lock() 用来加锁,lock.unlock() 用来释放锁;
3. unlock() 一定要放在 finally 里;
4. 多个线程必须竞争同一把 ReentrantLock,锁才有效;
5. ReentrantLock 比 synchronized 更灵活,但写法也更容易出错;
6. PDF 独立文件处理一般不要锁整个流程,只在共享数据上加锁。

用一句话总结:

ReentrantLock 就是把加锁和释放锁这件事交给我自己控制。

下一节继续看“可重入锁”。

因为 ReentrantLock 这个名字里的 Reentrant,指的就是可重入。这个点理解了,后面看锁的执行过程会更清楚。

12. ReentrantLock:手动加锁和释放锁 - Java并发之从Thread到CompletableFuture - 上下文网