澳门新葡萄京官网注册 1

Java并发编程:性能、扩展性和响应

本文由码农网 – Sandbox
Wang原创翻译,转载请看清文末的转载要求,欢迎参与我们的付费投稿计划!

线程可以充分发挥系统的处理能力,提高资源利用率。同时现有的线程可以提升系统响应性。

原创文章,同步发自作者个人博客,转载请以超链接形式在文章开头处注明出处

1、介绍

本文讨论的重点在于多线程应用程序的性能问题。我们会先给性能和扩展性下一个定义,然后再仔细学习一下Amdahl法则。下面的内容我们会考察一下如何用不同的技术方法来减少锁竞争,以及如何用代码来实现。

但是在安全性与极限性能上,我们首先需要保证的是安全性。

多线程编程中的三个核心概念

2、性能

我们都知道,多线程可以用来提高程序的性能,背后的原因在于我们有多核的CPU或多个CPU。每个CPU的内核都可以自己完成任务,因此把一个大的任务分解成一系列的可彼此独立运行的小任务就可以提高程序的整体性能了。可以举个例子,比如有个程序用来将硬盘上某个文件夹下的所有图片的尺寸进行修改,应用多线程技术就可以提高它的性能。使用单线程的方式只能依次遍历所有图片文件并且执行修改,如果我们的CPU有多个核心的话,毫无疑问,它只能利用其中的一个核。使用多线程的方式的话,我们可以让一个生产者线程扫描文件系统把每个图片都添加到一个队列中,然后用多个工作线程来执行这些任务。如果我们的工作线程的数量和CPU总的核心数一样的话,我们就能保证每个CPU核心都有活可干,直到任务被全部执行完成。

对于另外一种需要较多IO等待的程序来说,利用多线程技术也能提高整体性能。假设我们要写这样一个程序,需要抓取某个网站的所有HTML文件,并且将它们存储到本地磁盘上。程序可以从某一个网页开始,然后解析这个网页中所有指向本网站的链接,然后依次抓取这些链接,这样周而复始。因为从我们对远程网站发起请求到接收到所有的网页数据需要等待一段时间,所以我们可以将此任务交给多个线程来执行。让一个或稍微更多一点的线程来解析已经收到的HTML网页以及将找到的链接放入队列中,让其他所有的线程负责请求获取页面。与上一个例子不同的是,在这个例子中,你即便使用多于CPU核心数量的线程也仍然能够获得性能提升。

上面这两个例子告诉我们,高性能就是在短的时间窗口内做尽量多的事情。这个当然是对性能一词的最经典解释了。但是同时,使用线程也能很好地提升我们程序的响应速度。想象我们有这样一个图形界面的应用程序,上方有一个输入框,输入框下面有一个名字叫“处理”的按钮。当用户按下这个按钮的时候,应用程序需要重新对按钮的状态进行渲染(按钮看起来被按下了,当松开鼠标左键时又恢复原状),并且开始对用户的输入进行处理。如果处理用户输入的这个任务比较耗时的话,单线程的程序就无法继续响应用户其他的输入动作了,比如,来自操作系统传送过来的用户单击鼠标事件或鼠标指针移动事件等等,这些事件的响应需要有独立的线程来响应。

可扩展性(Scalability)的意思是程序具备这样的能力:通过添加计算资源就可以获得更高的性能。想象我们需要调整很多图片的大小,因为我们机器的CPU核心数是有限的,所以增加线程数量并不总能相应提高性能。相反,因为调度器需要负责更多线程的创建和关闭,也会占用CPU资源,反而有可能降低性能。

11.1 对性能的思考

提升性能=用更少的资源做更多的事情(太对了,这才是问题的本质)。

资源包括:CPU时钟周期,内存,网络带宽,I/O带宽,数据请求,磁盘空间等。

资源密集型说的就是对上述维度敏感的应用。

与单线程相比,多线程总会一起一些额外的性能开销:

  • 线程协调with coordinating between threads (locking, signaling, and
    memory synchronization)
  • 上下文切换increased context switching
  • 澳门新葡萄京官网注册,线程创建和销毁thread creation and teardown
  • 线程调度scheduling overhead

可伸缩性是指:增加资源,程序的吞吐可以成比例的增加。

性能的提高往往是一个权衡的过程,需要考虑诸多因素。

原子性

这一点,跟数据库事务的原子性概念差不多,即一个操作(有可能包含有多个子操作)要么全部执行(生效),要么全部都不执行(都不生效)。

关于原子性,一个非常经典的例子就是银行转账问题:比如A和B同时向C转账10万元。如果转账操作不具有原子性,A在向C转账时,读取了C的余额为20万,然后加上转账的10万,计算出此时应该有30万,但还未来及将30万写回C的账户,此时B的转账请求过来了,B发现C的余额为20万,然后将其加10万并写回。然后A的转账操作技术——将30万写回C的余额。这种情况下C的最终余额为30万,而非预期的40万。

2.1 Amdahl法则

上一段提到了在某些情形下,添加额外的运算资源可以提高程序的整体性能。为了能够计算出当我们添加了额外的资源的时候到底能获得多少性能提升,我们有必要来检查一下程序有哪些部分是串行运行(或同步运行),有哪些部分是并行运行的。如果我们把需要同步执行的代码占比量化为B(例如,需要同步执行的代码的行数),把CPU的总核心数记为n,那么,根据Amdahl法则,我们可以获得的性能提升的上限是:

澳门新葡萄京官网注册 1

如果n趋于无穷大的话,(1-B)/n就收敛于0。因此,我们可以忽略这个表达式的值,因此性能提升位数收敛于1/B,这里面的B代表是那些必须同步运行的代码比例。如果B等于0.5的话,那意味着程序的一半代码无法并行运行,0.5的倒数是2,因此,即使我们添加无数个CPU核心,我们获得的性能提升也最多是2倍。假设我们现在把程序修改了一下,修改之后只有0.25的代码必须同步运行,现在1/0.25=4,表示我们的程序如果在具有大量CPU的硬件上运行时速度将会比在单核的硬件上快大概4倍。

另一方面,通过Amdahl法则,我们也能根据我们想获得的提速的目标计算出程序应该的同步代码的比例。如果我们想要达到100倍的提速,而1/100=0.01,意味着,我们程序同步执行的代码的数量最多不能超过1%。

总结Amdahl法则我们可以看出,我们通过添加额外CPU来获得性能提升的最大值取决于程序同步执行部分代码所占的比例有多小。虽然在实际中,想要计算出这个比例并不总是那么容易,更别说面对一些大型的商业系统应用了,但是Amdahl法则给了我们很重要的启示,那就是,我们必须非常仔细地去考虑那些必须同步执行的代码,并且力图减少这部分代码。

11.2 Amdahl定律 Amdahl’s Law

收割可以靠并行提高性能,而作物生长则不行。这是一个很简单的自然界的问题,在计算机界也存在,需要对问题进行合理的分解,发现潜在的并行能力。

Amdahl定律:并行计算中的加速比是用并行前的执行速度和并行后的执行速度之比来表示的,它表示了在并行化之后的效率提升情况。

speedup <= 1 / F + (1 – F) /N

F表示被串行化的部分,N表示处理器数量。

如果N无穷大,那么最大的加速比例是1/F。理论上如果50%是串行的,那么最大的加速比只能是2。如果10%串行。那么最大加速比接近10,如果N=10也就是说有10个处理器资源,那么最高的加速比是5.4,在100个处理器的情况下是9.2。

但是任何程序都存在串行部分,例如从队列中take数据,访问数据库的操作等,这是绝对的。

书中举了一个例子是Synchronized
linkedlist和ConcurrentLinkedQueue的吞吐率对比,在处理器数量到达上限后,他们的吞吐都基本是一条持平的线,但是Synchronized
linkedlist吞吐率更低,在处理器较少的情况下就到达了极限,这主要受context
switch的限制。

可见性

可见性是指,当多个线程并发访问共享变量时,一个线程共享变量的修改,其它线程能够立即看到。可见性问题是好多个忽略或者理解错误的一点。

CPU从主内存中读数据的效率相对来说不高,现在主流的计算机中,都有几级缓存。每个线程读取共享变量时,都会将该变量加载进其对应CPU的高速缓存里,修改该变量后,CPU会立即更新该缓存,但并不一定会立即将其写回主内存(实际上写回主内存的时间不可预期)。此时其它线程(尤其是不在同一个CPU上执行的线程)访问该变量时,从主内存中读到的就是旧的数据,而非第一个线程更新后的数据。

这一点是操作系统或者说是硬件层面的机制,所以很多应用开发人员经常会忽略。

2.2 对性能的影响

文章写到这里,我们已经表明这样一个观点:增加更多的线程可以提高程序的性能和响应速度。但是另一方面,想要取得这些好处却并非轻而易举,也需要付出一些代价。线程的使用对性能的提升也会有所影响。

首先,第一个影响来自线程创建的时候。线程的创建过程中,JVM需要从底层操作系统申请相应的资源,并且在调度器中初始化数据结构,以便决定执行线程的顺序。

如果你的线程的数量和CPU的核心数量一样的话,每个线程都会运行在一个核心上,这样或许他们就不会经常被打断了。但是事实上,在你的程序运行的时候,操作系统也会有些自己的运算需要CPU去处理。所以,即使这种情形下,你的线程也会被打断并且等待操作系统来重新恢复它的运行。当你的线程数量超过CPU的核心数量的时候,情况有可能变得更坏。在这种情况下,JVM的进程调度器会打断某些线程以便让其他线程执行,线程切换的时候,刚才正在运行的线程的当前状态需要被保存下来,以便等下次运行的时候可以恢复数据状态。不仅如此,调度器也会对它自己内部的数据结构进行更新,而这也需要消耗CPU周期。所有这些都意味着,线程之间的上下文切换会消耗CPU计算资源,因此带来相比单线程情况下没有的性能开销。

多线程程序所带来的另外一个开销来自对共享数据的同步访问保护。我们可以使用synchronized关键字来进行同步保护,也可以使用Volatile关键字来在多个线程之间共享数据。如果多于一个线程想要去访问某一个共享数据结构的话,就发生了争用的情形,这时,JVM需要决定哪个进程先,哪个进程后。如果决定该要执行的线程不是当前正在运行的线程,那么就会发生线程切换。当前线程需要等待,直到它成功获得了锁对象。JVM可以自己决定如何来执行这种“等待”,假如JVM预计离成功获得锁对象的时间比较短,那JVM可以使用激进等待方法,比如,不停地尝试获得锁对象,直到成功,在这种情况下这种方式可能会更高效,因为比较进程上下文切换来说,还是这种方式更快速一些。把一个等待状态的线程挪回到执行队列也会带来额外的开销。

因此,我们要尽力避免由于锁竞争而带来的上下文切换。下面一节将阐述两种降低这种竞争发生的方法。

11.3 线程引入的开销

单线程不存在线程调度,也不存在同步开销,不需要使用锁来保证安全一致性。而多线程这些都需要考虑。

顺序性

顺序性指的是,程序执行的顺序按照代码的先后顺序执行。

以下面这段代码为例

boolean started = false; // 语句1
long counter = 0L; // 语句2
counter = 1; // 语句3
started = true; // 语句4

从代码顺序上看,上面四条语句应该依次执行,但实际上JVM真正在执行这段代码时,并不保证它们一定完全按照此顺序执行。

处理器为了提高程序整体的执行效率,可能会对代码进行优化,其中的一项优化方式就是调整代码顺序,按照更高效的顺序执行代码。

讲到这里,有人要着急了——什么,CPU不按照我的代码顺序执行代码,那怎么保证得到我们想要的效果呢?实际上,大家大可放心,CPU虽然并不保证完全按照代码顺序执行,但它会保证程序最终的执行结果和代码顺序执行时的结果一致。

2.3 锁竞争

像上一节所说的那样,两个或更多线程对锁的竞争访问会带来额外的运算开销,因为竞争的发生逼迫调度器来让一个线程进入激进等待状态,或者让它进行等待状态而引发两次上下文切换。有某些情况下,锁竞争的恶果可以通过以下方法来减轻:

1,减少锁的作用域;

2,减少需要获取锁的频率;

3,尽量使用由硬件支持的乐观锁操作,而不是synchronized;

4,尽量少用synchronized;

5,减少使用对象缓存

11.3.1 上下文切换

操作系统的设计者巧妙地利用了时间片轮转的方式,
CPU给每个任务都服务一定的时间, 然后把当前任务的状态保存下来,
在加载下一任务的状态后, 继续服务下一任务.
如果可运行的线程数大于CPU数量,那么OS会最终将某个正在运行的线程调度出来,从而让其他线程能够使用CPU,这会导致一次上下文切换,主要包括当前线程“保存现场”,并且新调度出来的线程需要“恢复现场“。这里的context
switch直接消耗包括: CPU寄存器需要保存和加载, 系统调度器的代码需要执行,
TLB实例需要重新加载, CPU 的pipeline需要刷掉;
间接消耗指的是多核的cache之间得共享数据,
间接消耗对于程序的影响要看线程工作区操作数据的大小).

JVM和OS消耗的CPU时钟周期越少,那么APP可用的CPU时钟周期就越多。

往往OS有一个最小的执行时间,防止过于频繁的上下文切换。

JVM会因为阻塞比如锁、阻塞I/O而挂起线程,如果频繁的阻塞,就会无法使用完整的调度时间片。//?

如果可运行的线程数大于CPU的内核数,那么OS会根据一定的调度算法,强行切换正在运行的线程,从而使其它线程能够使用CPU周期。

切换线程会导致上下文切换。线程的调度会导致CPU需要在操作系统和进程间花费更多的时间片段,这样真正执行应用程序的时间就减少了。另外上下文切换也会导致缓存的频繁进出,对于一个刚被切换的线程来说,可能由于高速缓冲中没有数据而变得更慢,从而导致更多的IO开销。

vmstat 命令可以看cs这一个字段看上下文切换的数据。

Java如何解决多线程并发问题

2.3.1 缩减同步域

如果代码持有锁超过必要的时间,那么可以应用这第一种方法。通常我们可以将一行或多行代码移出同步区域来降低当前线程持有锁的时间。在同步区域里运行的代码数量越少,当前线程就会越早地释放锁,从而让其他线程更早地获得锁。这与Amdahl法则相一致的,因为这样做减少了需要同步执行的代码量。

为了更好地理解,看下面的源码:

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中添加Key/Value的操作放到了synchronized保护的代码块中。当我们仔细察看这段代码的时候,我们可以看到,计算key和value的几句代码并不需要同步执行,key和value只属于当前执行这段代码的线程,仅仅对当前线程有意义,并且不会被其他线程所修改。因此,我们可以把这几句移出同步保护。如下:

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当前线程想要交出当前使用的计算资源,以便让其他等待运行的线程运行。这样也会带来更多的锁竞争的发生,因为,如果不如此的话某一个线程就会更久地占用某个核心继而减少了线程上下文切换。

11.3.2 内存同步

同步的性能开销包括多个方面。在synchronized和volatile提供的可见性保证中会使用一些特殊指令,即内存栅栏(memory
barrier),内存栅栏可以刷新缓存,满足可见性,但是它也会抑制一些编译器优化,例如不能指令重排序。

现代的JVM对于无竞争的synchronized的消耗非常小,基本微乎其微。

同时现代的JVM编译优化做的非常成熟,一些不必要的同步开销往往可以优化掉。例如,下面的代码会去掉锁获取。

synchronized (new Object()) {
 // do something
} 

还有一些比如escape
analysis会找出不会发布到堆上的本地对象,锁的获取和释放会被优化为最小的次数甚至去掉。例如下面的操作。

public String getStoogeNames() {
 List<String> stooges = new Vector<String>();
 stooges.add("Moe");
 stooges.add("Larry");
 stooges.add("Curly");
 return stooges.toString();
} 

当然即使不escape,也会有lock
coarsening过程,将临近的同步代码块使用同一个锁合并起来。这都减少了同步的开销。

所以不必过度担心非竞争同步带来的开销,这个基本的机制已经非常的快了,而且JVM还有能进行额外的优化以进一步降低或者消除开销的本领。

不同线程间要进行数据同步,synchronized以及volatile提供的可见性都会导致缓存失效。线程栈之间的数据要和主存进行同步,这些同步有一些小小的开销。如果线程间同时要进行数据同步,那么这些同步的线程可能都会受阻。

Java如何保证原子性

2.3.2 分拆锁

另外一种减少锁竞争的方法是将一块被锁定保护的代码分散到多个更小的保护块中。如果你的程序中使用了一个锁来保护多个不同对象的话,这种方式会有用武之地。假设我们想要通过程序来统计一些数据,并且实现了一个简单的计数类来持有多个不同的统计指标,并且分别用一个基本计数变量来表示(long类型)。因为我们的程序是多线程的,所以我们需要对访问这些变量的操作进行同步保护,因为这些操作动作来自不同的线程。要达到这个目的,最简单的方式就是对每个访问了这些变量的函数添加synchronized关键字。

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实例的锁定。其他线程如果想要对另外一个不同的变量调用increment方法,那也只能等待前一个线程释放了锁控制之后才能有机会去完成。在此种情况下,对每个不同的变量使用单独的synchronized保护将会提高执行效率。

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;
        }
    }
}

这种实现为每个计数指标引入了一个单独synchronized对象,因此,一个线程想要增加Customer计数的时候,它必须等待另一个正在增加Customer计数的线程完成,而并不用等待另一个正在增加Shipping计数的线程完成。

使用下面的类,我们可以非常容易地计算分拆锁所带来的性能提升。

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实现维护了两个不同的锁,一个保护读操作,一个保护写操作。这两个锁都有获取锁和释放锁的操作。仅仅当在没有人获取读锁的时候,写锁才能成功获得。反过来,只要写锁没有被获取,读锁可以被多个线程同时获取。为了演示这种方法,下面的Counter类使用了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;
    }
}

所有的读操作都被读锁保护,同时,所有的写操作都被写锁所保护。如果程序中执行的读操作要远大于写操作的话,这种实现可以带来比前一节的方式更大的性能提升,因为读操作可以并发进行。

11.3.3 阻塞

竞争的同步需要OS介入,从而增加了开销。当在锁上发生竞争时,失败者线程会被阻塞,JVM在实现发现阻塞的行为时,可以采用

  • 自旋等待 spin-waiting
  • 或者OS挂起被阻塞的线程

这两种的效率高低取决于上下文切换的开销以及成功获取锁之前的等待时间,如果等待时间较短,则spin-waiting,如果较长则挂起。

一个线程被阻塞会产生上下文切换的影响,但是它到底何时执行这是由OS决定的,靠时间分片机制,这个调度的策略是OS解决的,而JVM的scheduler解决的是阻塞释放锁之后哪个线程需要被select出来执行,也就是转到runnable状态。

There is no single Java Virtual Machine; JVM is a specification, and
there are multiple implementations of it, including the OpenJDK version
and the Sun version of it, among others. I don’t know for certain, but I
would guess that any reasonable JVM would simply use the underlying
threading mechanism provided by the OS, which would imply POSIX Threads
(pthreads) on UNIX (Mac OS X, Linux, etc.) and would imply WIN32 threads
on Windows. Typically, those systems use a round-robin strategy by
default. Many types of algorithms exist like preemptive and time
slicing
with round robin etc.

The JVM is based on preemptive and priority based scheduling
algorithm to select thread to run.

每个Java线程一对一映射到Solaris平台上的一个本地线程上,并将线程调度交由本地线程的调度程序。由于Java线程是与本地线程是一对一地绑在一起的,所以改变Java线程的优先权也不会有可靠地运行结果。

对于类Unix系统而言,一般都是进程作为任务的调度单位,也即是操作系统调度器,只会针对进程来分配CPU等资源。由于进程彼此独立,相互不可进行直接访问,这增加了应用的通信成本。所以后面有了微进程,微进程与进程不同的是,允许一定程度上,彼此可以直接进行访问,详细可参考LinuxThreads。JVM在一些类Unix平台下,就是将线程映射到操作系统的微进程,来实现线程调度。这样多线程能够直接被系统调度器进行调度,与此对应的就是其线程的创建和销毁的成本就比较高,而且JVM的线程优先级很难进行匹配,无法提供确切的保证,仅仅是个hint。

当发生锁竞争时,失败的线程会导致阻塞。通常阻塞的线程可能在JVM内部进行自旋等待,或者被操作系统挂起。自旋等待可能会导致更多的CPU切片浪费,而操作系统挂起则会导致更多的上下文切换。

锁和同步

常用的保证Java操作原子性的工具是锁和同步方法(或者同步代码块)。使用锁,可以保证同一时间只有一个线程能拿到锁,也就保证了只一时间只有一个线程能执行申请锁和释放锁之间的代码。

public void testLock () {
  lock.lock();
  try{
    int j = i;
    i = j + 1;
  } finally {
    lock.unlock();
  }
}

与锁类似的是同步方法或者同步代码块。使用非静态同步方法时,锁住的是当前实例;使用静态同步方法时,锁住的是该类的Class对象;使用静态代码块时,锁住的是synchronized关键字后面括号内的对象。下面是同步代码块示例

public void testLock () {
  synchronized (anyObject){
    int j = i;
    i = j + 1;
  }
}

无论使用锁还是synchronized,本质都是一样,通过锁来实现资源的排它性,从而实际目标代码段同一时间只会被一个线程执行,进而保证了目标代码段的原子性。这是一种以牺牲性能为代价的方法。

2.3.3 分离锁

上面一个例子展示了如何将一个单独的锁分开为多个单独的锁,这样使得各线程仅仅获得他们将要修改的对象的锁就可以了。但是另一方面,这种方式也增加了程序的复杂度,如果实现不恰当的话也可能造成死锁。

分离锁是与分拆锁类似的一种方法,但是分拆锁是增加锁来保护不同的代码片段或对象,而分离锁是使用不同的锁来保护不同范围的数值。JDK的java.util.concurrent包里的ConcurrentHashMap即使用了这种思想来提高那些严重依赖HashMap的程序的性能。在实现上,ConcurrentHashMap内部使用了16个不同的锁,而不是封装一个同步保护的HashMap。16个锁每一个负责保护其中16分之一的桶位(bucket)的同步访问。这样一来,不同的线程想要向不同的段插入键的时候,相应的操作会受到不同的锁来保护。但是反过来也会带来一些不好的问题,比如,某些操作的完成现在需要获取多个锁而不是一个锁。如果你想要复制整个Map的话,这16个锁都需要获得才能完成。

11.4 减少锁的竞争

减少锁的竞争能够提高性能和可伸缩性。

在并发程序中,对可伸缩性的最主要的威胁就是独占方式的资源锁。

有三种方式可以减低锁的竞争程度:

  • 减少锁的持有时间
  • 降低锁的请求频率
  • 使用带有协调机制的独占锁,这些机器允许更好的并发性。//?

CAS(compare and swap)

基础类型变量自增(i++)是一种常被新手误以为是原子操作而实际不是的操作。Java中提供了对应的原子操作类来实现该操作,并保证原子性,其本质是利用了CPU级别的CAS指令。由于是CPU级别的指令,其开销比需要操作系统参与的锁的开销小。AtomicInteger使用方法如下。

AtomicInteger atomicInteger = new AtomicInteger();
for(int b = 0; b < numThreads; b++) {
  new Thread(() -> {
    for(int a = 0; a < iteration; a++) {
      atomicInteger.incrementAndGet();
    }
  }).start();
}

2.3.4 原子操作

另外一种减少锁竞争的方法是使用原子操作,这种方式会在其他文章中详细阐述原理。java.util.concurrent包对一些常用基础数据类型提供了原子操作封装的类。原子操作类的实现基于处理器提供的“比较置换”功能(CAS),CAS操作只在当前寄存器的值跟操作提供的旧的值一样的时候才会执行更新操作。

这个原理可以用来以乐观的方式来增加一个变量的值。如果我们的线程知道当前的值的话,就会尝试使用CAS操作来执行增加操作。如果期间别的线程已经修改了变量的值,那么线程提供的所谓的当前值已经跟真实的值不一样了,这时JVM来尝试重新获得当前值,并且再尝试一次,反反复复直到成功为止。虽然循环操作会浪费一些CPU周期,但是这样做的好处是,我们不需要任何形式的同步控制。

下面的Counter类的实现就利用了原子操作的方式,你可以看到,并没有使用任何synchronized的代码。

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%。

11.4.1 缩小锁的范围(快进快出)

原理就是Amdah定律,串行的代码总量减少了。

Java如何保证可见性

Java提供了volatile关键字来保证可见性。当使用volatile修饰某个变量时,它会保证对该变量的修改会立即被更新到内存中,并且将其它缓存中对该变量的缓存设置成无效,因此其它线程需要读取该值时必须从主内存中读取,从而得到最新的值。

2.3.5 避免热点代码段

一个典型的LIST实现通过会在内容维护一个变量来记录LIST自身所包含的元素个数,每一次从列表里删除或增加元素的时候,这个变量的值都会改变。如果LIST在单线程应用中使用的话,这种方式无可厚非,每次调用size()时直接返回上一次计算之后的数值就行了。如果LIST内部不维护这个计数变量的话,每次调用size()操作都会引发LIST重新遍历计算元素个数。

这种很多数据结构都使用了的优化方式,当到了多线程环境下时却会成为一个问题。假设我们在多个线程之间共享一个LIST,多个线程同时地去向LIST里面增加或删除元素,同时去查询大的长度。这时,LIST内部的计数变量成为一个共享资源,因此所有对它的访问都必须进行同步处理。因此,计数变量成为整个LIST实现中的一个热点。

下面的代码片段展示了这个问题:

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的实现内部有两个LIST变量,一个用来放洗车元素,一个用来放卡车元素,同时,提供了查询这两个LIST总共的大小的方法。采用的优化方式是,每次添加一个Car元素的时候,都会增加内部的计数变量的值,同时增加的操作受synchronized保护,返回计数值的方法也是一样。

为了避免带来这种额外的代码同步开销,看下面另外一种CarRepository的实现:它不再使用一个内部的计数变量,而是在返回汽车总数的方法里实时计数这个数值。如下:

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()方法里,两个LIST的访问需要同步保护,像上一种实现那样每次添加新元素时的同步开销已经不存在了。

11.4.2 减小锁的粒度

这种方式就是降低线程请求锁的频率,通过锁分解来实现。

下面的应用明显锁的粒度太粗了。

public class ServerStatusBeforeSplit {
    @GuardedBy("this") public final Set<String> users;
    @GuardedBy("this") public final Set<String> queries;

    public ServerStatusBeforeSplit() {
        users = new HashSet<String>();
        queries = new HashSet<String>();
    }

    public synchronized void addUser(String u) {
        users.add(u);
    }

    public synchronized void addQuery(String q) {
        queries.add(q);
    }

    public synchronized void removeUser(String u) {
        users.remove(u);
    }

    public synchronized void removeQuery(String q) {
        queries.remove(q);
    }
}

锁分解就是独立的变量独立分配锁,不适用全局锁。优化后如下:

public class ServerStatusAfterSplit {
    @GuardedBy("users") public final Set<String> users;
    @GuardedBy("queries") public final Set<String> queries;

    public ServerStatusAfterSplit() {
        users = new HashSet<String>();
        queries = new HashSet<String>();
    }

    public void addUser(String u) {
        synchronized (users) {
            users.add(u);
        }
    }

    public void addQuery(String q) {
        synchronized (queries) {
            queries.add(q);
        }
    }

    public void removeUser(String u) {
        synchronized (users) {
            users.remove(u);
        }
    }

    public void removeQuery(String q) {
        synchronized (users) {
            queries.remove(q);
        }
    }
}

Java如何保证顺序性

上文讲过编译器和处理器对指令进行重新排序时,会保证重新排序后的执行结果和代码顺序执行的结果一致,所以重新排序过程并不会影响单线程程序的执行,却可能影响多线程程序并发执行的正确性。

Java中可通过volatile在一定程序上保证顺序性,另外还可以通过synchronized和锁来保证顺序性。

synchronized和锁保证顺序性的原理和保证原子性一样,都是通过保证同一时间只会有一个线程执行目标代码段来实现的。

除了从应用层面保证目标代码段执行的顺序性外,JVM还通过被称为happens-before原则隐式的保证顺序性。两个操作的执行顺序只要可以通过happens-before推导出来,则JVM会保证其顺序性,反之JVM对其顺序性不作任何保证,可对其进行任意必要的重新排序以获取高效率。

2.3.6 避免对象缓存复用

在JAVA VM的第一版里,使用new关键字来创建新对象的开销比较大,因此,很多开发人员习惯了使用对象复用模式。为了避免一次又一次重复创建对象,开发人员维护一个缓冲池,每次创建完对象实例之后可以把它们保存在缓冲池里,下次其他线程再需要使用的时候就可以直接从缓冲池里去取。

乍一看,这种方式是很合理的,但是这种模式在多线程应用程序里会出现问题。因为对象的缓冲池在多个线程之间共享,因此所有线程在访问其中的对象时的操作需要同步保护。而这种同步所带来的开销已经大过了创建对象本身了。当然了,创建过多的对象会加重垃圾回收的负担,但是即便把这个考虑在内,避免同步代码所带来的性能提升仍然要好过使用对象缓存池的方式。

本文所讲述的这些优化方案再一次的表明,每一种可能的优化方式在真正应用的时候一定需要多多仔细评测。不成熟的优化方案表面看起来好像很有道理,但是事实上很有可能会反过来成为性能的瓶颈。

11.4.3 锁分段

最典型的例子就是ConcurrentHashMap。

public class StripedMap {
    // Synchronization policy: buckets[n] guarded by locks[n%N_LOCKS]
    private static final int N_LOCKS = 16;
    private final Node[] buckets;
    private final Object[] locks;

    private static class Node {
        Node next;
        Object key;
        Object value;
    }

    public StripedMap(int numBuckets) {
        buckets = new Node[numBuckets];
        locks = new Object[N_LOCKS];
        for (int i = 0; i < N_LOCKS; i++)
            locks[i] = new Object();
    }

    private final int hash(Object key) {
        return Math.abs(key.hashCode() % buckets.length);
    }

    public Object get(Object key) {
        int hash = hash(key);
        synchronized (locks[hash % N_LOCKS]) {
            for (Node m = buckets[hash]; m != null; m = m.next)
                if (m.key.equals(key))
                    return m.value;
        }
        return null;
    }

    public void clear() {
        for (int i = 0; i < buckets.length; i++) {
            synchronized (locks[i % N_LOCKS]) {
                buckets[i] = null;
            }
        }
    }
}

happens-before原则(先行发生原则)

  • 传递规则:如果操作1在操作2前面,而操作2在操作3前面,则操作1肯定会在操作3前发生。该规则说明了happens-before原则具有传递性
  • 锁定规则:一个unlock操作肯定会在后面对同一个锁的lock操作前发生。这个很好理解,锁只有被释放了才会被再次获取
  • volatile变量规则:对一个被volatile修饰的写操作先发生于后面对该变量的读操作
  • 程序次序规则:一个线程内,按照代码顺序执行
  • 线程启动规则:Thread对象的start()方法先发生于此线程的其它动作
  • 线程终结原则:线程的终止检测后发生于线程中其它的所有操作
  • 线程中断规则: 对线程interrupt()方法的调用先发生于对该中断异常的获取
  • 对象终结规则:一个对象构造先于它的finalize发生

11.4.4 避免热点域hot field

比如HashMap的size方法,ConcurrentHashMap采用了牺牲size的准确性的策略。

volatile适用场景

volatile适用于不需要保证原子性,但却需要保证可见性的场景。一种典型的使用场景是用它修饰用于停止线程的状态标记。如下所示

boolean isRunning = false;

public void start () {
  new Thread( () -> {
    while(isRunning) {
      someOperation();
    }
  }).start();
}

public void stop () {
  isRunning = false;
}

在这种实现方式下,即使其它线程通过调用stop()方法将isRunning设置为false,循环也不一定会立即结束。可以通过volatile关键字,保证while循环及时得到isRunning最新的状态从而及时停止循环,结束线程。

11.4.5 一些替代独占锁的方法

ReadWriteLock,AtomicInteger,UNSAFE.compareAndSwap(..)

线程安全十万个为什么

问:平时项目中使用锁和synchronized比较多,而很少使用volatile,难道就没有保证可见性?
答:锁和synchronized即可以保证原子性,也可以保证可见性。都是通过保证同一时间只有一个线程执行目标代码段来实现的。

问:既然锁和synchronized即可保证原子性也可保证可见性,为何还需要volatile?
答:synchronized和锁需要通过操作系统来仲裁谁获得锁,开销比较高,而volatile开销小很多。因此在只需要保证可见性的条件下,使用volatile的性能要比使用锁和synchronized高得多。

问:既然锁和synchronized可以保证原子性,为什么还需要AtomicInteger这种的类来保证原子操作?
答:锁和synchronized需要通过操作系统来仲裁谁获得锁,开销比较高,而AtomicInteger是通过CPU级的CAS操作来保证原子性,开销比较小。所以使用AtomicInteger的目的还是为了提高性能。

问:还有没有别的办法保证线程安全
答:有。尽可能避免引起非线程安全的条件——共享变量。如果能从设计上避免共享变量的使用,即可避免非线程安全的发生,也就无须通过锁或者synchronized以及volatile解决原子性、可见性和顺序性的问题。

问:synchronized即可修饰非静态方式,也可修饰静态方法,还可修饰代码块,有何区别
答:synchronized修饰非静态同步方法时,锁住的是当前实例;synchronized修饰静态同步方法时,锁住的是该类的Class对象;synchronized修饰静态代码块时,锁住的是synchronized关键字后面括号内的对象。

11.4.6 监测CPU的利用率

vmstat,kill -3 pid

”waiting to lock monitor…“有这句就证明竞争太激烈了。

Java进阶系列

  • Java进阶系列(一)Annotation(注解)
  • Java进阶系列(二)当我们说线程安全时,到底在说什么

11.5 示例:比较Map的性能

比较了ConcurrentHashMap和synchronized hashmap的性能对比。

串行访问Map一个锁 pk 多个线程能并发的访问Map通过分段锁。

竞争非常激烈的时候,synchronized
hashmap伸缩性非常差,吞吐量不会随着线程数增加而增加,反而降低,因为每个操作消耗的时间大部分都用于上下文切换和调度延迟上了。

11.6 减少上下文切换的开销

举个例子,就是APP记录日志,例如写日志到本地或者远程RPC,直接记录会存在I/O阻塞,靠一个轻量级的queue来解耦,使得APP不感知影响,减少阻塞。

http://www.artima.com/insidejvm/ed2/threadsynch.html
//TODO

总结

了解了性能的提升的几个方面,也了解性能的开销后,应用程序就要根据实际的场景进行取舍和评估。没有一劳永逸的优化方案,不断的进行小范围改进和调整是提高性能的有效手段。当前一些大的架构调整也会导致较大的性能的提升。

性能提升考虑的方面:

  • 系统平台的资源利用率

一个程序对系统平台的资源利用率是指某一个设备繁忙且服务于此程序的时间占所有时间的比率。从物理学的角度讲类似于有用功的比率。简单的说就是:资源利用率=有效繁忙时间/总耗费时间。

也就说尽可能的让设备做有用的功,同时榨取其最大值。无用的循环可能会导致CPU
100%的使用率,但不一定是有效的工作。有效性通常难以衡量,通常只能以主观来评估,或者通过被优化的程序的行为来判断是否提高了有效性。

  • 延迟

延迟描述的是完成任务所耗费的时间。延迟有时候也成为响应时间。如果有多个并行的操作,那么延迟取决于耗费时间最大的任务。

  • 多处理

多处理是指在单一系统上同时执行多个进程或者多个程序的能力。多处理能力的好处是可以提高吞吐量。多处理可以有效利用多核CPU的资源。

  • 多线程

多线程描述的是同一个地址空间内同时执行多个线程的过程。这些线程都有不同的执行路径和不同的栈结构。我们说的并发性更多的是指针对线程。

  • 并发性

同时执行多个程序或者任务称之为并发。单程序内的多任务处理或者多程序间的多任务处理都认为是并发。

  • 吞吐量

吞吐量衡量系统在单位之间内可以完成的工作总量。对于硬件系统而言,吞吐量是物理介质的上限。在没有达到物理介质之前,提高系统的吞吐量也可以大幅度改进性能。同时吞吐量也是衡量性能的一个指标。

  • 瓶颈

程序运行过程中性能最差的地方。通常而言,串行的IO、磁盘IO、内存单元分配、网络IO等都可能造成瓶颈。某些使用太频繁的算法也有可能成为瓶颈。

  • 可扩展性

这里的可扩展性主要是指程序或系统通过增加可使用的资源而增加性能的能力。

发表评论

电子邮件地址不会被公开。 必填项已用*标注