资讯专栏INFORMATION COLUMN

Java 8 并发:同步和锁

andycall / 838人阅读

摘要:可重入意味着锁被绑定到当前线程,线程可以安全地多次获取相同的锁,而不会发生死锁例如同步方法在同一对象上调用另一个同步方法。写入锁释放后,两个任务并行执行,它们不必等待对方是否完成,因为只要没有线程持有写入锁,它们就可以同时持有读取锁。

原文地址: 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 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 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秒再获取。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/70892.html

相关文章

  • Java 8 并发教程:同步和锁

    摘要:在接下来的分钟,你将会学会如何通过同步关键字,锁和信号量来同步访问共享可变变量。所以在使用乐观锁时,你需要每次在访问任何共享可变变量之后都要检查锁,来确保读锁仍然有效。 原文:Java 8 Concurrency Tutorial: Synchronization and Locks译者:飞龙 协议:CC BY-NC-SA 4.0 欢迎阅读我的Java8并发教程的第二部分。这份指南将...

    wyk1184 评论0 收藏0
  • Java 8 并发教程:原子变量和 ConcurrentMa

    摘要:并发教程原子变量和原文译者飞龙协议欢迎阅读我的多线程编程系列教程的第三部分。如果你能够在多线程中同时且安全地执行某个操作,而不需要关键字或上一章中的锁,那么这个操作就是原子的。当多线程的更新比读取更频繁时,这个类通常比原子数值类性能更好。 Java 8 并发教程:原子变量和 ConcurrentMap 原文:Java 8 Concurrency Tutorial: Synchroni...

    bitkylin 评论0 收藏0
  • Java 8 并发教程:线程和执行器

    摘要:在这个示例中我们使用了一个单线程线程池的。在延迟消逝后,任务将会并发执行。这是并发系列教程的第一部分。第一部分线程和执行器第二部分同步和锁第三部分原子操作和 Java 8 并发教程:线程和执行器 原文:Java 8 Concurrency Tutorial: Threads and Executors 译者:BlankKelly 来源:Java8并发教程:Threads和Execut...

    jsdt 评论0 收藏0
  • 不可不说的Java“锁”事

    摘要:本文旨在对锁相关源码本文中的源码来自使用场景进行举例,为读者介绍主流锁的知识点,以及不同的锁的适用场景。中,关键字和的实现类都是悲观锁。自适应意味着自旋的时间次数不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。 前言 Java提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率。本文旨在对锁相关源码(本文中的源码来自JDK 8)、使用场景...

    galaxy_robot 评论0 收藏0
  • 手撕面试官系列(七):面试必备之常问并发编程高级面试专题

    摘要:如何在线程池中提交线程内存模型相关问题什么是的内存模型,中各个线程是怎么彼此看到对方的变量的请谈谈有什么特点,为什么它能保证变量对所有线程的可见性既然能够保证线程间的变量可见性,是不是就意味着基于变量的运算就是并发安全的请对比下对比的异同。 并发编程高级面试面试题 showImg(https://upload-images.jianshu.io/upload_images/133416...

    Charles 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<