不用锁,也能实现线程安全的缓存系统

真有这种操作

《java并发编程实战》第三章:发布对象
第二章主要介绍了什么是线程安全,以及怎么检测一个类到底是不是线程安全的,从一个实例引出线程不安全的情况,并且怎么用synchronized关键字来保证线程安全。通读本章全文,发现其围绕一个核心主题就是“发布对象”(sharing object)。怎么在多线程的环境下,正确的发布一个对象,来达到想要的目的。下面来详细介绍这方面的问题。


可见性
通过《java并发编程实战》之java内存模型 这篇文章的分析,我们知道多线程开发中,我们面对的主要挑战就是“可见性”、“原子性”、“有序性”,三个方面。而其中“可见性”往往是最让新手感觉“理所应当”但其实是“来之不易”的一种特性。我们再来看看书中引出可见性问题的例子:

public class NoVisibility{
    private static boolean ready; 
    private static int number;
    private static class ReaderThread extends Thread{
        public void run(){
            while(!ready)
                Thread.yield();
                System.out.println(number);
        }
    }

    public static void main(String[] args){
        new ReaderThread().start();
        number = 42;
        ready = true;
    }
}

观察NoVisibility的代码,这段代码有两个线程,有一个调用main方法的线程,我们暂叫它main线程,还有一个在main方法里面启动的read线程,代码表达的逻辑也很简单,mian线程先修改number的值,然后把ready变量赋值为true告诉read线程number的值可以使用了,然后再read线程中打印出来,number的值,这个number的值,我们期望是main线程赋值以后的最新值42。读过《java并发编程实战》之java内存模型 这篇文章的人知道,这样写代码是问题的,read线程可能一直停在while语句;也可能打印出的结果是0不是42。
这两种情况都不是我们想要的结果,一直停在while语句处是因为read线程没有看到main线程对ready的修改,所以一直不能去执行“System.out.println(number)”这句,这是内存可见性问题。打印结果是0而不是42是因为发生了指令重排序导致导致main线程先执行了对number的赋值操作然后执行了对ready的赋值操作,read线程看到赋值后的ready变量后打印number变量,但是这时候对read线程并没有看到number的最新值42而去打印了number的默认值0。
我们可以通过添加锁来解决此问题:

public class LockVisibility  {
    private static boolean ready;
    private static int number;
    private static class ReadThread extends Thread{
        @Override
        public void run() {
            synchronized (NoVisibility.class) { //lock
                while(!ready){
                    Thread.yield();
                }
                System.out.println(number);
            }
        }
    }

    public static void main(String[] args){
        new ReadThread().start();
        synchronized (NoVisibility.class) {  //lock
            number = 42;
            ready = true;
        }
    }
}

注意观察上述代码,其与NoVisibility类的区别就是在线程main和线程read操作两个共享变量ready和number时,都加上了同一把锁。这样就可以保证最后read线程的输出结果是42,这样也就引出了锁的除了可以保障操作“原子性”外另外一个特性,锁可以保障操作的“可见性”。这也就是 java 8大happen-before原则超全面详解 中介绍的“锁的happen-before”原则。详细介绍可以移步到上述这篇文章观看。
如果单纯的想要保证“可见性”,我们还可以通过java提供的另外一个关键字volatile,来保证read线程打印的结果是42,如下面的代码:

public class VolatileVisibility {
    private static volatile boolean ready; //use volatile variable
    private static int number;

    private static class ReadThread extends Thread {
        @Override
        public void run() {
            while (!ready) {
                Thread.yield();
            }
            System.out.println(number);
        }
    }



    public static void main(String[] args) {
        new ReadThread().start();
        number = 42;
        ready = true;
    }
}

VolatileVisibility实现可见性的方式是通过将其中的ready变量声明为volatile的变量,通过 java 8大happen-before原则超全面详解 这篇文章介绍的“volatile的happen-before原则”和“可传递的happen-before原则”可知道。read线程最终打印的结果只能是42一种情况。这种可见性实现的方式量级比较轻,相较于用锁的方式吃的资源更少。但是需要注意的是volatile变量不能保证“操作的原子性”,而且过多的使用volatile变量来保证“操作的可见性”会在并发代码的实现上和后期维护上带来不小的问题。所以尽量少用volatile变量。


对象泄露
来看下面一段代码:

class UnsafeStates {
    private final String[] states = { "active","sleep","dead"};

    public String[] getStates(){
        return states;
    }
}

任何可以调用到getStates方法的线程,都可以改变states变量中的值,这就违背了把states定义成private变量的初衷了。还有一种情况是在类的构造方法中启用一个线程,如果在构造方法启动的线程中用到了此对象的任何成员变量或者方法,都可能导致异常情况。因为:

An object is in a predictable, consistent state only after its constructor returns.

如果没有等构造方法返回一个实例对象就用此对象的成员变量和成员方法,很有可能看到的成员变量处在一个非稳定的状态。不要在构造方法中启动一个线程,这在发布对象的时候尤其要注意。


对象的线程限制
对象的线程限制讨论的是如何把某个对象的所有权限制为特定的线程。比如变量var只属于线程1,其它线程不能看到或者得到此变量。java中有三种方式来实现这种要求。分别是“Ad-hoc thread confinement”、“stack confinement”和“ThreadLocal 类的使用”。

  • “Ad-hoc thread confinement”:说白就是利用各种java以后的方式来限制特定对象的访问权限,来达到只对特定线程可用的目的。java的GUI就是用这种方式设计实现的。这种方式非常脆弱,需要考虑的非常周详,才能实现要求。
  • “stack confinement” : 栈限制,这种方式是java自带的特性,比如下面的代码:
public void method1(int var){
    int a = var;
    Object obj = new Object();
}

如果线程1调用了method1方法,其中的变量a和obj指向的对象永远不会被其他线程看到或者获得,如果又有新的线程2调用了method1方法,那么线程2对a变量的复制不会影响到线程1对a变量的复制。

  • “ThreadLocal的使用” :ThreadLocal 类可以被理解成是一种特殊的Map,其key是线程实例自身,value是其想要独有的变量。具体使用方法大家可以去自行google。

不变性
不可变的对象永远是线程安全的。如果一个对象自打创建以后其状态不可以再被改变,那么不管是哪个线程使用这个对象,此对象都是线程安全的。那么什么是不可变的对象呢?不可变对象要满足如下三个条件:

  1. 不可变对象的成员变量必须被声明成final类型的。
  2. 不可变对象的成员变量在初始化以后就不会再改变。
  3. 不可变对象在初始化的时候不会泄露this变量。

下面看一个例子:

public class Immutable {
    private final Set<String> set = new HashSet<String>();

    public Immutable(){
        set.add("started");
        set.add("sleep");
        set.add("suspend");
    }

    public Set<String> getStates(){
        Set<String> newSet = new HashSet<String>();
        newSet.addAll(set);
        return newSet;
    }
}

我们来看下Immutable对象是一个不可变对象,首先它满足第一个条件,其成员变量都用final来修饰了;第二个条件,我们可以发现虽然set是一个Set类型的容器,但是其变量被声明成private类型的,而且Immutable类的getStates方法拿到的是set的深度拷贝,返回的那个Set类型的容器的变化不会对原来的set中的内容造成任何影响,set变量只是在Immutable的构造法中传入了三个值,在Immutable构造方法完成后,set中的内容不会被改变,所以其满足第二条;第三个条件,我们发现在构造方法中this变量没有被泄露,所以此条件也满足。所以Immutable对象是不可变对象,其也就是线程安全的。
我们可以利用不可变对象是线程安全的这一特点来在多线程环境下实现不用锁的编程。如多线程环境下的一个简单的缓存系统设计:

class OneValue {
    private final Integer number;
    private final BigInteger[] result;

    public OneValue(Integer number, BigInteger[] result) {
        this.number = number;
        this.result = result;
    }

    public BigInteger[] getResult(int number){
        if ( this.number == null ||  number != this.number) {
            return  null;
        }

        return Arrays.copyOf(result, result.length); //1
    }
}

public class Cache {

    private volatile OneValue oneValue = new OneValue(null, null); //2
    public BigInteger[] getCache(Integer integer) {
        BigInteger[] result = oneValue.getResult(integer);
        if (result == null) {
            /**
             * result 值从硬盘中读取
             */
            oneValue = new OneValue(integer, result); //3
        }

        return  result;
    }

}

我们先看OneValue类,其实例化的对象都是不可变的对象,注意//1处的写法,没有直接返回result变量,而是对其进行深度拷贝,这样就保证getResult方法不会泄露result变量,从而避免其他线程通过getResult方法改变result变量的内容。在来看下Cache类,其实现的功能是只有一个缓存值的缓存系统,通过getCache方法传入一个整数,如果能命中那么就直接从内存返回结果,不然需要从硬盘中耗费较多的时间获取结果。由于OneValue是线程安全的,其在 //3处的代码操作的原子性,即integer和result的值要么同时改变,要么同时不变。而在//2出的oneValue变量被声明成volatile保证了可见性,如果有线程对oneValue变量进行修改,其它线程可以马上获得最新的oneValue值。整个系统没有用任何锁,但是却在volatile和不可变对象的支持下,完成了一个简单的多线程环境下线程安全的缓存系统。
希望大家积极留言讨论,共同进步!

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

推荐阅读更多精彩内容