java安全編碼指南之:可見性和原子性

簡介

java類中會定義很多變量,有類變量也有實例變量,這些變量在訪問的過程中,會遇到一些可見性和原子性的問題。這裏我們來詳細瞭解一下怎麼避免這些問題。

不可變對象的可見性

不可變對象就是初始化之後不能夠被修改的對象,那麼是不是類中引入了不可變對象,所有對不可變對象的修改都立馬對所有線程可見呢?

實際上,不可變對象只能保證在多線程環境中,對象使用的安全性,並不能夠保證對象的可見性。

先來討論一下可變性,我們考慮下面的一個例子:

public final class ImmutableObject {
    private final int age;
    public ImmutableObject(int age){
        this.age=age;
    }
}

我們定義了一個ImmutableObject對象,class是final的,並且裏面的唯一字段也是final的。所以這個ImmutableObject初始化之後就不能夠改變。

然後我們定義一個類來get和set這個ImmutableObject:

public class ObjectWithNothing {
    private ImmutableObject refObject;
    public ImmutableObject getImmutableObject(){
        return refObject;
    }
    public void setImmutableObject(int age){
        this.refObject=new ImmutableObject(age);
    }
}

上面的例子中,我們定義了一個對不可變對象的引用refObject,然後定義了get和set方法。

注意,雖然ImmutableObject這個類本身是不可變的,但是我們對該對象的引用refObject是可變的。這就意味着我們可以調用多次setImmutableObject方法。

再來討論一下可見性。

上面的例子中,在多線程環境中,是不是每次setImmutableObject都會導致getImmutableObject返回一個新的值呢?

答案是否定的。

當把源碼編譯之後,在編譯器中生成的指令的順序跟源碼的順序並不是完全一致的。處理器可能採用亂序或者並行的方式來執行指令(在JVM中只要程序的最終執行結果和在嚴格串行環境中執行結果一致,這種重排序是允許的)。並且處理器還有本地緩存,當將結果存儲在本地緩存中,其他線程是無法看到結果的。除此之外緩存提交到主內存的順序也肯能會變化。

怎麼解決呢?

最簡單的解決可見性的辦法就是加上volatile關鍵字,volatile關鍵字可以使用java內存模型的happens-before規則,從而保證volatile的變量修改對所有線程可見。

public class ObjectWithVolatile {
    private volatile ImmutableObject refObject;
    public ImmutableObject getImmutableObject(){
        return refObject;
    }
    public void setImmutableObject(int age){
        this.refObject=new ImmutableObject(age);
    }
}

另外,使用鎖機制,也可以達到同樣的效果:

public class ObjectWithSync {
    private  ImmutableObject refObject;
    public synchronized ImmutableObject getImmutableObject(){
        return refObject;
    }
    public synchronized void setImmutableObject(int age){
        this.refObject=new ImmutableObject(age);
    }
}

最後,我們還可以使用原子類來達到同樣的效果:

public class ObjectWithAtomic {
    private final AtomicReference<ImmutableObject> refObject= new AtomicReference<>();
    public ImmutableObject getImmutableObject(){
        return refObject.get();
    }
    public void setImmutableObject(int age){
        refObject.set(new ImmutableObject(age));
    }
}

保證共享變量的複合操作的原子性

如果是共享對象,那麼我們就需要考慮在多線程環境中的原子性。如果是對共享變量的複合操作,比如:++, -- *=, /=, %=, +=, -=, <<=, >>=, >>>=, ^= 等,看起來是一個語句,但實際上是多個語句的集合。

我們需要考慮多線程下面的安全性。

考慮下面的例子:

public class CompoundOper1 {
    private int i=0;
    public int increase(){
        i++;
        return i;
    }
}

例子中我們對int i進行累加操作。但是++實際上是由三個操作組成的:

從內存中讀取i的值,並寫入CPU寄存器中。
CPU寄存器中將i值+1
將值寫回內存中的i中。
如果在單線程環境中,是沒有問題的,但是在多線程環境中,因爲不是原子操作,就可能會發生問題。

解決辦法有很多種,第一種就是使用synchronized關鍵字

    public synchronized int increaseSync(){
        i++;
        return i;
    }

第二種就是使用lock:

    private final ReentrantLock reentrantLock=new ReentrantLock();

    public int increaseWithLock(){
        try{
            reentrantLock.lock();
            i++;
            return i;
        }finally {
            reentrantLock.unlock();
        }
    }

第三種就是使用Atomic原子類:

    private AtomicInteger atomicInteger=new AtomicInteger(0);

    public int increaseWithAtomic(){
        return atomicInteger.incrementAndGet();
    }

保證多個Atomic原子類操作的原子性

如果一個方法使用了多個原子類的操作,雖然單個原子操作是原子性的,但是組合起來就不一定了。

我們看一個例子:

public class CompoundAtomic {
    private AtomicInteger atomicInteger1=new AtomicInteger(0);
    private AtomicInteger atomicInteger2=new AtomicInteger(0);

    public void update(){
        atomicInteger1.set(20);
        atomicInteger2.set(10);
    }

    public int get() {
        return atomicInteger1.get()+atomicInteger2.get();
    }
}

上面的例子中,我們定義了兩個AtomicInteger,並且分別在update和get操作中對兩個AtomicInteger進行操作。

雖然AtomicInteger是原子性的,但是兩個不同的AtomicInteger合併起來就不是了。在多線程操作的過程中可能會遇到問題。

同樣的,我們可以使用同步機制或者鎖來保證數據的一致性。

保證方法調用鏈的原子性

如果我們要創建一個對象的實例,而這個對象的實例是通過鏈式調用來創建的。那麼我們需要保證鏈式調用的原子性。

考慮下面的一個例子:

public class ChainedMethod {
    private int age=0;
    private String name="";
    private String adress="";

    public ChainedMethod setAdress(String adress) {
        this.adress = adress;
        return this;
    }

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

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

很簡單的一個對象,我們定義了三個屬性,每次set都會返回對this的引用。

我們看下在多線程環境下面怎麼調用:

        ChainedMethod chainedMethod= new ChainedMethod();
        Thread t1 = new Thread(() -> chainedMethod.setAge(1).setAdress("www.flydean.com1").setName("name1"));
        t1.start();

        Thread t2 = new Thread(() -> chainedMethod.setAge(2).setAdress("www.flydean.com2").setName("name2"));
        t2.start();

因爲在多線程環境下,上面的set方法可能會出現混亂的情況。

怎麼解決呢?我們可以先創建一個本地的副本,這個副本因爲是本地訪問的,所以是線程安全的,最後將副本拷貝給新創建的實例對象。

主要的代碼是下面樣子的:

public class ChainedMethodWithBuilder {
    private int age=0;
    private String name="";
    private String adress="";

    public ChainedMethodWithBuilder(Builder builder){
        this.adress=builder.adress;
        this.age=builder.age;
        this.name=builder.name;
    }

    public static class Builder{
        private int age=0;
        private String name="";
        private String adress="";

        public static Builder newInstance(){
            return new Builder();
        }
        private Builder() {}

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

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

        public Builder setAdress(String adress) {
            this.adress = adress;
            return this;
        }

        public ChainedMethodWithBuilder build(){
            return new ChainedMethodWithBuilder(this);
        }
    }

我們看下怎麼調用:

      final ChainedMethodWithBuilder[] builder = new ChainedMethodWithBuilder[1];
        Thread t1 = new Thread(() -> {
            builder[0] =ChainedMethodWithBuilder.Builder.newInstance()
                .setAge(1).setAdress("www.flydean.com1").setName("name1")
                .build();});
        t1.start();

        Thread t2 = new Thread(() ->{
            builder[0] =ChainedMethodWithBuilder.Builder.newInstance()
                .setAge(1).setAdress("www.flydean.com1").setName("name1")
                .build();});
        t2.start();

因爲lambda表達式中使用的變量必須是final或者final等效的,所以我們需要構建一個final的數組。

讀寫64bits的值

在java中,64bits的long和double是被當成兩個32bits來對待的。

所以一個64bits的操作被分成了兩個32bits的操作。從而導致了原子性問題。

考慮下面的代碼:

public class LongUsage {
    private long i =0;

    public void setLong(long i){
        this.i=i;
    }
    public void printLong(){
        System.out.println("i="+i);
    }
}

因爲long的讀寫是分成兩部分進行的,如果在多線程的環境中多次調用setLong和printLong的方法,就有可能會出現問題。

解決辦法本簡單,將long或者double變量定義爲volatile即可。

private volatile long i = 0;

原文鏈接
本文爲阿里雲原創內容,未經允許不得轉載。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章