Java 8 并发:同步和锁

1,962 阅读8分钟

原文地址: Java 8 Concurrency Tutorial: Synchronization and Locks

为了简单起见,本教程的示例代码使用了在这里定义的两个辅助方法,sleep(seconds)stop(executor)

Synchronized

当我们编写多线程代码访问可共享的变量时需要特别注意,下面是一个多线程去改变一个整数的例子。

定义一个变量 count,定义一个方法 increment() 使 count 增加 1.

int count = 0;

void increment() {
    count = count + 1;
}

当多个线程同时调用 increment() 时就会出现问题:

ExecutorService executor = Executors.newFixedThreadPool(2);

IntStream.range(0, 10000)
    .forEach(i -> executor.submit(this::increment));

stop(executor);

System.out.println(count);  // 9965

上面的代码执行结果并不是10000,原因是我们在不同的线程上共享一个变量,而没有给这个变量的访问设置竞争条件。

为了增加数字,必须执行三个步骤:(i) 读取当前值;(ii) 将该值增加1;(iii) 将新值写入变量;如果两个线程并行执行这些步骤,则两个线程可能同时执行步骤1,从而读取相同的当前值。 这导致写入丢失,所以实际结果较低。 在上面的示例中,35个增量由于并发非同步访问计数而丢失,但是当你自己执行代码时可能会看到不同的结果。

幸运的是,Java 早期通过 synchronized 关键字支持线程同步。增加计数时,我们可以利用同步来解决上述竞争条件:

synchronized void incrementSync() {
    count = count + 1;
}

当我们使用 incrementSync() 方法时,我们得到了希望的结果,而且每次执行的结果都是这样的。

ExecutorService executor = Executors.newFixedThreadPool(2);

IntStream.range(0, 10000)
    .forEach(i -> executor.submit(this::incrementSync));

stop(executor);

System.out.println(count);  // 10000

synchronized 关键值也可以用在一个语句块中

void incrementSync() {
    synchronized (this) {
        count = count + 1;
    }
}

JVM 的内部使用了一个监视器,也可以称为监视器锁和内部锁来管理同步。这个监视器被绑定到一个对象上,当使用同步方法时,每个方法共享相应对象的监视器。

所有隐式监视器都实现了可重入特性。 可重入意味着锁被绑定到当前线程,线程可以安全地多次获取相同的锁,而不会发生死锁(例如同步方法在同一对象上调用另一个同步方法)。

Locks

除了使用关键字 synchronized 支持的隐式锁(对象的内置锁)外,Concurrency API 支持由 Lock 接口指定的各种显示锁。显示锁能控制更细的粒度,因此也有更好的性能,在逻辑上也比较清晰。

标准 JDK中提供了多种显示锁的实现,将在下面的章节中进行介绍。

ReentrantLock

ReentrantLock 类是一个互斥锁,它和 synchronized 关键字访问的隐式锁具有相同的功能,但它具有扩展功能。它也实现了可重入的功能。

下面来看看如何使用 ReentrantLock

ReentrantLock lock = new ReentrantLock();
int count = 0;

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

锁通过 lock() 获取,通过 unlock() 释放,将代码封装到 try/finally 块中是非常重要的,以确保在出现异常的时候也能释放锁。这个方法和使用关键字 synchronized 修饰的方法是一样是线程安全的。如果一个线程已经获得了锁,后续线程调用 lock() 会暂停线程,直到锁被释放,永远只有一个线程能获取锁。

lock 支持更细粒度的去控制一个方法的同步,如下面的代码:

ExecutorService executor = Executors.newFixedThreadPool(2);
ReentrantLock lock = new ReentrantLock();

executor.submit(() -> {
    lock.lock();
    try {
        sleep(1000);
    } finally {
        lock.unlock();
    }
});

executor.submit(() -> {
    System.out.println("Locked: " + lock.isLocked());
    System.out.println("Held by me: " + lock.isHeldByCurrentThread());
    boolean locked = lock.tryLock();
    System.out.println("Lock acquired: " + locked);
});

stop(executor);

当第一个任务获取锁时,第二个任务获取锁的状态信息:

Locked: true
Held by me: false
Lock acquired: false

作为 lock() 方法的替代方法 tryLock() 尝试去获取锁而不暂停当前线程,必须使用 bool 结果去判断是否真的获取到了锁。

ReadWriteLock

ReadWriteLock 指定了另一种类型的锁,即读写锁。读写锁实现的逻辑是,当没有线程在写这个变量时,其他的线程可以读取这个变量,所以就是当没有线程持有写锁时,读锁就可以被所有的线程持有。如果读取比写更频繁,这将增加系统的性能和吞吐量。

ExecutorService executor = Executors.newFixedThreadPool(2);
Map<String, String> map = new HashMap<>();
ReadWriteLock lock = new ReentrantReadWriteLock();

executor.submit(() -> {
    lock.writeLock().lock();
    try {
        sleep(1000);
        map.put("foo", "bar");
    } finally {
        lock.writeLock().unlock();
    }
});

上面的例子首先获取一个写入锁,在 sleep 1秒后在 map 中写入值,在这个任务完成之前,还有两个任务正在提交,试图从 map 读取值:

Runnable readTask = () -> {
    lock.readLock().lock();
    try {
        System.out.println(map.get("foo"));
        sleep(1000);
    } finally {
        lock.readLock().unlock();
    }
};

executor.submit(readTask);
executor.submit(readTask);

stop(executor);

当执行上面的代码时,你会注意到两人读取的任务必须等待直到写入完成(当在读取的时候,写是不能获取锁的)。写入锁释放后,两个任务并行执行,它们不必等待对方是否完成,因为只要没有线程持有写入锁,它们就可以同时持有读取锁。

StampedLock

Java 8 提供了一种新类型的锁 StampedLock,像上面的例子一样它也支持读写锁,与 ReadWriteLock 不同的是,StampedLock 的锁定方法返回一个 long 值,可以利用这个值检查是否释放锁和锁仍然有效。另外 StampedLock 支持另外一种称为乐观锁的模式。

下面使用 StampedLock 来替换 ReadWriteLock

ExecutorService executor = Executors.newFixedThreadPool(2);
Map<String, String> map = new HashMap<>();
StampedLock lock = new StampedLock();

executor.submit(() -> {
    long stamp = lock.writeLock();
    try {
        sleep(1000);
        map.put("foo", "bar");
    } finally {
        lock.unlockWrite(stamp);
    }
});

Runnable readTask = () -> {
    long stamp = lock.readLock();
    try {
        System.out.println(map.get("foo"));
        sleep(1000);
    } finally {
        lock.unlockRead(stamp);
    }
};

executor.submit(readTask);
executor.submit(readTask);

stop(executor);

通过 readLock()writeLock() 方法来获取读写锁会返回一个稍后用于在 finally 块中释放锁的值。注意,这里的锁不是可重入的。每次锁定都会返回一个新的值,并在没有锁的情况下阻塞,在使用的时候要注意不要死锁。

就像前面 ReadWriteLock 中的示例一样,两个读取任务必须等待写入任务释放锁。然后同时并行执行打印结果到控制台。

下面的例子演示了乐观锁

ExecutorService executor = Executors.newFixedThreadPool(2);
StampedLock lock = new StampedLock();

executor.submit(() -> {
    long stamp = lock.tryOptimisticRead();
    try {
        System.out.println("Optimistic Lock Valid: " + lock.validate(stamp));
        sleep(1000);
        System.out.println("Optimistic Lock Valid: " + lock.validate(stamp));
        sleep(2000);
        System.out.println("Optimistic Lock Valid: " + lock.validate(stamp));
    } finally {
        lock.unlock(stamp);
    }
});

executor.submit(() -> {
    long stamp = lock.writeLock();
    try {
        System.out.println("Write Lock acquired");
        sleep(2000);
    } finally {
        lock.unlock(stamp);
        System.out.println("Write done");
    }
});

stop(executor);

通过调用 tryOptimisticRead() 来获取乐观读写锁tryOptimisticRead()总是返回一个值,而不会阻塞当前线程,也不关锁是否可用。如果有一个写锁激活则返回0。可以通过 lock.validate(stamp) 来检查返回的标记(long 值)是否有效。

执行上面的代码输出:

Optimistic Lock Valid: true
Write Lock acquired
Optimistic Lock Valid: false
Write done
Optimistic Lock Valid: false

乐观锁在获得锁后立即生效。与普通读锁相反,乐观锁不会阻止其他线程立即获得写锁。在第一个线程休眠一秒之后,第二个线程获得一个写锁,而不用等待乐观读锁解除。乐观的读锁不再有效,即使写入锁定被释放,乐观的读取锁仍然无效。

因此,在使用乐观锁时,必须在每次访问任何共享的变量后验证锁,以确保读取仍然有效。

有时将读锁转换为写锁并不需要再次解锁和锁定是有用的。StampedLock 为此提供了tryConvertToWriteLock() 方法,如下面的示例所示:

ExecutorService executor = Executors.newFixedThreadPool(2);
StampedLock lock = new StampedLock();

executor.submit(() -> {
    long stamp = lock.readLock();
    try {
        if (count == 0) {
            stamp = lock.tryConvertToWriteLock(stamp);
            if (stamp == 0L) {
                System.out.println("Could not convert to write lock");
                stamp = lock.writeLock();
            }
            count = 23;
        }
        System.out.println(count);
    } finally {
        lock.unlock(stamp);
    }
});

stop(executor);

该任务首先获得一个读锁,并将当前的变量计数值打印到控制台。 但是,如果当前值为 0,我们要分配一个新的值23。我们首先必须将读锁转换为写锁,以不打破其他线程的潜在并发访问。 调用 tryConvertToWriteLock() 不会阻塞,但可能会返回 0,指示当前没有写锁定可用。 在这种情况下,我们调用writeLock()来阻塞当前线程,直到写锁可用。

Semaphores

除了锁之外,并发API还支持计数信号量。 锁通常授予对变量或资源的独占访问权,而信号量则能够维护整套许可证。 在不同的情况下,必须限制对应用程序某些部分的并发访问量。

下面是一个如何限制对长时间任务的访问的例子:

ExecutorService executor = Executors.newFixedThreadPool(10);

Semaphore semaphore = new Semaphore(5);

Runnable longRunningTask = () -> {
    boolean permit = false;
    try {
        permit = semaphore.tryAcquire(1, TimeUnit.SECONDS);
        if (permit) {
            System.out.println("Semaphore acquired");
            sleep(5000);
        } else {
            System.out.println("Could not acquire semaphore");
        }
    } catch (InterruptedException e) {
        throw new IllegalStateException(e);
    } finally {
        if (permit) {
            semaphore.release();
        }
    }
};

IntStream.range(0, 10)
    .forEach(i -> executor.submit(longRunningTask));

stop(executor);

执行程序可以同时运行10个任务,但是我们使用5信号量,因此限制并发访问为5个。使用try/finally块,即使在异常的情况下正确释放信号量也是非常重要的。

运行上面的代码输出:

Semaphore acquired
Semaphore acquired
Semaphore acquired
Semaphore acquired
Semaphore acquired
Could not acquire semaphore
Could not acquire semaphore
Could not acquire semaphore
Could not acquire semaphore
Could not acquire semaphore

当有 5 个任务获取型号量后,随后的任务便不能获取信号量了。但是如果前面 5 的任务执行完成,finally 块释放了型号量,随后的线程就可以获取星号量了,总数不会超过5个。这里调用 tryAcquire() 获取型号量设置了超时时间1秒,意味着当线程获取信号量失败后可以阻塞等待1秒再获取。