【Java并发基础】安全性、活跃性与性能问题

软件发布|下载排行|最新软件

当前位置:首页IT学院IT技术

【Java并发基础】安全性、活跃性与性能问题

sakuraxx   2020-01-28 我要评论

前言

Java的多线程是一把双刃剑,使用好它可以使我们的程序更高效,但是出现并发问题时,我们的程序将会变得非常糟糕。并发编程中需要注意三方面的问题,分别是安全性、活跃性和性能问题。

安全性问题

我们经常说这个方法是线程安全的、这个类是线程安全的,那么到底该怎么理解线程安全呢?

要给线程安全性定一个非常明确的定义是比较复杂的。越正式的定义越复杂,也就越难理解。但是不管怎样,在线程安全性定义中,最核心的概念还是正确性,可以简单的理解为程序按照我们期望的执行。
正确性的含义是:某个类的行为与其规范完全一致。线程的安全性就可以理解为:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。

我们要想编写出线程安全的程序,就需要避免出现并发问题的三个主要源头:原子性问题、可见性问题和有序性问题。(前面的文章介绍了规避这三个问题的方法)当然也不是所有的代码都需要分析这三个问题,只有存在共享数据并且该数据会发生变化,即有多个线程会同时读写同一个数据时,我们才需要同步对共享变量的操作以保证线程安全性。

这也暗示了,如果不共享数据或者共享数据状态不发生变化,那么也可以保证线程安全性。

综上,我们可以总结出设计线程安全的程序可以从以下三个方面入手:

  1. 不在线程之间共享变量。
  2. 将共享变量设置为不可变的。
  3. 在访问共享变量时使用同步。

我们前面介绍过使用Java中主要的同步机制synchronized关键字来协同线程对变量的访问,synchronized提供的是一种独占的加锁方式。同步机制除了synchronized内置锁方案,还包括volatile类型变量,显式锁(Explicit Lock)以及原子变量。而基于一二点的技术方案有线程本地存储(Thread Local Storage, LTS)、不变模型等(后面会介绍)。

数据竞争

当多个线程同时访问一个数据,并且至少有一个线程会写这个数据时,如果我们不采用任何 同步机制协同这些线程对变量的访问,那么就会导致并发问题。这种情况我们叫做数据竞争(Data Race)。

例如下面的例子就会发生数据竞争。

public class Test {
    private long count = 0;
    void add10K() {
        int idx = 0;
        while(idx++ < 10000) {
            count += 1;
        }
    }
}

当多个线程调用add10K()时,就会发生数据竞争。但是我们下面使用synchronized同步机制就可以来防止数据竞争。

public class Test {
    private long count = 0;
    synchronized long get(){
        return count;
    }
    synchronized void set(long v){
        count = v;
    }
    void add10K() {
        int idx = 0;
        while(idx++ < 10000) {
            set(get()+1);      
        }
    }
}

竞态条件

但是此时的add10K()方法并不是线程安全的。
假设count=0, 当两个线程同时执行get()方法后,get()方法会返回相同的值0,两个线程执行get()+1操作,结果都是1,之后两个线程再将结果1写入了内存。本来期望的是2,但是结果却是1。(至于为什么会同时?我当初脑袋被“阻塞”好一会儿才反应过来,哈哈,╮(~▽~)╭,看来不能熬夜写博客。因为如果实参需要计算那么会先被计算,然后作为函数调用的参数传入。这里get()会先被调用,等其返回了才会调用set(),所以一个线程调用完了get()后,另一个线程可以马上获取锁调用get()。这也就会造成两个线程会得到相同的值。)

这种情况,我们称为竞态条件(Race Condition)。竞态条件,是指程序的执行结果依赖线程执行的顺序 。
上面的例子中,如果两个线程完全同时执行,那么结果是1;如果两个线程是前后执行,那么结果就是2。在并发环境里,线程的执行顺序是不确定的,如果程序存在竞态条件问题,那么就意味着程序执行的结果是不确定的,而执行结果不确定就是一个大问题。

我们前面讲并发bug源头时,也介绍过竞态条件。由于不恰当的执行时序而导致的不正确的结果。要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或者之后读取和修改状态,而不是在修改状态的过程中。

解决这个例子的竞态条件问题,我们可以介绍过的加锁机制来保证:其他线程只能在修改操作完成之前或者之后读取和修改状态,而不是在修改状态的过程中。

public class Test {
    private long count = 0;
    synchronized long get(){
        return count;
    }
    synchronized void set(long v){
        count = v;
    }
    void add10K() {
        int idx = 0;
        while(idx++ < 10000) {
            synchronized(this){
                set(get()+1);    
            }  
        }
    }
}

所以面对数据竞争和竞态条件我们可以使用加锁机制来保证线程的安全性!

活跃性问题

安全性的含义是“永远不发生糟糕的事情”,而活跃性则关注另外一个目标,即“某件正确的事情最终会发生”。 当某个操作无法继续执行下去时,就会发生活跃性问题。
在串行程序中,活跃性问题的形式之一便是无意中造成的无限循环。从而使循环之后的代码无法被执行。而线程将会带来其他的一些活跃性问题,例如我们前面所讲的死锁,以及我们下面将要介绍的饥饿和活锁。

饥饿

饥饿(Starvation)指的是线程无法访问到所需要的资源而无法执行下去的情况。

引发饥饿最常见的资源便是CPU时钟周期。如果Java应用程序中对线程的优先级使用不当,或者在持有锁时执行一些无法结束的结构(例如无限循环或者无限制地等待某个资源),那么也可能导致饥饿,因为其他需要这个锁的线程无法得到它。

通常,我们尽量不要改变线程的优先级,在大部分并发应用程序中,可以使用默认的线程优先级。只要改变了线程的优先级,程序的行为就将与平台相关,并且可能导致发生饥饿问题的风险(例如优先级高的线程会一直获取资源,而低优先级的线程则将一直无法获取到资源)。
当某个程序会在一些奇怪的地方调用Thread.sleepThread.yield,那是这个程序在试图克服优先级调整问题或响应性问题,并试图让低优先级的线程执行更多的时间。

饥饿问题的实质可以用孔子老人家说过的一句话来总结:不患寡而患不均。

解决饥饿问题,有以下三种方案:

  1. 保证资源充足。
  2. 公平地分配资源。
  3. 避免持有锁的线程长时间执行。

这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。所以,方案二的适用场景会多一点。在并发编程里,我们可以使用公平锁来公平的分配资源。所谓公平锁,是一种FIFO方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。

活锁

活锁(Livelock)是另一种形式的活跃性问题,它和死锁很相似,但是它却不会阻塞线程。活锁尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复执行相同的操作,而且总会失败。

活锁通常发生在处理事务消息的应用程序中:如何不能成功地处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放置到队列的开头。如果消息处理器在处理某种特定的消息时存在错误并导致它失败,那么每当这个消息从队列中取出并传递到存在错误的处理器时,都会发生事务回滚。由于这个消息又被放到队列开头,因此处理器将被反复调用,并返回相同的处理结果。(有时候也被称为毒药消息,Poison Message。)虽然处理消息的线程没有被阻塞,但也无法执行下去。这种形式的活锁,通常由过度的错误恢复代码造成,因为它错误地将不可修复的错误作为可修复的错误。

当多个相互协作的线程都对彼此进行响应从而修改各自的状态,并使得任何一个线程都无法继续执行时,就发生了活锁。 这就好比两个过于礼貌的人在半路上相遇,为了不相撞,他们彼此都给对方让路,结果导致他们又相撞。他们如此反复下一,便造成了活锁问题。

解决这种活锁问题,我们在重试机制中引入随机性。即,让他们在谦让时尝试等待一个随机的时间。如此,他们便不会相撞而顺序通行。我们在以太网协议的二进制指数退避算法中,也可以看到引入随机性降低冲突和反复失败的好处。在并发应用程序中,通过等待随机长度的时间和回退可以有效避免活锁的发生。

性能问题

与活跃性问题密切相关的是性能问题。活跃性意味着某件正确的事情最终会发生,但却不够好,因为我们通常希望正确事情尽快发生。性能问题包括多个方面,例如服务时间过长,响应不灵敏,吞吐量过低,资源消耗过高,或者可伸缩性降低等。与活跃性和安全性一样,在多线程程序中不仅存在与单线程程序相同的性能问题,而且还存在由于实现线程而引入的其他性能问题。

我们使用多线程的目的是提升程序的整体性能,但是与单线程的方法相比,使用多个线程总会引入一些额外的性能开销。造成这些开销的操作包括:线程之间的协调(如加锁、内存同步等),增加上下文切换,线程的创建和销毁,以及线程的调度等。如果我们多度地使用线程,那么这些开销可能超过由于提高吞吐量、响应性或者计算能力所带来的性能提升。另一方面,一个并发设计很糟糕的程序,其性能甚至比完成相同功能的串行程序性能还要低。
想要通过并发来获得更好的性能就需要做到:更有效地利用现有处理资源,以及在出现新的处理资源时使程序尽可能地利用这些新资源。

下面我们将介绍如何评估性能、分析多线程带来的额外开销以及如何减少这些开销。

性能和可伸缩性

应用程序的性能可以采用多个指标来衡量,例如服务时间、延迟时间、吞吐量、效率、可伸缩性以及容量等。其中一些指标(服务时间、等待时间)用于衡量程序的“运行速度”,即某个指定的任务单元需要“多快”才能处理完成。另一些指标(生产量、吞吐量)用于程序的“处理能力”,即在计算资源一定的情况下,能完成“多少”工作。

可伸缩性指的是:当增加计算资源(例如CPU、内存、存储容量或者I/O带宽)时,程序的吞吐量或者处理能力相应地增加。在对可伸缩性调优时,目的是将设法将问题的计算并行化,从而能够利用更多的计算资源来完成更多的任务。而我们传统的对性能调优,目的是用更小的代价完成相同的工作,例如通过缓存来重用之前的计算结果。

Amdahl定律

大多数的并发程序都是由一系列的并行工作和串行工作组成。
Amdahl定律描述的是:在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决于程序中可并行组件与串行组件所占比重。简单点说,Amdahl定律代表了处理器并行运算之后效率提升的能力。
假定F是必须被串行执行的部分,那么根据Amdahl定律,在包含N个处理器的机器中,最高加速比为:

\[Speedup <= \frac{1}{F+\frac{(1-F)}{N}}\]

当N趋近于无穷大时,最高加速比趋近于\(\frac{1}{F}\) 。因此,如果程序有50%的计算需要串行执行,那么最高加速比只能是2,而不管有多个线程可用。无论我们采用什么技术,最高也就只能提升2倍的性能。
Amdahl定律量化了串行化的效率开销。在拥有10个处理器的系统中,如果程序中有10%的部分需要串行执行,那么最高加速比为5.3(53%的使用率),在拥有100个处理器的系统中,加速比可以达到9.2(92%的使用率)。但是拥有无限多的处理器,加速比也不会到达10。

如果能准确估计出执行过程中穿行部分所占的比例,那么Amdahl定律就可以量化当有更多计算资源可用时的加速比。

线程引入的开销

在多个线程的调度和协调过程中都需要一定的性能开销。所以我们要保证,并行带来的性能提升必须超过并发导致的开销,不然这就是一个失败的并发设计。下面介绍并发带来的开销。

上下文切换

如果主线程是唯一的线程,那么它基本上不会被调度出去。如果可运行的线程数目大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU。这将导致一次上下文切换,在这个过程中,将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。

切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和JVM共享的数据结构。上下文切换的开销不止包含JVM和操作系统的开销。当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失(丢失局部性),因而线程在首次调度运行时会更加缓慢。
调度器会为每个可运行的线程分配一个最小执行时间,即使有许多其他的线程正在等待执行:这是为了将上下文切换的开销分摊到更多不会中断的执行时间上,从而提高整体的吞吐量(以损失响应性为代价)。

当线程被频繁的阻塞时,也可能会导致上下文切换,从而增加调度开销,降低吞吐量。因为,当线程由于没有竞争到锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被交换出去。

上下文切换的实际开销会随着平台的不同而变化,按照经验来看:在大多数通用的处理器上,上下文切换的开销相当于5000~10000个时钟周期,也就是几微秒。

内存同步

同步操作的性能开销包括多个方面。在synchronized和volatile提供的可见性保证中可能会使用一些特殊指令,即内存栅栏(也就是我们前面文章介绍过的内存屏障)。内存栅栏可以刷新缓存,使缓存无效,刷新硬件的写缓冲,以及停止执行管道。内存栅栏可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。在内存栅栏中,大多数的操作都是不能被重排序的。

在评估同步操作带来的性能影响时,需要区分有竞争的同步和无竞争的同步。现代的JVM可以优化一些不会发生竞争的锁,从而减少不必要的同步开销。

synchronized(new Object()){...}

JVM会通过逃逸分析优化掉以上的加锁。
所以,我们应该将优化重点放在那些发生锁竞争的地方。

某个线程的同步可能会影响其他线程的性能。同步会增加共享内存总线上的通信量,总线的带宽是有限的,并且所有的处理器都将共享这条总线。如果有多个线程竞争同步带宽,那么所有使用了同步的线程都会受到影响。

阻塞

非竞争的同步可以完全在JVM中处理,而竞争的同步可能需要操作系统的介入,从而增加系统的开销。在锁上发生竞争时,竞争失败的线程会被阻塞。JVM在实现阻塞行为时,可以采用自旋等待(Spin-Waitiin,指通过循环不断地尝试获取锁,直到成功)或者通过操作系统挂起被阻塞的线程。这两种方式的效率高低,取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。如果等待时间短,就采用自旋等待方式;如果等待时间长,则适合采用线程挂起的方式。JVM会分析历史等待时间做选择,不过,大多数JVM在等待锁时都只是将线程挂起。

线程被阻塞挂起时,会包含两次的上下文切换,以及所有必要的操作系统操作和缓存操作。

减少锁的竞争

串行操作会降低可伸缩性,并且上下文切换也会降低性能。当在锁上发生竞争时会同时导致这两种问题,因此减少锁的竞争能够提高性能和可伸缩性。
在对某个独占锁保护的资源进行访问时,将采用串行方式——每次只有一个线程能访问它。如果在锁上发生竞争,那么将限制代码的可伸缩性。
在并发程序中,对可伸缩性的最主要的威胁就是独占方式的资源锁。

有两个因素将影响在锁上发生竞争的可能性:锁的请求频率和每次持有该锁的时间。(Little定律)
如果二者的乘积很小,那么大多数获取锁的操作都不会发生竞争,因此在该锁上的竞争不会对可伸缩性造成严重影响。

下面介绍降低锁的竞争程度的方案。

缩小锁的范围

降低发生竞争的可能性的一种有效方式就是尽可能缩短锁的持有时间。例如,可以将一些与锁无关的代码移除代码块,尤其是那些开销较大的操作,以及可能被阻塞的操作(I/O操作)。
尽管缩小同步代码块能提高可伸缩性,但同步代码块也不能太小,因为会有一些复合操作需要以原子操作的方式进行,这时就必须在同一同步块中。

减小锁的粒度

另一种减少锁的持有时间的方式便是降低线程请求锁的频率(从而减小发生竞争的可能性)。这可以通过锁分解和锁分段等技术来实现,这些技术中将采用多个相互独立的锁来保护相互独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。这些技术能缩小锁操作的粒度,并能实现更高的可伸缩性。但是需要注意,使用的锁越多,也就越容易发生死锁。

锁分解

如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。

例如,如下的程序我们便可以进行锁分解。(例子来自《Java并发编程实践》)

@ThreadSafe   // 该注解表示该类是线程安全的
public class ServerStatus {
    // @GuardedBy(xxx)表示该状态变量是由xxx锁保护
    @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);
    }
}

以上程序表示的是某个数据库服务器的部分监视接口,该数据库维护了当前已经登录的用户以及正在执行的请求。当一个用户登录、注销、开始查询或者结束查询时,都会调用相应的add或者remove方法来更新ServerStatus对象。这两种类型信息是完全独立的,因此,我们可以尝试用锁分解来提升该程序的性能。

@ThreadSafe
public class ServerStatus{
    @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);
        }
    }
}

我们将原来的ServerStatus分解,使用新的细粒度锁来同步对状态变量的维护。减少了锁的竞争,提升了性能。

锁分段

把一个竞争激烈的锁分解为两个锁时,这两个锁可能都存在激烈的竞争。在上面的锁分解例子中,并不能进一步对锁进行分解。

在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。

例如,ConcurrentHashMap的实现中使用了一个包含16个锁的数组,每个锁保护所有散列桶的\(\frac{1}{16}\) ,其中第N个散列桶由第(N mod 16)个锁来保护。
假设散列函数具有合理的分布性,并且关键字能够实现均匀分布,那么这大约能把对于锁的请求减少到原来的\(\frac{1}{16}\) 。正是因为这项技术,使用ConcurrentHashMap可以支持多大16个并发的写入器。

锁分段的一个劣势在于:需要获取多个锁来实现独占访问将更加困难且开销更高。例如当ConcurrentHashMap需要扩展映射范围,以及重新计算键值的散列值需要分不到更大的桶集合中时,就需要获取所有分段锁。

下面的代码展示了在基于散列的Map中使用锁分段的技术。它拥有N_LOCKS个锁,并且每个锁保护散列桶的一个子集。大多数方法都只需要获得一个锁,如get(),而有些方法则需要获取到所有的锁,但不要求同时获得,如clear()。(例子来自《Java并发编程实践》)

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

一些代替独占锁的方法

除了缩小锁的范围、减少请求锁的粒度,还有第三种降低锁的影响的技术就是放弃使用独占锁。
使用一些无锁的算法或者数据结构来管理共享状态。例如,使用并发容器、读-写锁、不可变对象以及原子变量。

后面也会陆续介绍这些方案。

小结

结合我们前面讲的并发知识,我们现在可以从微观和宏观来理解并发编程。在微观上,设计并发程序时我们要考虑到原子性、可见性和有序性问题。跳出微观,从宏观上来看,我们设计程序,要考虑到到线程的安全性、活跃性以及性能问题。我们在做性能优化的前提是要保证线程安全性,如果会优化后出现并发问题,那么结果将会与我们的预期背道而驰。

参考:
[1]极客时间专栏王宝令《Java并发编程实战》
[2]Brian Goetz.Tim Peierls. et al.Java并发编程实战[M].北京:机械工业出版社,2016

Copyright 2022 版权所有 软件发布 访问手机版

声明:所有软件和文章来自软件开发商或者作者 如有异议 请与本站联系 联系我们