簡介
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;
本文的程式碼:
learn-java-base-9-to-20/tree/master/security
本文已收錄於 http://www.flydean.com/java-security-code-line-visibility-atomicity/
最通俗的解讀,最深刻的乾貨,最簡潔的教程,眾多你不知道的小技巧等你來發現!
歡迎關注我的公眾號:「程式那些事」,懂技術,更懂你!