paint-brush
重新学习 Java 线程原语的指南经过@shai.almog
761 讀數
761 讀數

重新学习 Java 线程原语的指南

经过 Shai Almog8m2023/04/11
Read on Terminal Reader

太長; 讀書

同步是革命性的,并且仍然有很大的用途。但现在是时候转向更新的线程原语,并且有可能重新考虑我们的核心逻辑。
featured image - 重新学习 Java 线程原语的指南
Shai Almog HackerNoon profile picture

从第一个测试版开始,我就用 Java 编写代码;即使在那时,线程也是我最喜欢的功能列表的首位。 Java 是第一个在语言本身中引入线程支持的语言。这在当时是一个有争议的决定。


在过去的十年里,每一种语言都竞相包含 async/await,甚至 Java 也有一些第三方支持……但 Java 不是曲折而是曲折地引入了更优越的虚拟线程(Loom 项目) 。这篇文章不是关于那个的。


我认为它很棒,证明了 Java 的核心力量。不仅作为一种语言,而且作为一种文化。一种深思熟虑改变而不是匆忙赶上时尚潮流的文化。


在这篇文章中,我想重新审视在 Java 中执行线程的旧方法。我已经习惯了synchronizedwaitnotify等。但是它们已经很久没有成为 Java 线程处理的最佳方法了。


我是问题的一部分;我仍然习惯于这些方法,并且发现很难习惯自 Java 5 以来就存在的一些 API。这是一种习惯的力量。吨


我在这里的视频中讨论了很多用于处理线程的很棒的 API,但我想谈谈基本但重要的锁。

同步与 ReentrantLock

我不愿意离开 synchronized 的原因是替代方案也好不到哪儿去。今天离开它的主要动机是,此时,synchronized 可以触发 Loom 中的线程固定,这并不理想。


JDK 21 可能会解决这个问题(当 Loom 正式发布时),但保留它仍然有意义。


synchronized 的直接替代品是 ReentrantLock。不幸的是,ReentrantLock 与 synchronized 相比几乎没有优势,因此迁移的好处充其量是可疑的。


事实上,它有一个主要缺点;为了理解这一点,让我们看一个例子。这就是我们使用 synchronized 的方式:

 synchronized(LOCK) { // safe code } LOCK.lock(); try { // safe code } finally { LOCK.unlock(); }


ReentrantLock的第一个缺点是冗长。我们需要 try 块,因为如果块内发生异常,锁将保持不变。同步为我们无缝处理。


有些人使用AutoClosable包装锁的技巧大致如下所示:

 public class ClosableLock implements AutoCloseable { private final ReentrantLock lock; public ClosableLock() { this.lock = new ReentrantLock(); } public ClosableLock(boolean fair) { this.lock = new ReentrantLock(fair); } @Override public void close() throws Exception { lock.unlock(); } public ClosableLock lock() { lock.lock(); return this; } public ClosableLock lockInterruptibly() throws InterruptedException { lock.lock(); return this; } public void unlock() { lock.unlock(); } }


请注意,我没有实现理想的 Lock 接口。那是因为 lock 方法返回自动关闭实现而不是void


一旦我们这样做了,我们就可以编写更简洁的代码,例如:

 try(LOCK.lock()) { // safe code }


我喜欢减少冗长,但这是一个有问题的概念,因为 try-with-resource 是为清理目的而设计的,我们会重用锁。它正在调用关闭,但我们将在同一个对象上再次调用该方法。


我认为使用资源语法扩展 try 以支持锁定接口可能会很好。但在那之前,这可能不是一个值得的技巧。

ReentrantLock 的优点

使用ReentrantLock的最大原因是 Loom 支持。其他优点很好,但没有一个是“杀手级功能”。


我们可以在方法之间而不是在一个连续的块中使用它。这可能是个坏主意,因为您想要最小化锁定区域,并且失败可能是个问题。我不认为该功能是优势。


它有公平的选择。这意味着它将服务于第一个停在锁上的线程。我试图想出一个现实的非复杂用例,这很重要,但我正在画空白。


如果你正在编写一个复杂的调度程序,其中有许多线程不断地在资源上排队,你可能会创建一个线程“饿死”的情况,因为其他线程不断进入。但并发包中的其他选项可能会更好地满足这种情况.


也许我在这里遗漏了一些东西......


lockInterruptibly()让我们在线程等待锁时中断它。这是一个有趣的功能,但同样,很难找到它能真正发挥作用的情况。


如果您编写的代码必须对中断非常敏感,则需要使用lockInterruptibly() API 来获得该功能。但是您平均在lock()方法中花费多长时间?


在某些边缘情况下,这可能很重要,但我们大多数人都不会遇到这种情况,即使在执行高级多线程代码时也是如此。

读写重入锁

更好的方法是ReadWriteReentrantLock 。大多数资源遵循读频繁,写少的原则。由于读取变量是线程安全的,因此除非我们正在写入变量,否则不需要锁。


这意味着我们可以将读取优化到极致,同时让写入操作稍微慢一些。


假设这是您的用例,您可以创建更快的代码。使用读写锁时,我们有两个锁;如下图所示的读锁。它允许多个线程通过并且实际上是“对所有人免费”。


一旦我们需要写入变量,我们需要获得写锁,如下图所示。我们尝试请求写锁,但仍有线程仍在读取变量,因此我们必须等待。


一旦线程完成读取,所有读取都将阻塞,写入操作只能从单个线程发生,如下图所示。一旦我们释放写锁,我们将回到第一张图片中的“free for all”状态。


这是一个强大的模式,我们可以利用它来加快收集速度。典型的同步列表非常慢。它同步所有操作,读取或写入。我们有一个读取速度很快的 CopyOnWriteArrayList,但是任何写入都非常慢。


假设您可以避免从您的方法返回迭代器,您可以封装列表操作并使用此 API。


例如,在下面的代码中,我们将名称列表公开为只读,但是当我们需要添加名称时,我们使用写锁。这可以轻松胜过synchronized列表:

 private final ReadWriteLock LOCK = new ReentrantReadWriteLock(); private Collection<String> listOfNames = new ArrayList<>(); public void addName(String name) { LOCK.writeLock().lock(); try { listOfNames.add(name); } finally { LOCK.writeLock().unlock(); } } public boolean isInList(String name) { LOCK.readLock().lock(); try { return listOfNames.contains(name); } finally { LOCK.readLock().unlock(); } }

冲压锁

关于StampedLock我们需要了解的第一件事是它不可重入。假设我们有这个块:

 synchronized void methodA() { // … methodB(); // … } synchronized void methodB() { // … }

这会起作用。由于同步是可重入的。我们已经持有锁,所以从methodB()进入methodA()不会阻塞。假设我们使用相同的锁或相同的同步对象,这也适用于 ReentrantLock。


StampedLock返回一个我们用来释放锁的标记。因此,它有一些限制。但它仍然非常快速和强大。它还包括一个读写标记,我们可以用它来保护共享资源。


但与ReadWriteReentrantLock,它让我们可以升级锁。为什么我们需要这样做?

看看之前的addName()方法……如果我用“Shai”调用它两次会怎样?


是的,我可以使用 Set……但是对于本练习的重点,假设我们需要一个列表……我可以使用ReadWriteReentrantLock编写该逻辑:

 public void addName(String name) { LOCK.writeLock().lock(); try { if(!listOfNames.contains(name)) { listOfNames.add(name); } } finally { LOCK.writeLock().unlock(); } }


这很糟糕。我“支付”写锁只是为了在某些情况下检查contains() (假设有很多重复项)。我们可以在获取写锁之前调用isInList(name) 。然后我们会:


  • 抢读锁


  • 释放读锁


  • 获取写锁


  • 释放写锁


在这两种情况下,我们都可能要排队,这可能不值得额外的麻烦。


使用StampedLock ,我们可以将读锁更新为写锁,并在必要时当场进行更改,如下所示:

 public void addName(String name) { long stamp = LOCK.readLock(); try { if(!listOfNames.contains(name)) { long writeLock = LOCK.tryConvertToWriteLock(stamp); if(writeLock == 0) { throw new IllegalStateException(); } listOfNames.add(name); } } finally { LOCK.unlock(stamp); } }

这是针对这些情况的强大优化。

最后

我在上面的视频系列中涵盖了许多类似的主题;检查一下,让我知道你的想法。


我经常不假思索地使用同步集合。有时这可能是合理的,但对于大多数人来说,这可能不是最佳选择。通过花一些时间研究与线程相关的原语,我们可以显着提高我们的性能。


在处理潜在竞争更为敏感的 Loom 时尤其如此。想象一下在 1M 并发线程上扩展读取操作......在那些情况下,减少锁争用的重要性要大得多。


您可能会想,为什么synchronized集合不能使用ReadWriteReentrantLock甚至StampedLock


这是有问题的,因为 API 的表面积太大,很难针对通用用例对其进行优化。这就是对低级原语的控制可以在高吞吐量和阻塞代码之间产生差异的地方。