Java 8 并发教程:同步和锁

原文:Java 8 Concurrency Tutorial: Synchronization and Locks
译者:飞龙
协议:CC BY-NC-SA 4.0java

欢迎阅读个人Java8并发教程的第二部分。这份指南将会以简单易懂的代码示例来教给你如何在Java8中进行并发编程。这是一系列教程中的第二部分。在接下来的15分钟,你将会学会如何经过同步关键字,锁和信号量来同步访问共享可变变量。git

这篇文章中展现的中心概念也适用于Java的旧版本,然而代码示例适用于Java 8,并严重依赖于lambda表达式和新的并发特性。若是你还不熟悉lambda,我推荐你先阅读个人Java 8 教程多线程

出于简单的因素,这个教程的代码示例使用了定义在这里的两个辅助函数sleep(seconds)stop(executor)并发

同步

上一章中,咱们学到了如何经过执行器服务同时执行代码。当咱们编写这种多线程代码时,咱们须要特别注意共享可变变量的并发访问。假设咱们打算增长某个可被多个线程同时访问的整数。函数

咱们定义了count字段,带有increment()方法来使count加一:post

int count = 0;

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

当多个线程并发调用这个方法时,咱们就会遇到大麻烦:性能

ExecutorService executor = Executors.newFixedThreadPool(2);

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

stop(executor);

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

咱们没有看到count为10000的结果,上面代码的实际结果在每次执行时都不一样。缘由是咱们在不一样的线程上共享可变变量,而且变量访问没有同步机制,这会产生竞争条件

增长一个数值须要三个步骤:(1)读取当前值,(2)使这个值加一,(3)将新的值写到变量。若是两个线程同时执行,就有可能出现两个线程同时执行步骤1,因而会读到相同的当前值。这会致使无效的写入,因此实际的结果会偏小。上面的例子中,对count的非同步并发访问丢失了35次增长操做,可是你在本身执行代码时会看到不一样的结果。

幸运的是,Java自从好久以前就经过synchronized关键字支持线程同步。咱们可使用synchronized来修复上面在增长count时的竞争条件。

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

在咱们并发调用incrementSync()时,咱们获得了count为10000的预期结果。没有再出现任何竞争条件,而且结果在每次代码执行中都很稳定:

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;
    }
}

Java在内部使用所谓的“监视器”(monitor),也称为监视器锁(monitor lock)或内在锁( intrinsic lock)来管理同步。监视器绑定在对象上,例如,当使用同步方法时,每一个方法都共享相应对象的相同监视器。

全部隐式的监视器都实现了重入(reentrant)特性。重入的意思是锁绑定在当前线程上。线程能够安全地屡次获取相同的锁,而不会产生死锁(例如,同步方法调用相同对象的另外一个同步方法)。

并发API支持多种显式的锁,它们由Lock接口规定,用于代替synchronized的隐式锁。锁对细粒度的控制支持多种方法,所以它们比隐式的监视器具备更大的开销。

锁的多个实如今标准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代码块中来确保异常状况下的解锁很是重要。这个方法是线程安全的,就像同步副本那样。若是另外一个线程已经拿到锁了,再次调用lock()会阻塞当前线程,直到锁被释放。在任意给定的时间内,只有一个线程能够拿到锁。

锁对细粒度的控制支持多种方法,就像下面的例子那样:

executor.submit(() -> {
    lock.lock();
    try {
        sleep(1);
    } 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

tryLock()方法是lock()方法的替代,它尝试拿锁而不阻塞当前线程。在访问任何共享可变变量以前,必须使用布尔值结果来检查锁是否已经被获取。

ReadWriteLock

ReadWriteLock接口规定了锁的另外一种类型,包含用于读写访问的一对锁。读写锁的理念是,只要没有任何线程写入变量,并发读取可变变量一般是安全的。因此读锁能够同时被多个线程持有,只要没有线程持有写锁。这样能够提高性能和吞吐量,由于读取比写入更加频繁。

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

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

上面的例子在暂停一秒以后,首先获取写锁来向映射添加新的值。在这个任务完成以前,两个其它的任务被启动,尝试读取映射中的元素,并暂停一秒:

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

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

stop(executor);

当你执行这一代码示例时,你会注意到两个读任务须要等待写任务完成。在释放了写锁以后,两个读任务会同时执行,并同时打印结果。它们不须要相互等待完成,由于读锁能够安全同步获取,只要没有其它线程获取了写锁。

StampedLock

Java 8 自带了一种新的锁,叫作StampedLock,它一样支持读写锁,就像上面的例子那样。与ReadWriteLock不一样的是,StampedLock的锁方法会返回表示为long的标记。你可使用这些标记来释放锁,或者检查锁是否有效。此外,StampedLock支持另外一种叫作乐观锁(optimistic locking)的模式。

让咱们使用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(1);
        map.put("foo", "bar");
    } finally {
        lock.unlockWrite(stamp);
    }
});

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

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

stop(executor);

经过readLock()writeLock()来获取读锁或写锁会返回一个标记,它能够在稍后用于在finally块中解锁。要记住StampedLock并无实现重入特性。每次调用加锁都会返回一个新的标记,而且在没有可用的锁时阻塞,即便相同线程已经拿锁了。因此你须要额外注意不要出现死锁。

就像前面的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(1);
        System.out.println("Optimistic Lock Valid: " + lock.validate(stamp));
        sleep(2);
        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(2);
    } finally {
        lock.unlock(stamp);
        System.out.println("Write done");
    }
});

stop(executor);

乐观的读锁经过调用tryOptimisticRead()获取,它老是返回一个标记而不阻塞当前线程,不管锁是否真正可用。若是已经有写锁被拿到,返回的标记等于0。你须要老是经过lock.validate(stamp)检查标记是否有效。

执行上面的代码会产生如下输出:

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);

第一个任务获取读锁,并向控制台打印count字段的当前值。可是若是当前值是零,咱们但愿将其赋值为23。咱们首先须要将读锁转换为写锁,来避免打破其它线程潜在的并发访问。tryConvertToWriteLock()的调用不会阻塞,可是可能会返回为零的标记,表示当前没有可用的写锁。这种状况下,咱们调用writeLock()来阻塞当前线程,直到有可用的写锁。

信号量

除了锁以外,并发API也支持计数的信号量。不过锁一般用于变量或资源的互斥访问,信号量能够维护总体的准入许可。这在一些不一样场景下,例如你须要限制你程序某个部分的并发访问总数时很是实用。

下面是一个例子,演示了如何限制对经过sleep(5)模拟的长时间运行任务的访问:

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(5);
        } 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

信号量限制对经过sleep(5)模拟的长时间运行任务的访问,最大5个线程。每一个随后的tryAcquire()调用在通过最大为一秒的等待超时以后,会向控制台打印不能获取信号量的结果。

这就是个人系列并发教程的第二部分。之后会放出更多的部分,因此敬请等待吧。像之前同样,你能够在Github上找到这篇文档的全部示例代码,因此请随意fork这个仓库,并本身尝试它。

我但愿你能喜欢这篇文章。若是你还有任何问题,在下面的评论中向我反馈。你也能够在Twitter上关注我来获取更多开发相关的信息。

相关文章
相关标签/搜索