[Java多线程编程之十一] 对象的共享与发布

  [Java多线程编程之九] 线程安全 介绍了并发编程中常见的问题包括原子性、可见性,以及如何解决问题的方法包括锁机制(sychronized、Lock)、volatile和JUC包中提供的原子类等,并阐述了线程安全的各种概念。在 [Java多线程编程之十] 深入理解Java的锁机制 中针对锁机制进行了详细的介绍。

  并发编程涉及多线程的共享变量的操作,而变量在使用前需要先发布,本文将介绍对象共享发布的细节,以及其他能保证线程安全的手段。

一、发布与逸出

1、发布

发布一个对象指是对象能够在当前的作用域之外的代码中使用

  发布对象最简单的方式是将对象的引用保存在一个公有的静态变量中,eg:

public Object obj;

  其他任何能让调用代码获取到对象引用的方式,都算是发布了对象,eg:

private Object obj;
public Object getObj() {
    return obj;
}

  但是发布一个对象不意味着要发布对象所有的成员,比如一些表示对象内部状态的成员有时我们不想发布就会将其声明为私有,并不对外提供访问和修改该成员的方法,这就是面向对象中的封装。刚接触面向对象编程时,老师总告诉我们要将对象的成员属性声明为私有变量,并对外提供getter和setter,这样有利于类提供者更好地实现类成员的操控,并限定了代码访问路径。

public class Person {
    private int age;
    private String name;

    public Person(int age, String name) {
        this.age = age;
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

  如上面的类,要访问Person的age属性,只能通过getAge()方法,封装限定了代码路径能为一些新增的需求提供非侵入式的支持,比如现在要保证访问age属性的线程安全,则可以将getAge()方法改写为:

    public synchronized int getAge() {
        return age;
    }

    public synchronized void setAge(int age) {
        this.age = age;
    }

  但是调用代码person.getAge()不需要修改,如果没有封装,调用者代码为person.age,实现同步的需求需要调用代码显式加锁,如:

sychronized (person) {
     person.age;
}


2、逸出

逸出:如果某个对象或对象成员不想被发布,但是被发布时,就称为逸出。

  逸出会产生潜在的问题和隐患,因为你不知道调用者代码会如何使用逸出的对象,eg:

public class UnsafeStates {
    private String[] states = new String[] {"AK", "AL", "AM"};

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

    @Override
    public String toString() {
        StringBuffer sb = new StringBuffer();
        for (String state : states) {
            sb.append(state).append(" ");
        }
        return sb.toString();
    }
}

  调用代码如下:

        UnsafeStates unsafeStates = new UnsafeStates();
        System.out.println(unsafeStates.toString());
        unsafeStates.getStates()[0] = "AA";
        System.out.println(unsafeStates.toString());

执行结果如下:

AK AL AM 
AA AL AM 

  UnsafeStates 中的states属性被声明为private,本意是为了防止被调用代码修改,但是getStates()方法返回的是对象引用,导致states逸出了类的作用域,本来应是私有的变量被发布了。

  当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布,这也会导致逸出。

3、安全发布

  发布对象时,如果在对象构造完成之前对象引用就显式或隐式被外界获取,那么使用该引用操作对象时,可能看到的对象状态是不可预测的。

public class ThisEscape {
    // Other Field And Method

    public ThisEscape(EventSource source) {
        source.registerListener(
            new EventListener() {
                public void onEvent(Event e) {
                    doSomething(e);
                }
            });
    }

    public void doSomething(Event e) {
        // xxx
    }
}

  如上面的代码,在构造函数中,创建的EventListener匿名实现类的 onEvent 方法中,隐式地调用到了当前的 ThisEscape 对象,即 doSomething(e) 等同于 this.doSomething(e), 但是此时该对象还未构造完成,如果 doSomething 中操作了对象的其他属性,那么由于构造函数尚未完成,执行 doSomething 时看到的属性值可能不是一个未初始化的失效值或非法值。

  因此:

不要在构造过程中使this引用逸出

  上面的代码在构造函数中注册一个事件监听器或启动线程会导致逸出,可以使用一个私有构造方法和一个公共的工厂方法来实现正确的构造过程和安全发布,代码如下:

public class SafeListener {
    private EventListener listener;

    private SafeListener() {
        listener = new EventListener() {
            public void onEvent(Event e) {
                doSomething(e);
            }
        };
    }

    public static SafeListener newInstance(EventSource source) {
        SafeListener safe = new SafeListener();
        source.registerListener(safe.listener);
        return safe;
    }
}

  如果构造函数中掉用了一个可改写的实例方法(非private final),由于可以被子类重写,同样会导致this引用在构造过程中逸出,因为为了防止逸出,应该将实例方法加上private或final声明。


二、线程封闭

  线程安全问题的根源是多个线程共享了可变变量,如果线程间不共享数据,变量只被单个线程使用,则不需要同步,这就是线程封闭,线程封闭有几种实现手段:Ad-hoc线程封闭、栈封闭、使用ThreadLocal。

1、Ad-hoc线程封闭

  Ad-hoc线程封闭是指维护线程封闭性的职责完全由程序实现来承担。但是没有什么语言特性做保障,使得它很脆弱,因为随时可能因为代码的误用或者接手的程序员对原来的设计不清楚导致封闭被打破。

2、栈封闭

  每个线程在执行方法时,JVM都会为其创建一个线程私有的方法栈,在方法栈中定义的局部变量只要通过方法return发布出去,则这些局部变量的生命周期局限在方法内部,不会被其他线程共享,从而保证线程安全。

3、ThreadLocal

  ThreadLocal是一个特殊的类,会为每个使用该类型变量的线程都保存一份独立的副本。

public class ThreadLocalTest {
    public static void main(String[] args) throws InterruptedException {
        ThreadLocal<String> threadLocal = new ThreadLocal<String>();

        Thread thread = new Thread() {
            @Override
            public void run() {
                threadLocal.set("haha");
                System.out.println(threadLocal.get());
            }
        };
        thread.start();

        Thread.sleep(1000);
        System.out.println(threadLocal.get());
    }
}

  测试程序中,主线程休眠1秒等待thread执行完再获取,如果ThreadLocal保存的变量是可以共享的,那主线程的输出应该是haha,但实际执行输出是null,因为主线程没有调用过set方法设置值。

haha
null

  ThreadLocal特别适合保存线程上下文信息的场景,但是要注意防止滥用,而且使用ThreadLocal也会引入耦合。


三、不变性

  线程封闭是从禁止多线程共享变量的角度实现线程安全,如果被线程共享的数据是不变的,那么不需要额外的同步,也能保持线程安全。

  假如是一个简单基本类型被声明了final,如果想尝试修改它,IDE会提示编译错误


  如果一个引用类型变量(即对象)被声明为final,只代表引用指向的对象内存地址不可修改,实际上对象成员只要不是final,或者没有用private封装起来,实际上成员还是可变的。

public class TestFinal {
    public static void main(String[] args) {
        final NonfinalObject obj = new NonfinalObject();
        System.out.println(obj.num);
        obj.num = 2;
        System.out.println(obj.num);
    }
}
class NonfinalObject {
    public int num = 1;
}

执行结果:

1
2

  要满足线程同步需求,我们不仅希望对象引用不可被修改,还希望不可变对象的成员无法被修改,可以将对象的所有域声明为final,也可以将将域封装起来(比如声明为private,不提供对外的setter并且避免发布),如下所示:

class NonfinalObject {
    private int num = 1;

    public int getNum() {
        return num;
    }
}

  同时对象还应该被正确地创建,避免逸出(这其实是所有对象的要求,特别是多线程中使用的对象,安全的构造可避免难以预料的错误),满足这些需求的对象就可以被称为不可变对象

总结一下,满足下面条件的对象是不可变的:

  • 对象创建以后其状态不能修改。
  • 对象的所有域都是无法被外界修改的,手段有final和封装。
  • 对象是正确创建的(在对象的创建期间,this引用没有逸出,被正确构造)

不可变对象一定是线程安全的。

1、final

  被final修饰的域不可被修改,同时能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无需同步。如果一个对象的某些域不能或者不需要被修改,则应该用final进行修饰,有利于简化状态判断和防止被修改。

“除非需要更高的可见性,否则应将所有的域声明为私有域。”
“除非需要某个域是可变的,否则应将其声明为final域。”


2、使用volatile发布不可变对象

  使用volatile发布不可变对象,可以达到不加锁但是类似锁的效果,可以保证共享数据的可见性和原子性。

  如下面的代码所示,OneValueCache类的每个域都是不可变域,所以该类的对象都是不可变对象,对象用作一个最近一次请求的缓存,通过getFactors方法获取缓存,当请求数据与被缓存结果的请求值相等时,返回缓存结果,因为缓存结果lastFactors是一个数组类型,为了防止被篡改,返回的是一个数组的拷贝对象。

public class OneValueCache {
    private final BigInteger lastNumber;
    private final BigInteger[] lastFactors;

    public OneValueCache(BigInteger i, BigInteger[] factors) {
        lastNumber = i;
        lastFactors = Arrays.copyOf(factors, factors.length);
    }

    public BigInteger[] getFactors(BigInteger i) {
        if (lastNumber == null || !lastNumber.equals(i))
            return null;
        else
            return Arrays.copyOf(lastFactors, lastFactors.length);
    }
}

  调用代码示例如下:

public class VolatileCachedFactorizer {
    private volatile OneValueCache cache = new OneValueCache(null, null);

    public void service(BigInteger req) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = cache.getFactors(req);
        if (factors == null) {
            factors = factor(i);
            cache = new OneValueCache(i, factors);
        }
        encodeIntoResonse(resp, factors);
    }
}

  使用volatile修饰不可变对象,是为了保证对象的可见性,当引用指向的对象变化时所有线程都能看到,不可变对象是线程安全的,所以不存在原子性和可见性问题,两者结合,可以满足对共享数据的读取和写入的线程安全,虽然没有使用锁但是达到了使用锁的效果。


四、安全发布

  当共享对象需要被多个线程使用时,如果要避免多线程看到被不正确构造且状态变化存在可见性的问题,对象就应该被安全地发布。

1、初始化安全性

  如果一个对象在初始化构造完成之前就被逸出了,调用代码可能会看到对象处于不一致的状态,或者失效的值,如下面的代码所示:

public Holder holder;
public void initialize() {
    holder = new Holder(42);
}

  在没有足够同步的情况下发布对象,应该将holder声明为private,保证发布holder对象时,已经正确地构造对象。

  如果对象还未构造完或者构造中就被发布,那么某个线程调用assertSanity(),可能会抛出错误,如下面的代码所示:

public class Holder {
    private int n;
    public Holder(int n) {
        this.n = n;
    }
    public void assertSanity() {
        if (n != n)
            throw new AssertionError("This statement is false");
    }
}

  线程调用assertSanity()方法时,会去读取两次对象成员n的值,由于此时对象尚未完成创建,所以两次读取到的值可能是不一样的,所以可能会抛出错误。

2、使用不可变对象保证初始化安全性

  上面的代码,如果将Holder类中的成员n,用final修饰,那么holder对象就变成了一个不可变对象,这时即使对象没有被安全发布,调用assertSanity()方法时也不会抛出错误,因为:

Java内存模型为不可变对象的共享提供了一种特殊的初始化安全性保证。

  使用不可变对象时,不需要对其进行同步,比如加锁,从一定程度上看,效率更高。

任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象时没有使用同步。


3、常用安全发布模式

  要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:

  • 在静态初始化函数中初始化一个对象引用(保证使用前已完成初始化)。
  • 将对象的引用保存到volatile类型的域或者AtomicReference对象中。
  • 将对象的引用保存到某个正确构造对象的final类型域中。
  • 将对象的引用保存到一个由锁保护的域中。

  在安全发布模式中,优先使用final修饰不可变成员和对象,其次构造初始化可以充分利用static带来的静态初始化加载机制保证正确构造。

4、事实不可变对象

  还有一类对象,发布后虽然没有采用额外同步机制(这可以减少开销),但是发布时状态对所有线程可见,并且在实际使用中一经发布就不会被修改,称之为 “事实不可变对象”

  对事实不可变对象,不需要特别处理,因为:

在没有额外同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。


5、可变对象

  但是在并发编程中,更多情况下使用的还是共享可变变量,要安全地共享可变对象,除了要安全地发布,还必须是线程安全的或者由某个锁保护起来。

对象的发布需求取决于它的可变性:

  • 不可变对象可以通过任意机制来发布。
  • 事实不可变对象必须通过安全方式来发布。
  • 可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来。



五、并发编程共享策略

  在并发编程中,数据的共享度越高,安全性保障的代价越大,根据共享度的变化,共享策略总结如下:

  • 线程封闭。不共享,就没有线程安全问题。
  • 只读共享。多线程并发访问,但不修改数据,可以不用额外的同步,但是需要安全地初始化,共享的只读对象包括不可变对象和事实不可变对象。
  • 线程安全共享。被使用的对象的类,已经在类的内部实现了同步,多线程可以安全地调用类的方法而无需额外同步,但是如果想将对象跟其他数据绑定成一个整体,则需要额外同步。
  • 保护对象。被保护的对象的类没有内置同步机制,只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,098评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,213评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,960评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,519评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,512评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,533评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,914评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,804评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,563评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,644评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,350评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,933评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,908评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,146评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,847评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,361评论 2 342

推荐阅读更多精彩内容