乐趣区

关于java:重新学习Java线程原语

Synchronized 已经是一个革命性的技术,在以后依然有重要的用处。然而,当初是时候转向更新的 Java 线程原语,同时重新考虑咱们的外围逻辑。

自从 Java 第一个测试版以来,我就始终在应用它。从那时起,线程就是我最喜爱的个性之一。Java 是第一种在编程语言自身中引入线程反对的语言。那是一个具备争议的决定。在过来的十年中,每种编程语言都竞相引入 async/await,甚至 Java 也有一些第三方反对 …… 然而 Java 抉择了引入更优越的虚构线程(Loom 我的项目)。本文并不探讨这个问题。

我感觉这很好,证实了 Java 的外围实力。Java 不仅仅是一种语言,还是一种文化。这种文化重视三思而行的改革,而不是自觉追随时尚潮流。

在本文中,我想从新探讨 Java 中的线程编程旧办法。我习惯应用 synchronized、wait、notify 等技术。然而,“然而,这些办法曾经不再是 Java 中线程解决的最佳形式。我也是问题的一部分。我还是习惯于应用这些技术,发现很难适应自 Java 5 以来就存在的一些 API。这是一种习惯的力量。尽管能够探讨许多解决线程的杰出 API,但我想在这里专一探讨锁,因为它们是根底但极为重要的。

Synchronized 与 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 块,因为如果在块外部产生异样,锁将放弃。而 synchronized 则会主动解决异样。

有些人会应用 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 语句是用于清理资源的,而咱们正在重复使用锁对象。尽管调用了 close 办法,然而咱们会再次在同一个对象上调用它。我认为,将 try-with-resource 语法扩大到反对锁接口可能是个好主见。但在此之前,这个技巧可能不值得采纳。

ReentrantLock 的劣势

应用 ReentrantLock 的最大起因是 Loom 反对。其余的长处也不错,但没有一个是“杀手级性能”。

咱们能够在办法之间应用它,而不是在一个间断的代码块中应用。然而这可能不是一个好主见,因为你心愿尽量减少锁定区域,并且失败可能会成为一个问题。我不认为这个个性是一个长处。

ReentrantLock 提供了偏心锁(fairness)的选项。这意味着它会先服务于最先停在锁上的线程。我试图想到一个事实而简略的应用案例,但却无从下手。如果您正在编写一个简单的调度程序,并且有许多线程一直地排队期待资源,您可能会发现一个线程因为其余线程一直到来而被“饥饿”。然而,这种状况可能更适宜应用并发包中的其余选项。兴许我漏掉了什么……

lockInterruptibly() 办法容许咱们在线程期待锁时中断它。这是一个乏味的个性,然而很难找到一个真正理论利用场景。如果你编写的代码须要十分疾速响应中断,你须要应用 lockInterruptibly() API 来取得这种能力。然而,你通常在 lock() 办法外部破费多长时间呢?

这种状况可能只在极其状况下才会有影响,大多数人在编写高级多线程代码时可能不会遇到这种状况。

ReadWriteReentrantLock

更好的办法是应用 ReadWriteReentrantLock。大多数资源都遵循频繁读取、大量写入的准则。因为读取变量是线程平安的,除非正在写入变量,否则没有必要加锁。这意味着咱们能够将读取操作进行极致优化,同时略微升高写操作的速度。

假如这是你的应用状况,你能够创立更快的代码。应用读写锁时,咱们有两个锁,一个读锁,如下图所示。它容许多个线程通过,实际上是“自由竞争”的。

一旦咱们须要写入变量,咱们须要取得写锁,如下图所示。咱们尝试申请写锁,但仍有线程从变量中读取,因而咱们必须期待。

一旦所有线程实现读取,所有读取操作都会阻塞,写入操作只能由一个线程执行,如下图所示。一旦开释写锁,咱们将回到第一张图中的“自由竞争”状态。

这是一种弱小的模式,咱们能够利用它使汇合变得更快。一个典型的同步列表十分慢。它同步所有的操作,包含读和写。咱们有一个 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();}
}

这个计划可行,因为 synchronized 是可重入的。咱们曾经持有锁,所以从 methodA() 进入 methodB() 不会阻塞。这在应用 ReentrantLock 时也同样实用,只有咱们应用雷同的锁或雷同的 synchronized 对象。

StampedLock 返回一个戳记(stamp),咱们用它来开释锁。因而,它有一些限度,但它依然十分快和弱小。它也包含一个读写戳记,咱们能够用它来爱护共享资源。但 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);
   }
}

这是针对这些状况的一个弱小的优化。

终论

我常常不假思索地应用 synchronized 汇合,这有时可能是正当的,但对于大多数状况来说,这可能是次优的。通过破费一点工夫钻研与线程相干的原语,咱们能够显著进步性能。特地是在解决 Loom 时,其中底层争用更为敏感。设想一下在 100 万并发线程上扩大读取操作的状况 … 在这些状况下,缩小锁争用的重要性要大得多。

你可能会想,为什么 synchronized 汇合不能应用 ReadWriteReentrantLock 或者是 StampedLock 呢?

这是一个问题,因为 API 的可见接口范畴十分大,很难针对通用用例进行优化。这就是管制低级原语的中央,能够使高吞吐量和阻塞代码之间的差别。


【注】本文译自:Relearning Java Thread Primitives – DZone

退出移动版