Java 8 并发:同步和锁

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

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

Synchronized

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

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

int count = 0;

void increment() {
    count = count + 1;
}
复制代码

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

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个增量因为并发非同步访问计数而丢失,可是当你本身执行代码时可能会看到不一样的结果。bash

幸运的是,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 关键值也能够用在一个语句块中oracle

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秒再获取。

相关文章
相关标签/搜索