巧用阻塞类设计高效缓存系统

阻塞(blocks)对于初学者来说可能有些太陌生,但是只要接触过java并发的就肯定接触过阻塞。如果我们对某个方法使用锁,我们就是在运用阻塞。如果线程1持有了锁a,那么直到线程1释放锁a前,线程2一直在等待锁,其一直都处在阻塞状态。这就是阻塞(现象),而锁就是阻塞类。在java并发中,阻塞机制是十分有必要的。


阻塞是线程间通信的重要机制,它用来控制线程间的执行顺序。而java中用各种阻塞类来实现阻塞机制,在java中凡是能让用来控制线间程执行顺序的类,就是阻塞类。比如ReentantLock、Latch、FutureTask、Semaphores等,也包含各种并发容器类,如ConcurrentHashMap、CopyOnWriteList等。因为线程在调用它们的某些方法后,就可能进入阻塞状态。比如线程1调用ReentantLock.lock方法,当有其它线程已经调用了同一个ReentantLock对象的lock方法,那么直到其调用unklock方法前,线程1一直处在阻塞状态;比如线程1调用ConcurrentHashMap的get方法时,如果有其它线程已经调用了此同一个ConcurrentHashMap对象的get方法并且没有获得返回值前,线程1就可能处在阻塞状态。注意这里是“可能”不是一定,因为ConcurrentHashMap使用的是分段锁,具体可以google一下。
阻塞机制是十分有必要的。生产者-消费者模式就是阻塞最常见的一种应用,假设有n个厨师生产面包,m个客人吃面包,每个厨师面包的生产速度是随机的,客人吃完一个面包的速度也是随机的。在java中BlockingQueue可以很好的实现这种需求,当我们构建一个容量有限的BlockingQueue时候,如果queue中的面包数量为0,那么客人线程就会被阻塞;而如果queue中的面包达到其最大容量的时候厨师线程就会被阻塞。本文我们将设计一个应用java阻塞类实现的高效缓存系统,来加深对阻塞的理解。


多阶段设计线程安全的缓存系统
公司有一个“time-honored”的类叫ExpensiveCompution,其通过一个方法可以传入一个字符串,返回一个段数字,其代码如下:

public class ExpensiveCompution {

    public Long compute(String string) {
        Long along = new Long(0);
        /**
         * 利用string计算出一个长整型的数复制给along
         * 方法比较耗时间  
         */
        return  along;
    }
}

现在公司要求你设计个缓存系统来改善下性能,要求缓存系统线程安全。


第一版

public class Cache1 {
    ExpensiveCompution computer;
    private Map<String,Long> map = new HashMap<String,Long>();

    public Cache1(ExpensiveCompution c) {
        this.computer = c;
    }

    public synchronized Long compute(String string) {
        Long aLong = map.get(string);
        if (aLong == null) {
            aLong = computer.compute(string);
            map.put(string, aLong);
        }

        return aLong;
    }
}

大家都可以不加思索的设计出这这个版本,但是这个版本在并发效率上是非常低的,在多线程环境下,有时候Cache1类反而可能成为累赘。具体如下图所示:
[图片上传失败...(image-38d296-1510909885377)]
当有线程1,线程2,线程3分别同时执行计算字符串"1"、"2"、"3"返回的值时,因为Cache1为了保证线程安全性,其用了synchrnozied关键字。这使得同一时间只能由一个线程调用Cache1.compute方法,如果把cache不能命中时Cache1.compute方法的执行时间设为一个单位时间。那么三个线程平均用时为2个单位时间((1+2+3)/3 = 2),Cache1缓存的引用在某些情况下甚至起到了负作用,因为即使不用缓存直接使用ExpensiveCompution.compute方法,其线程的平均用时也只有一个单位时间。这肯定需要改善。


第二版
分析第一版,之所以会在某些情况下让线程平均等待时间更长,是因为Cache1.compute方法把耗时很长的ExpensiveCompute.compution方法放在锁的里面,错误的锁的范围扩大了。启发下设计Cache2类如下:

public class Cache2 {
    ExpensiveCompution computer;
    private Map<String,Long> map = new HashMap<String,Long>();

    public Cache2(ExpensiveCompution c) {
        this.computer = c;
    }

    public Long compute(String string) {
        Long aLong;
        synchronized(this){                    //1
           aLong = map.get(string);            //1
        }                                      //1
        if (aLong == null) {
            aLong = computer.compute(string);
            synchronized (this) {              //2
                map.put(string, aLong);        //2
            }                                  //2
        }

        return aLong;
    }
}

这样如果有线程1、线程2、线程3同时调用Cache2.compute方法分别计算"1"、"2"、"3"对应的返回值时会有如下情况:
[图片上传失败...(image-8dc79a-1510909885377)]
图中的"//1","//2"分别对应CaChe2类中对应的"//1","//2"部分。线程2、线程3也会阻塞,但其时间不会像用Cache1时那么长,如果假设"//1"处代码的执行时间为0.1个单位时间,那么三个线程的平均用时为1.1个单位时间((1+1.1+1.2)/3=1.1)。实时上代码"//1"处的用时远远不能达到0.1个单位时间这么长,三个线程的平均用时随着ExpensiveCompution.compute的执行时间和"//1"的执行时间的比值的增大而减少,即平均用时将近1个单位时间,这就是非常大的进步。但是其代码还有如下图的缺点存在:
[图片上传失败...(image-786e53-1510909885377)]
此图中的两个线程都都请求计算字符串"1"的值,因为线程2在线程1执行"//2"代码前请求了Cache2.compute方法,而此时线程1虽然正在计算"1"的值,但是在线程1执行"//2"代码前,线程2是不知道已经有线程去执行"1"的计算,就违背了缓存系统的设计初衷:避免同一个变量的重复计算。代码仍然需要改进。


第三版
观察Cache2类的代码我们知道其有一个非常长的窗口期(//1 + compute,约0.9个单位时间),如果线程2在线程1执行的窗口期内请求处理同一个字符串(如上面的“1”),那么会导致重复计算。窗口期长的原因主要在于compute的计算在窗口期内,如果我们想办法能把compute的计算时间移除窗口,那么我们就能缩短窗口期。这就需要用到一个非常有用的阻塞类:FutureTask。具体代码如下:

public class Cache3 {
    ExpensiveCompution computer;
    private Map<String,FutureTask<Long>> map = new HashMap<String,FutureTask<Long>>();

    public Cache3(ExpensiveCompution c) {
        this.computer = c;
    }

    public Long compute(String string) throws InterruptedException,ExecutionException{
        FutureTask<Long> ft;
        synchronized(this){                  //1
            ft = map.get(string);            //1
        }                                    //1
        if (ft == null) {

            ft = new FutureTask<Long>(new Callable<Long>() {  //3
                @Override                                     //3
                public Long call() throws Exception {         //3
                    return  computer.compute(string);         //3
                }                                             //3
            });                                               //3
            synchronized (this) {            //2
                map.put(string, ft);         //2
            }                                //2
            ft.run();                        //ExpensvieCompution.compute方法在此开始执行
            
        }

        return ft.get();



    }
}

在Cache3中,重复计算的窗口期仅仅为(//1+//3)这段时间,而最最消耗时间的ExpensiveCompution.compute被移到了ft.run方法后执行,同时得益于FutureTask.get方法的语法特性,当线程2在线程1的非窗口期计算字符串“1”的返回值时候,其将得到和线程1相同的FutureTask实例。这就很大程度上的避免了对相同请求参数的重复计算。当然如果我们在//2处增加二次判断,就可以完全清除窗口期,这个缓存类的设计也就将近完美(除了定期要清理缓存中的数据外)。具体类Cache4如下图所示:

public class Cache4 {
    ExpensiveCompution computer;
    private Map<String,FutureTask<Long>> map = new HashMap<String,FutureTask<Long>>();

    public Cache4(ExpensiveCompution c) {
        this.computer = c;
    }

    public Long compute(String string) throws InterruptedException,ExecutionException{
        FutureTask<Long> ft;
        synchronized(this){                  //1
            ft = map.get(string);            //1
        }                                    //1
        if (ft == null) {

            ft = new FutureTask<Long>(new Callable<Long>() {  //3
                @Override                                     //3
                public Long call() throws Exception {         //3
                    return  computer.compute(string);         //3
                }                                             //3
            });                                               //3
            synchronized (this) {                 //2
                if (map.get(string) == null) {    //2
                    map.put(string, ft);          //2
                    ft.run();                     //2 ExpensvieCompution.compute方法在此开始执行
                } else {                          //2
                    ft = map.get(string);         //2
                }                                 //2
            }                                     //2
        }

        return ft.get();



    }
}

至此应用java阻塞类,我们设计了一个完美的线程安全的缓存系统。当然也可以把各种Cache的成员变量map换成ConcurrentHashMap类型,那样效率会更高一些。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容

  • 1. Java基础部分 基础部分的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法,线程的语...
    子非鱼_t_阅读 31,598评论 18 399
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,810评论 25 707
  • 年轻的时候,你该有沉得住气的远见和毅力。看见周围的人谈恋爱了,谁又去KTV了,看见谁又在宿舍待了几天上网。于是...
    夏神经阅读 379评论 0 2
  • 年纪越大,越害怕体检,好似每次体检都是一道难关。 昨晚特别想喝牛肉汤,却碍于今天要体检,不宜吃过于辛辣和油腻的食物...
    丢了朵朵阅读 319评论 2 2
  • 宫崎骏的动漫总是这样给我清新的感觉。有时像是风一般清爽,有时又是阳一般温暖。 这部动漫也是,其中的小人儿,不小心被...
    飘依阅读 644评论 8 7