java并发基础--- 活跃性性能与可伸缩性

Posted Stay hungry,stay foolish.

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了java并发基础--- 活跃性性能与可伸缩性相关的知识,希望对你有一定的参考价值。

  《java并发编程实战》的第9章主要介绍GUI编程,在实际开发中实在很少见到,所以这一章的笔记暂时先放一放,从第10章开始到第12章是第三部分,也就是活跃性、性能、与测试,这部分的知识偏理论多一些,但是尽量能用代码讲明白的问题就不用文字,话不多说,进入正题。

一、死锁

  在学习java基础的时候就听老师讲过“哲学家就餐”的例子,时间久了具体是怎么回事也容易忘,这里重新整理下。5个哲学家去吃中餐,坐在一张圆桌旁,他们有5根筷子(不是5双),并且每两个人中间放一根筷子,每个人需要一双筷子才能吃到东西,如果每个人都立即抓住自己左边的筷子,并等待自己右边的筷子空出来,同时又不肯放弃自己已经拿到的筷子,那么每个人都会饿死。在java中就是,线程A持有锁L并想获得锁M的同时,线程B持有锁M并尝试获得锁L,那么会产生死锁,总结一下,也就是多个线程由于存在环路的锁依赖关系而永远等待下去,所以会产生死锁,而java程序是无法从死锁中恢复过来的,所以开发时要尤其注意。

//容易发生死锁
public class LeftRightDeadLock{
    private final Object left = new Object();
    private final Object right = new Object();
    
    public void leftRight(){
        synchronized(left){
            synchronized (right) {
                System.out.println("left-right");
            }
        }
    }
    
    public void rightLeft(){
        synchronized (right) {
            synchronized (left) {
                System.out.println("right-left");
            }
        }
    }
}

  上面这种形式是最简单的死锁,叫锁顺序死锁,它发生死锁的原因是:两个线程试图以不同顺序访问相同的锁。反过来,也就是说,如果所有线程以固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁。但是在实际开发中几乎不可能实现。

  有些时候死锁并不那么容易被发现,如下面代码,它将资金从一个账户转入另一个账户,在开始转账之前,首先要获得这两个Account对象的锁,以确保通过原子方式来更新两个账户中的余额,看上去没什么毛病,但是如果有两个线程同时调用transferMoney,其中一个线程从X向Y转账,另一个线程从Y向X转账,那么就可能发生死锁。

//A线程:transferMoney(myAccount,yourAccount,10);
//B线程:transferMoney(yourAccount,myAccount,20);
public void transferMoney(Account fromAccount,
                            Account toAccount,
                            Amount amount) throws InsufficientFundsException{
    synchronized (fromAccount) {
        synchronized (toAccount) {
            if (fromAccount.getBalance().compareTo(account) < 0) {//账户余额不能为0
                throw new InsufficientFundsException();
            }else {
                fromAccount.debit(amount);
                toAccount.credit(amount);
            }
        }
    }
}

前面提到过,如果能让线程访问锁的顺序一致,可以避免锁顺序死锁,可以通过两个账户的hashCode判断,修改代码:

private static final Object tieLock = new Object();

public void transferMoney(Account fromAccount,
                            Account toAccount,
                            Amount amount) throws InsufficientFundsException{
    class Helper{
        public void transfer() throws InsufficientFundsException{
            if (fromAccount.getBalance().compareTo(account) < 0) {//账户余额不能为0
                throw new InsufficientFundsException();
            }else {
                fromAccount.debit(amount);
                toAccount.credit(amount);
            }
        }
    }
    
    int fromHash = System.identityHashCode(fromAccount);
    int toHash = System.identityHashCode(toAccount);
    
    if (fromHash < toHash) {
        synchronized (fromAccount) {
            synchronized (toAccount) {
                new Helper().transfer();
            }
        }
    }else if (fromHash > toHash) {
        synchronized (toAccount) {
            synchronized (fromAccount) {
                new Helper().transfer();
            }
        }
    }else {
        synchronized (tieLock) {
            synchronized (fromAccount) {
                synchronized (toAccount) {
                    new Helper().transfer();
                }
            }
        }
    }
}

  这样就避免了上面提到的两个账户互相转账的问题,两个对象的hashCode如果相同,则又添加了一个锁,保证每次只有一个线程以未知顺序访问锁。你可能会问,为什么不直接在最外面再加一层锁呢,把hashCode的判断直接省去,肯定不能这样做,如果这样,那么转账就成了串行的了,这样毫无并发可言,好在System.identityHashCode发生哈希码相同的时候非常少。增加了代码,至少解决了问题,这样,这个方法至少能用了。

  在大型网站中每天可能要执行数十亿次获取锁和释放锁的操作,只要有一次发生错误产生死锁,程序都会蹦掉,而且,有时候应用程序即使通过了压力测试也不可能找到所有潜在的死锁。很多潜在的死锁比上面的例子更加隐晦,这里就不写例子了,那么有什么最好的解决办法让我们尽量的避免锁顺序死锁呢,答案是在程序中始终使用开放调用

  什么是开放调用,我自己的理解是,在方法中调用某个外部方法时,没有持有任何锁,也就是说,锁用完了,赶紧释放掉。这也就是为什么不提倡直接用synchronized直接修饰方法的最重要的原因。我想获得哪个锁,我获得,用完了,赶紧释放掉,哪怕过几行代码我还要用到这个锁,我也不会多占用这个锁一秒钟。这个只能说是一个良好的编程习惯,但它可以尽量规避锁顺序死锁。

 二、性能与可伸缩性

  1、系统为什么要做成分布式

  程序的性能由多个指标来衡量,比如服务时间、延迟时间、吞吐率、效率、可伸缩性以及容量等。服务时间和等待时间指某个任务需要多快才能完成,吞吐量指在计算资源一定的情况下,能完成多少工作。可伸缩性指的是:当增加计算资源时(例如CPU、内存、存储容量或I/O带宽),程序的吞吐量或者处理能力相应地增加。

  性能的多快和多少是完全独立的,有时是互相矛盾的,我们熟悉的mvc三层结构是彼此独立的,并且可能由不同系统处理,这个例子很好的说明了提高可伸缩性通常会造成性能损失的原因,如果三层在单个系统中,处理第一个请求的时候其性能肯定高于将程序分成多层并将不同层次分布到多个系统时的性能。(LZ在面试时曾被问到具体说说是哪里带来了延迟,LZ当时只回答是网络通信,系统调用、数据复制之间的延迟,结果面试官很不满意,一再追问,大家可以自行百度,当做一个面试题记下来,预防面试官为难。)然而,当这种单一系统到达自身处理能力的极限时,会遇到一个很严重的问题:要进一步处理大量请求会非常难,因此,通常会接受每个请求执行更长时间或者消耗更多资源来换取更高的负载,这也就是为什么系统分布式的原因。

  2、线程带来的问题

  (1)上下文切换

  如果可运行的线程数大于CPU数量,那么OS会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU,这将导致一次上下文切换,也就带来一些开销。

  (2)锁竞争

  当线程由于等待某个锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被交换出去,这也可能导致上下文切换。

  3、减少锁竞争

  通过上面介绍,串行操作降低可伸缩性,上下文切换降低性能,锁竞争会同时导致这两个问题,因此减少锁竞争能提高性能和可伸缩性。在并发程序中,对可伸缩性最主要威胁就是独占方式的资源锁。那怎么做呢,上面提到过了,开放调用,用完锁,赶紧释放,还有就是减少锁的粒度:锁分解和锁分段。这块内容LZ打算单独写一份笔记。

  这块内容理论的东西很多,很多东西很难展开介绍,先看下这几个点吧,其实所有的知识点都指向如何避免锁竞争这个事情上,开放调用是一个办法,但是作用没有锁分段大。LZ也是刚开始用博客记录一些新知识,可能很多时候写东西写不到重点,我自己也尽量避免这个问题,慢慢来吧,很羡慕面对新知识能一下子抓住重点的大神,LZ只是一个想往上再走一步的菜鸟,各位多提意见,感谢大家。

以上是关于java并发基础--- 活跃性性能与可伸缩性的主要内容,如果未能解决你的问题,请参考以下文章

Android技术书4

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

第十二章:并发程序的测试——Java并发编程实战

java并发编程实战:第十二章---并发程序的测试

java并发编程性能与可伸缩性

深入了解Java并发——《Java Concurrency in Practice》11.性能与可伸缩性