Java高并发编程:性能与线程竞争

线程的最主要目的是提高程序的运行性能,线程可以使程序更加充分地发挥系统地可处理能力,从而提高系统的资源利用率。此外,线程还可以使程序在运行现有的任务的情况下即开始处理新的任务。但性能的提升会导致复杂性的提升,又会导致安全性和活跃性的风险

1.对性能的思考

  • 提升性能意味着用更少的资源做更多地事情。要想通过并发来获得更好的性能,就要更有效地利用现有处理资源
  • 线程使用的额外的性能开销:线程之间的协调(例如加锁、触发信号以及内存同步等),增加的上下文切换,线程的创建和销毁,以及线程的调度等

1.1 性能与可伸缩性(多块vs多少)

性能通过服务时间、延迟时间、吞吐率、效率、可伸缩性以及容量等衡量,即某个任务需要“多块”才能完成。还有一个指标用于程序的“处理能力”,即计算资源一定的情况下,能完成“多少”工作。

当进行性能调优时,其目的通常是用更小的代价完成相同的工作

可伸缩性指的是:当增加计算资源时(例如CPU、内存、存储容量或I/O带宽),程序的吞吐量或者处理能力响应地增加

在进行可伸缩性调优时,其目的是设法将问题的计算并行化,从而能利用更多地计算资源来完成更多的工作。

我们通常会接受每个工作单元执行更长的时间或消耗更多的计算资源,以换取应用程序在增加更多资源的情况下处理更高的负载。(多少更重要)

1.2 评估各种权衡因素

如果要实现一个高效地算法,那么需要知道被处理的数据集大小,还有衡量优化的指标,包括:平均计算时间、最差时间、可预知性。

避免不成熟的优化。首先要使程序正确,然后再提高速度————如果它还运行的不够快。

2. Amdahl定律

Amdahl定律描述的是:在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决于程序中可并行组件与串行组件所占的比重。

Speedup <= 1 / (F + (1 - F) / N)

------- F是必须被串行执行的部分所占比例,N是机器中含有处理器的个数

当N趋近无穷大时,最大的加速比趋近于1/F

如下图,串行比例越高的程序到达瓶颈需要的处理器数越少,瓶颈的处理器利用率越低

在这里插入图片描述

在所有并发程序中都存在串行部分(例:存储结果的共享容器,从共享队列中取出任务)

2.1 框架中隐藏着串行部分

通过比较当增加线程时吞吐量的变化,推断出框架中串行部分所占比例

在这里插入图片描述

  • synchronizedLinkedList有更高的串行比例,更容易到达瓶颈,最高加速比更低
  • 到达瓶颈后小幅的下降表示增多线程时加速比的提高已经小于由于线程切换带来性能的损失

2.2 Amdahl定律的应用

串行执行比例 => 最大加速比 => 达到最大加速比的线程数量

3. 线程引入的开销

多个线程的调度和协调过程总都需要一定的性能开销:对于为了提升性能而引入的线程来说,并行带来的性能提升必须超过并发导致的开销

3.1 上下文切换

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

  • 切换上下文需要一定的开销,而在线程调度过程中需要访问操作系统和JVM共享的数据结构
  • 上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢

当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并在允许它被交换出去。如果线程频繁地发生阻塞,那么他将无法使用完整的调度时间片。在程序中发生越多的阻塞,与CPU密集型的程序就会发生越多的上下文切换,从而增加调度开销,并且因此降低吞吐量。

3.2 内存同步

内存栅栏:在synchronized和volatile提供的可见性保证中可能会使用一些特殊指令,即内存栅栏(Memory Barrier)。内存栅栏可以刷新缓存,使缓存无效,刷新硬件的写缓冲,以及停止执行管道。内存栅栏同样会对性能带来间接的影响,因为它将抑制一些编译器优化操作。比如在内存栅栏中,大多数操作是不可以重排序。

解决方案:

  1. JVM优化去掉不会发生竞争的锁
  2. 找出不需同步的本地栈元素
  3. 锁粒度粗化,将近邻的锁合并,减少锁请求和锁释放的次数

3.3 阻塞

竞争的同步可能需要操作系统地介入,从而增加开销。当在锁上面发生竞争,竞争失败的线程肯定会阻塞。

  • JVM发现阻塞行为的时候,可以采用自旋等待(Spin-Waiting:通过不断循环地尝试获取锁,直到成功)或者通过操作系统挂起被阻塞的线程。
  • 当线程无法获取某个锁或者由于某个条件等待或在I/O操作上阻塞,需要被挂起,在这个过程中需要两次额外的上下文切换的开销,以及所有必要的操作和缓存操作:被阻塞的线程在其他执行时间片还没有用完之前就被交换出去,而在随后当要获取的锁或者其他资源可以用时间,又被切换出来。

4. 减少锁竞争的开销

从上面我们可以看出,大部分线程的开销都是由于线程之间竞争造成的。比如线程在锁上面发生竞争线程就会阻塞;为了防止线程竞争共享资源,我们就使用synchronized这样就增加了内存同步的开销;大量线程竞争同步共享资源,造成线程之间频繁进行上下文切换的开销。

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

有两个因素会在锁上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间。

有三种方式可以降低锁竞争的开销:

  1. 减少锁的持有时间
  2. 降低锁的请求频率
  3. 使用带有协调机制的独占锁,这些机制允许更高的并发性

4.1 缩小锁的范围(“快进快出”)

目标:尽可能缩短持有锁的时间

方法:超出共享变量,只对操作共享变量的代码加锁

理论:根据Amdahl定律,减少了必须串行执行的部分

注意:必要的原子操作不能分别加锁;锁粒度细化造成更多的同步开销,JVM会自动进行锁粒度粗化

public class AttributeStore {
    private final Map<String,String> attributes =new HashMap<>();

    public synchronized boolean userLocationMatches(String name,String regexp){
        String key="users."+name+".location";
        String location=attributes.get(key);
        if(location==null){
            return false;
        }else
            return Pattern.matches(regexp,location);
    }
}

public class BetterAttributeStore {
    private final Map<String,String> attributes =new HashMap<>();

    public boolean userLocationMatches(String name,String regexp){
        String key="users."+name+".location";
        String location=null;
        synchronized (attributes) {
            location = attributes.get(key);
        }
        if(location==null){
            return false;
        }else
            return Pattern.matches(regexp,location);
    }
}

4.2 减少锁粒度——是不同组对象持有不同的锁

另一种减少锁的持有时间的方式就是降低线程请求锁的频率(从而减少锁发生竞争的可能性)。这里我们可以通过锁分解技术和锁分段技术来实现。

锁分解技术

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

对锁分解后每个新的细粒度锁上的访问将减少,分摊到两个锁上

对竞争适中的锁进行分解时,实际上是把这些锁转变为”非竞争“的锁,从而有效地提高性能和可伸缩性。

public class ServerStatusAfterSplit {
    public final Set<String> users;
    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);
        }
    }
}

锁分段技术

对竞争激烈的锁进行分解时,两个锁可能竞争仍很激励,性能提高不明显。在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况称为锁分段技术。

例:在ConcurrentHashMap的实现中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第(N mod 16)个锁来保护。

挑战:有些操作需要独占整个对象,即需要全部的锁,这样开销更大。但有些操作即使需要获得全部的锁,但也不需要同时获得。

public class StripedMap {
    private static final int N_LOCKs=16;
    private final Node[] buckets;
    private final Object[] locks;
    
    public StripedMap(int numBuckets) {
        this.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);
        Object lock = locks[hash % N_LOCKs];
        synchronized (lock){
            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++) {
            Object lock = locks[i];
            synchronized (lock){
                buckets[i]=null;
            }
        }
    }

    private static class Node{
        private Object key;
        private Object value;
        public Node next;
        
        public Node() {
        }

        public Node(Object key, Object value) {
            this.key = key;
            this.value = value;
        }

        public Object getKey() {
            return key;
        }

        public void setKey(Object key) {
            this.key = key;
        }

        public Object getValue() {
            return value;
        }

        public void setValue(Object value) {
            this.value = value;
        }
        
        @Override
        public String toString() {
            return "Node{" +
                    "key=" + key +
                    ", value=" + value +
                    '}';
        }
    }
}

避免热点域

如果程序采用锁分段或分解技术,那么一定要表现出在锁上的竞争频率高于在锁保护的数据上发生竞争的频率(例:ConcurrentHashMap和Map中的每一項)

热点域:数据上发生很高频率的竞争(例:HashMap.size())

解决:ConcurrentHashMap为每个分段都维护一个独立的size计数,并通过每个分段的锁来维护总size

4.3 代替独占锁的方法

ReadWriteLock:如果多个读取操作都不会修改共享资源,那么这些读取操作可以同时访问该共享资源,但在执行写入操作时必须以独占方式来获取锁

原子变量:降低更新“热点域”时的开销,例如竞态计数器、序列发生器、或者对链表数据结构中头节点的引用。原子变量类提供了在整数或者对象引用上的细粒度原子操作(因此可伸缩性更高),并使用了现代处理器中提供的底层并发原语(例如比较并交换)

5. 监控CPU利用率

linux命令:vmstat或mpstat

cpu利用不充分的原因:

  • 负载不充足。
  • I/O密集。*nix可用iostat, windows用perfmon。
  • 外部限制。如数据库服务,web服务等。
  • 锁竞争。可通过jstack等查看栈信息。

如果CPU的利用率很高,并且总会有可运行的线程在等待CPU,那么当增加更多地处理器时,程序的性能可能会得到提升。

6. 对对象池说"不"

当线程分配新的对象时,基本上不需要在线程之间进行协调,因为对象分配器通常会使用线程本地的内存块,所以不需要在堆数据结构上进行同步。然而,如果这些线程从对象池中请求一个对象,那么就需要通过某种同步来协调对象池数据结构的访问,从而使某个线程被阻塞。

对象分配操作的开销比同步的开销更低。

7. 减少上下文切换的开销

减少锁的持有时间,因为持有时间越长,就越容易发生竞争,就月容易发生阻塞。当任务在运行和阻塞这两个状态之间转换时,就相当于一次上下文切换。

8. 总结

由于使用线程是为了充分利用多个处理器的计算能力,因此在并发程序性能的讨论中,通常更多地将侧重点放在吞吐量和可伸缩性上,而不是服务时间,Amdahl定律告诉我们,程序的可伸缩性取决于在所有代码中必须可串行执行的代码比例。因为Java程序中串行操作的主要来源是独占式的资源锁,因此通常可以通过以下方式来提高伸缩性:减少锁的持有时间、降低锁的粒度,以及采用非独占锁或非阻塞锁来代替独占锁。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值