高性能 高可用 可弹性伸缩
本文是我们名为Java Concurrency Essentials的学院课程的一部分。
在本课程中,您将深入探讨并发的魔力。 将向您介绍并发和并发代码的基础知识,并学习诸如原子性,同步和线程安全性的概念。 在这里查看 !
目录
- 1.简介 2.表现
- 2.1。 阿姆达尔定律 2.2。 线程对性能的影响 2.3。 锁争用
1.简介
本文讨论了多线程应用程序的性能主题。 在定义性能和可伸缩性这两个术语之后,我们将仔细研究阿姆达尔定律。 在本课程的进一步内容中,我们将看到如何通过应用不同的技术来减少锁争用,如代码示例所示。
2.表现
线程可用于提高应用程序的性能。 其背后的原因可能是我们有多个处理器或CPU内核可用。 每个CPU内核都可以执行自己的任务,因此将大任务划分为一系列相互独立运行的较小任务,可以改善应用程序的总运行时间。 这种性能改善的一个示例可以是调整硬盘上文件夹结构中的图像大小的应用程序。 单线程方法将仅遍历所有文件并逐个缩放每个图像。 如果我们的CPU具有多个内核,则调整大小过程将仅利用可用内核之一。 例如,多线程方法可以让生产者线程扫描文件系统,并将所有找到的文件添加到队列中,该队列由一堆工作线程处理。 当我们拥有与CPU内核一样多的工作线程时,我们确保每个CPU内核都有事情要做,直到处理完所有映像为止。
使用多线程可以提高应用程序整体性能的另一个示例是具有大量I / O等待时间的用例。 假设我们要编写一个应用程序,以HTML文件的形式将完整的网站镜像到我们的硬盘上。 从一页开始,应用程序必须遵循指向同一域(或URL部分)的所有链接。 从向远程Web服务器发出请求直到收到所有数据之间的时间可能很长,我们可以将工作分配到几个线程上。 一个或多个线程可以解析收到HTML页面并将找到的链接放入队列,而其他线程可以将请求发送到Web服务器,然后等待答案。 在这种情况下,我们将等待时间用于新请求的页面以及已接收页面的解析。 与前面的示例相比,如果我们添加的线程数超过了CPU内核的数量,则此应用程序甚至可能会获得性能。
这两个例子表明,性能意味着可以在更短的时间内完成更多的工作。 当然,这是对性能一词的经典理解。 但是线程的使用也可以提高我们应用程序的响应速度。 想象一下简单的GUI应用程序,它带有一个输入表单和一个“ Process”按钮。 当用户按下按钮时,应用程序必须呈现被按下的按钮(按钮应像被按下并在释放鼠标时再次升起一样锁定),并且必须完成输入数据的实际处理。 如果此处理需要更长的时间,则单线程应用程序将无法对进一步的用户输入做出React,即我们需要一个附加线程来处理来自操作系统的事件,例如鼠标单击或鼠标指针移动。
可伸缩性是指程序通过向其添加更多资源来提高性能的能力。 想象一下,我们将不得不调整大量图像的大小。 由于我们当前计算机的CPU内核数量有限,因此添加更多线程并不能提高性能。 由于调度程序必须管理更多的线程,因此性能甚至可能下降,并且线程的创建和关闭也要消耗CPU功率。
阿姆达尔定律
最后一部分表明,在某些情况下,添加新资源可以提高应用程序的整体性能。 为了能够计算出当我们添加更多资源时我们的应用程序可以获得多少性能,我们需要确定程序中必须串行化/同步运行的部分以及程序中可以并行运行的部分。 如果我们表示必须与B同步运行的程序部分(例如,已同步执行的行数),并且如果我们表示具有n的可用处理器数,则阿姆达尔定律可让我们计算加速的上限我们的应用程序可能能够实现:
如果我们让n接近无穷大,则项(1-B)/ n收敛于零。 因此,我们可以忽略该术语,并且提速的上限与1 / B收敛,其中B是优化之前程序运行时在不可并行代码中花费的分数。 如果B例如为0.5,则意味着程序的一半不能并行化,则0.5的倒数为2;而B的倒数为2。 因此,即使我们在应用程序中添加无限数量的处理器,我们也只能获得大约2倍的加速。 现在,我们假设可以重写代码,以便仅0.25的程序运行时花费在同步块中。 现在0.25的倒数是4,这意味着我们已经构建了一个可以在大量处理器上运行的应用程序,它的运行速度比仅一个处理器快四倍。
反过来说,我们也可以使用阿姆达尔定律来计算必须同步执行以达到给定加速比的程序运行时间的分数。 如果我们想实现约100的加速,则倒数是0.01,这意味着我们应该只在同步代码中花费大约1%的运行时。
总结一下阿姆达尔定律的发现,我们可以得出结论,通过使用附加处理器可以使程序获得的最大速度受到程序花费在同步代码部分中的时间的倒数的限制。 尽管在实践中计算该分数并不总是那么容易,即使您考虑大型商业应用程序也不是一件容易的事,但法律给我们的提示是,我们必须非常仔细地考虑同步,并且必须保留程序运行时的各个部分。小,必须序列化。
线程对性能的影响
到目前为止,本文的著作表明,向应用程序添加更多线程可以提高性能和响应能力。 但是,另一方面,这不是免费的。 线程本身总是会对性能产生一些影响。
对性能的第一个影响是线程本身的创建。 这需要一些时间,因为JVM必须从基础操作系统中获取线程的资源并准备调度程序中的数据结构,该调度程序决定下一步执行哪个线程。
如果使用与处理器内核一样多的线程,则每个线程都可以在自己的处理器上运行,并且不会经常被中断。 实际上,在您的应用程序运行时,操作系统当然可能需要其自己的计算。 因此即使在这种情况下,线程也会中断,并且必须等到操作系统让它们再次运行。 当您必须使用比CPU内核更多的线程时,情况变得更糟。 在这种情况下,调度程序可以中断您的线程,以便让另一个线程执行其代码。 在这种情况下,必须保存正在运行的线程的当前状态,必须恢复应该在下一次运行的调度线程的状态。 除此以外,调度程序本身还必须对其内部数据结构执行一些更新,从而再次使用CPU功能。 总而言之,这意味着每个上下文从一个线程切换到另一个线程会消耗CPU能力,因此与单线程解决方案相比会导致性能下降。
具有多个线程的另一个成本是需要同步对共享数据结构的访问。 除了使用关键字sync,我们还可以使用volatile在多个线程之间共享数据。 如果有多个线程争用结构化的共享数据,那么我们就有争执。 然后,JVM必须决定下一步执行哪个线程。 如果这不是当前线程,那么将引入上下文切换成本。 然后,当前线程必须等待,直到可以获取锁为止。 JVM可以自行决定如何实现此等待。 与挂起线程并让另一个线程占用CPU时所需的上下文切换相比,当直到可以获取锁为止的预期时间很小时,自旋等待(即尝试一次又一次地获取锁)可能比效率更高。 使等待线程重新执行需要另一个上下文切换,并增加了锁争用的额外成本。
因此,减少由于锁争用而必需的上下文切换的数量是合理的。 下节描述了两种减少此争用的方法。
锁争用
如上一节所述,争用一个锁的两个或多个线程引入了额外的时钟周期,因为争用可能迫使调度程序要么让一个线程旋转等待该锁,要么让另一个线程占用处理器的成本。两个上下文切换。 在某些情况下,可以通过应用以下技术之一来减少锁争用:
- 锁的范围减小了。
- 减少获取某个锁的次数。
- 使用硬件支持的乐观锁定操作而不是同步。
- 尽可能避免同步
- 避免对象池
2.3.1缩小范围
当锁的保持时间超过必要时间时,可以应用第一种技术。 通常,这可以通过将一条或多条线移出同步块来实现,以减少当前线程保持锁的时间。 执行当前线程越早执行的代码行数越少,则可以离开同步块,从而让其他线程获得锁。 这也符合阿姆达尔定律,因为我们减少了在同步块中花费的运行时间的比例。
为了更好地理解此技术,请看以下源代码:
public class ReduceLockDuration implements Runnable {private static final int NUMBER_OF_THREADS = 5;private static final Map<String, Integer> map = new HashMap<String, Integer>();public void run() {for (int i = 0; i < 10000; i++) {synchronized (map) {UUID randomUUID = UUID.randomUUID();Integer value = Integer.valueOf(42);String key = randomUUID.toString();map.put(key, value);}Thread.yield();}}public static void main(String[] args) throws InterruptedException {Thread[] threads = new Thread[NUMBER_OF_THREADS];for (int i = 0; i < NUMBER_OF_THREADS; i++) {threads[i] = new Thread(new ReduceLockDuration());}long startMillis = System.currentTimeMillis();for (int i = 0; i < NUMBER_OF_THREADS; i++) {threads[i].start();}for (int i = 0; i < NUMBER_OF_THREADS; i++) {threads[i].join();}System.out.println((System.currentTimeMillis()-startMillis)+"ms");}
}
在此示例应用程序中,我们让五个线程竞争访问共享Map。 为了一次只允许一个线程访问Map,将访问Map并添加新的键/值对的代码放入同步块中。 当我们仔细查看该块时,我们看到密钥的计算以及原始整数42到Integer对象的转换必须不同步。 从概念上讲,它们属于访问Map的代码,但它们在当前线程本地,并且实例未被其他线程修改。 因此,我们可以将它们移出同步块:
public void run() {for (int i = 0; i < 10000; i++) {UUID randomUUID = UUID.randomUUID();Integer value = Integer.valueOf(42);String key = randomUUID.toString();synchronized (map) {map.put(key, value);}Thread.yield();}}
减少同步块会对可以测量的运行时间产生影响。 在我的机器上,使用最小化同步块的版本将整个应用程序的运行时间从420ms减少到370ms。 仅通过将三行代码移出同步块,就可以使运行时间总共减少11%。 引入Thread.yield()
语句是为了引起更多上下文切换,因为此方法调用告诉JVM当前线程愿意将处理器提供给另一个等待线程。 这再次引发了更多的锁争用,否则一个线程可能在没有任何竞争线程的情况下在处理器上运行太长时间。
2.3.2锁拆分
减少锁争用的另一种技术是将一个锁拆分为多个较小范围的锁。 如果您有一个锁来保护应用程序的不同方面,则可以应用此技术。 假设我们想收集有关应用程序的一些统计数据,并实现一个简单的计数器类,该计数器类在每个方面都包含一个原始计数器变量。 由于我们的应用程序是多线程的,因此必须同步访问这些变量,因为它们是从不同的并发线程访问的。 最简单的方法是在Counter的每个方法的方法签名中使用synced关键字:
public static class CounterOneLock implements Counter {private long customerCount = 0;private long shippingCount = 0;public synchronized void incrementCustomer() {customerCount++;}public synchronized void incrementShipping() {shippingCount++;}public synchronized long getCustomerCount() {return customerCount;}public synchronized long getShippingCount() {return shippingCount;}}
这种方法还意味着计数器的每个增量都会锁定Counter的整个实例。 其他要增加其他变量的线程必须等待,直到释放此单个锁。 在这种情况下,更有效的方法是为每个计数器使用单独的锁,如下例所示:
public static class CounterSeparateLock implements Counter {private static final Object customerLock = new Object();private static final Object shippingLock = new Object();private long customerCount = 0;private long shippingCount = 0;public void incrementCustomer() {synchronized (customerLock) {customerCount++;}}public void incrementShipping() {synchronized (shippingLock) {shippingCount++;}}public long getCustomerCount() {synchronized (customerLock) {return customerCount;}}public long getShippingCount() {synchronized (shippingLock) {return shippingCount;}}}
此实现引入了两个单独的同步对象,每个计数器一个。 因此,试图增加我们系统中客户数量的线程只需要与其他线程竞争,后者也可以增加客户数量,而不必与试图增加发货数量的线程竞争。
通过使用以下类,我们可以轻松衡量此锁拆分的影响:
public class LockSplitting implements Runnable {private static final int NUMBER_OF_THREADS = 5;private Counter counter;public interface Counter {void incrementCustomer();void incrementShipping();long getCustomerCount();long getShippingCount();}public static class CounterOneLock implements Counter { ... }public static class CounterSeparateLock implements Counter { ... }public LockSplitting(Counter counter) {this.counter = counter;}public void run() {for (int i = 0; i < 100000; i++) {if (ThreadLocalRandom.current().nextBoolean()) {counter.incrementCustomer();} else {counter.incrementShipping();}}}public static void main(String[] args) throws InterruptedException {Thread[] threads = new Thread[NUMBER_OF_THREADS];Counter counter = new CounterOneLock();for (int i = 0; i < NUMBER_OF_THREADS; i++) {threads[i] = new Thread(new LockSplitting(counter));}long startMillis = System.currentTimeMillis();for (int i = 0; i < NUMBER_OF_THREADS; i++) {threads[i].start();}for (int i = 0; i < NUMBER_OF_THREADS; i++) {threads[i].join();}System.out.println((System.currentTimeMillis() - startMillis) + "ms");}
}
在我的机器上,使用一个锁的实现平均大约需要56ms,而使用两个锁的实现大约需要38ms。 这减少了约32%。
另一个可能的改进是通过区分读和写锁来进一步分离锁。 例如, Counter
类提供用于读取和写入计数器值的方法。 虽然读取当前值可以由多个线程并行完成,但所有写入操作都必须序列化。 java.util.concurrent
包提供了此类ReadWriteLock
的即用型实现。
ReentrantReadWriteLock
实现管理两个单独的锁。 一种用于读访问,一种用于写访问。 读锁定和写锁定都提供了用于锁定和解锁的方法。 如果没有读取锁,则仅获取写入锁。 只要不获取写锁,就可以在读取器线程上获取读锁。 出于演示目的,下面显示了使用ReadWriteLock
实现计数器类的方法:
public static class CounterReadWriteLock implements Counter {private final ReentrantReadWriteLock customerLock = new ReentrantReadWriteLock();private final Lock customerWriteLock = customerLock.writeLock();private final Lock customerReadLock = customerLock.readLock();private final ReentrantReadWriteLock shippingLock = new ReentrantReadWriteLock();private final Lock shippingWriteLock = shippingLock.writeLock();private final Lock shippingReadLock = shippingLock.readLock();private long customerCount = 0;private long shippingCount = 0;public void incrementCustomer() {customerWriteLock.lock();customerCount++;customerWriteLock.unlock();}public void incrementShipping() {shippingWriteLock.lock();shippingCount++;shippingWriteLock.unlock();}public long getCustomerCount() {customerReadLock.lock();long count = customerCount;customerReadLock.unlock();return count;}public long getShippingCount() {shippingReadLock.lock();long count = shippingCount;shippingReadLock.unlock();return count;}}
所有读访问都通过获取读锁来保护,而所有写访问都通过相应的写锁来保护。 如果应用程序使用的读取访问次数比写入访问次数多,则这种实现甚至可以比以前的实现获得更多的性能改进,因为所有读取线程都可以并行访问getter方法。
2.3.3锁条
前面的示例演示了如何将一个锁分为两个单独的锁。 这允许竞争线程仅获取保护它们要操纵的数据结构的锁。 另一方面,如果实施不当,该技术还会增加复杂性和死锁的风险。
另一方面,锁条是一种类似于锁拆分的技术。 我们没有拆分一个保护不同代码部分或方面的锁,而是对不同的值使用了不同的锁。 JDK的java.util.concurrent
包中的ConcurrentHashMap
类使用此技术来提高严重依赖HashMap
的应用程序的性能。 与java.util.HashMap
的同步版本相反, ConcurrentHashMap
使用16个不同的锁。 每个锁仅保护可用哈希桶的1/16。 这允许希望将数据插入可用哈希桶的不同部分的不同线程同时执行此操作,因为它们的操作由不同的锁保护。 另一方面,它也引入了为特定操作获取多个锁的问题。 例如,如果要复制整个地图,则必须获取所有16个锁。
2.3.4原子操作
减少锁争用的另一种方法是使用所谓的原子操作。 以下文章之一将详细解释和评估此原理。 java.util.concurrent
包为某些原始数据类型提供了对原子操作的支持。 原子操作是使用处理器提供的所谓的比较和交换(CAS)操作实现的。 如果当前值等于提供的值,则CAS指令仅更新某个寄存器的值。 仅在这种情况下,旧值才被新值替换。
该原理可用于乐观地增加变量。 如果我们假设线程知道当前值,那么它可以尝试使用CAS操作将其递增。 如果事实证明,另一个线程同时增加了该值,而我们的值不再是当前值,则我们请求当前值,然后重试。 这可以完成,直到我们成功增加计数器。 尽管我们可能需要一些旋转,但此实现的优点是我们不需要任何类型的同步。
Counter
类的以下实现使用原子变量方法,并且不使用任何同步块:
public static class CounterAtomic implements Counter {private AtomicLong customerCount = new AtomicLong();private AtomicLong shippingCount = new AtomicLong();public void incrementCustomer() {customerCount.incrementAndGet();}public void incrementShipping() {shippingCount.incrementAndGet();}public long getCustomerCount() {return customerCount.get();}public long getShippingCount() {return shippingCount.get();}}
与CounterSeparateLock
类相比,平均总运行时间从39ms减少到16ms。 运行时间减少了约58%。
2.3.5避免热点
列表的典型实现将在内部管理一个计数器,该计数器保存列表中的项目数。 每次将新项目添加到列表或从列表中删除时,此计数器都会更新。 如果在单线程应用程序中使用,则此优化是合理的,因为列表上的size()
操作将直接返回先前计算的值。 如果列表不包含列表中的项目数,则size()
操作将必须遍历所有项目才能进行计算。
在许多数据结构中常见的优化可能会在多线程应用程序中成为问题。 假设我们想与一堆线程共享该列表的实例,这些线程可以从列表中插入和删除项目,并查询其大小。 现在,计数器变量也是共享资源,必须同步对其值的所有访问。 计数器已成为实施中的热点。
下面的代码段演示了此问题:
public static class CarRepositoryWithCounter implements CarRepository {private Map<String, Car> cars = new HashMap<String, Car>();private Map<String, Car> trucks = new HashMap<String, Car>();private Object carCountSync = new Object();private int carCount = 0;public void addCar(Car car) {if (car.getLicencePlate().startsWith("C")) {synchronized (cars) {Car foundCar = cars.get(car.getLicencePlate());if (foundCar == null) {cars.put(car.getLicencePlate(), car);synchronized (carCountSync) {carCount++;}}}} else {synchronized (trucks) {Car foundCar = trucks.get(car.getLicencePlate());if (foundCar == null) {trucks.put(car.getLicencePlate(), car);synchronized (carCountSync) {carCount++;}}}}}public int getCarCount() {synchronized (carCountSync) {return carCount;}}}
CarRepository
实现包含两个列表:一个用于汽车,一个用于卡车。 它还提供了一种返回两个列表中当前汽车和卡车数量的方法。 作为优化,每次将新车添加到两个列表之一时,它都会增加内部计数器。 该操作必须与专用的carCountSync
实例同步。 返回计数值时,将使用相同的同步。
为了摆脱这种额外的同步中, CarRepository
本来也可以实现通过省略额外的计数器和每个值是通过调用查询时间计算总的汽车数量getCarCount()
public static class CarRepositoryWithoutCounter implements CarRepository {private Map<String, Car> cars = new HashMap<String, Car>();private Map<String, Car> trucks = new HashMap<String, Car>();public void addCar(Car car) {if (car.getLicencePlate().startsWith("C")) {synchronized (cars) {Car foundCar = cars.get(car.getLicencePlate());if (foundCar == null) {cars.put(car.getLicencePlate(), car);}}} else {synchronized (trucks) {Car foundCar = trucks.get(car.getLicencePlate());if (foundCar == null) {trucks.put(car.getLicencePlate(), car);}}}}public int getCarCount() {synchronized (cars) {synchronized (trucks) {return cars.size() + trucks.size();}}}}
现在,我们需要与getCarCount()
方法中的汽车和卡车列表进行同步并计算大小,但是getCarCount()
添加新汽车期间的额外同步。
2.3.6避免对象池
在Java VM对象的第一个版本中,使用new运算符创建仍然是一项昂贵的操作。 这导致许多程序员采用了对象池的通用模式。 他们没有一次又一次地创建某些对象,而是构造了这些对象的池,并且每次需要一个实例时,都会从池中获取一个实例。 使用完对象后,将其放回池中,并可以由另一个线程使用。
乍看之下,在多线程应用程序中使用时可能会遇到问题。 现在,对象池在所有线程之间共享,并且必须同步对池中对象的访问。 现在,这种额外的同步开销可能大于对象创建本身的开销。 当您考虑垃圾收集器收集新创建的对象实例的额外费用时,甚至是这样。
与所有性能优化一样,此示例再次显示,在应用每个可能的改进之前,应仔细评估它们。 乍一看似乎很有意义的优化在没有正确实施的情况下甚至可能成为性能瓶颈。
翻译自: https://www.javacodegeeks.com/2015/09/performance-scalability-and-liveness.html
高性能 高可用 可弹性伸缩