Java Volatile(包含对volatile数组和对象的理解)

1.多线程中重要概念

1.1 可见性
也就说假设一个对象中有一个变量i,那么i是保存在main memory中的,当某一个线程要操作i的时候,首先需要从main memory中将i 加载到这个线程的working memory中,这个时候working memory中就有了一个i的拷贝,这个时候此线程对i的修改都在其working memory中,直到其将i从working memory写回到main memory中,新的i的值才能被其他线程所读取。从某个意义上说,可见性保证了各个线程的working memory的数据的一致性。
可见性遵循下面一些规则:

当一个线程运行结束的时候,所有写的变量都会被flush回main memory中。
当一个线程第一次读取某个变量的时候,会从main memory中读取最新的。
volatile的变量会被立刻写到main memory中的,在jsr133中,对volatile的语义进行增强,后面会提到
当一个线程释放锁后,所有的变量的变化都会flush到main memory中,然后一个使用了这个相同的同步锁的进程,将会重新加载所有的使用到的变量,这样就保证了可见性。
1.2 原子性
还拿上面的例子来说,原子性就是当某一个线程修改i的值的时候,从取出i到将新的i的值写给i之间不能有其他线程对i进行任何操作。也就是说保证某个线程对i的操作是原子性的,这样就可以避免数据脏读。
通过锁机制或者CAS(Compare And Set 需要硬件CPU的支持)操作可以保证操作的原子性。

1.3 有序性
假设在main memory中存在两个变量i和j,初始值都为0,在某个线程A的代码中依次对i和j进行自增操作(i,j的操作不相互依赖),


i++;
j++;
由于,所以i,j修改操作的顺序可能会被重新排序。那么修改后的ij写到main memory中的时候,顺序可能就不是按照i,j的顺序了,这就是所谓的reordering,在单线程的情况下,当线程A运行结束的后i,j的值都加1了,在线程自己看来就好像是线程按照代码的顺序进行了运行(这些操作都是基于as-if-serial语义的),即使在实际运行过程中,i,j的自增可能被重新排序了,当然计算机也不能帮你乱排序,存在上下逻辑关联的运行顺序肯定还是不会变的。但是在多线程环境下,问题就不一样了,比如另一个线程B的代码如下


if(j==1) {
    System.out.println(i);
}
按照我们的思维方式,当j为1的时候那么i肯定也是1,因为代码中i在j之前就自增了,但实际的情况有可能当j为1的时候i还是为0。这就是reordering产生的不好的后果,所以我们在某些时候为了避免这样的问题需要一些必要的策略,以保证多个线程一起工作的时候也存在一定的次序。JMM提供了happens-before 的排序策略。

 

2. JMM(java内存模型)

    Java 内存模型的抽象 

在java中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享(本文使用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local variables),方法定义参数(java语言规范称之为formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。

 

Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java内存模型的抽象示意图如下:

 

 

从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:

 

  1. 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
  2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。

 

下面通过示意图来说明这两个步骤:

 

 

如上图所示,本地内存A和B有主内存中共享变量x的副本。假设初始时,这三个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。

 

从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。

 

3. Volatile

对一个共享变量使用Volatile关键字保证了线程间对该数据的可见性,即不会读到脏数据。

注:1. 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入

        2. 原子性:对任意单个volatile变量的读/写具有原子性(long,double这2个8字节的除外),但类似于volatile++这种复合操作不具有原子性。

        3. volatile修饰的变量如果是对象或数组之类的,其含义是对象获数组的地址具有可见性,但是数组或对象内部的成员改变不具备可见性

            eg:以下代码要以-server模式运行,强制虚拟机开启优化

package com.xj;

public class VolatileObjectTest implements Runnable {
    private ObjectA a; // 加上volatile 就可以正常结束While循环了   
    public VolatileObjectTest(ObjectA a) {
        this.a = a;
    }
 
    public ObjectA getA() {
        return a;
    }
 
    public void setA(ObjectA a) {
        this.a = a;
    }
 
    @Override
    public void run() {
        long i = 0;
        while (a.isFlag()) {
            i++;
//            System.out.println("------------------");
        }
        System.out.println("stop My Thread " + i);
    }
 
    public void stop() {
        a.setFlag(false);
    }
 
    public static void main(String[] args) throws InterruptedException {
         // 如果启动的时候加上-server 参数则会 输出 Java HotSpot(TM) Server VM
        System.out.println(System.getProperty("java.vm.name"));
         
        VolatileObjectTest test = new VolatileObjectTest(new ObjectA());
        new Thread(test).start();
 
        Thread.sleep(1000);
        test.stop();
        Thread.sleep(1000);
        System.out.println("Main Thread " + test.getA().isFlag());
    }
 
    static class ObjectA {
        private boolean flag = true;
 
        public boolean isFlag() {
            return flag;
        }
 
        public void setFlag(boolean flag) {
            this.flag = flag;
        }
 
    }
}

       以上代码如果是红色标记那一行加volatile关键字,子线程是可以退出循环的,不加的话,子线程没法退出循环,如此说来,volatile变量修饰对象或者数组,当我们改变对象或者数组的成员的时候,岂非不同线程之间具有可见性?

      在看如下代码:

package com.xj;

public class VolatileTestAgain implements Runnable {
    private volatile ObjectA a; // 加上volatile也无法结束While循环了
 
    public VolatileTestAgain(ObjectA a) {
        this.a = a;
    }
 
    public ObjectA getA() {
        return a;
    }
 
    public void setA(ObjectA a) {
        this.a = a;
    }
 
    @Override
    public void run() {
        long i = 0;
        ObjectASub sub = a.getSub();
        while (!sub.isFlag()) {
            i++;        }
        System.out.println("stop My Thread " + i);
    }
  
    public static void main(String[] args) throws InterruptedException {
         // 如果启动的时候加上-server 参数则会 输出 Java HotSpot(TM) Server VM
        System.out.println(System.getProperty("java.vm.name"));
        ObjectASub sub = new ObjectASub();
        ObjectA sa = new ObjectA();
        sa.setSub(sub);
        VolatileTestAgain test = new VolatileTestAgain(sa);
        new Thread(test).start();
 
        Thread.sleep(1000);
        sub.setFlag(true);
        Thread.sleep(1000);
        System.out.println("Main Thread " + sub.isFlag());
    }
 
    static class ObjectA {
        private ObjectASub sub;

		public ObjectASub getSub() {
			return sub;
		}

		public void setSub(ObjectASub sub) {
			this.sub = sub;
		}
    }
    
    static class ObjectASub{
    	private boolean flag;

		public boolean isFlag() {
			return flag;
		}

		public void setFlag(boolean flag) {
			this.flag = flag;
		}
    	
    	
    }
}

      如上代码即使添加volatile关键字也无法让子线程结束循环,读者可以仔细对比一下2段代码,下面是我的解释。

     1)代码1中当主线程更改flag字段时候,是调用stop()方法里面的“a.setFlag(false); ”,注意这一句其实包含多步操作,含义丰富:首先是对volatile变量a的读,既然是volatile变量,当然读到的是主存(而不是线程私有的)中的地址,然后再setFlag来更新这个标志位实际上是更新的主存中引用指向的堆内存;然后是子线程读

a.isFlag(),同样的包含多步:首先是对volatile变量的读,当然读的是主存中的引用地址,然后根据这个引用找堆内存中flag值,当然找到的就是之前主线程写进去的值,所以能够立即生效,子线程退出。

    2)代码1中虽然主线程和子线程都是读volatile值,然后一个是改,一个是读,按照java内存模型中的happen-before,2个线程对volatile变量的读是不具有happen-before特性的,但是这里要注意的是,因为都是以volatile变量为根,层层引用,最后找到的都是同一块堆内存,然后一个修改,一个查看,所以实际上相当于同一个线程在写和修改(因为写和修改的是同一块内存);所以可以利用happen-before中第一条规则——程序顺序规则,从而有主线程的写happen-before子线程的读

    3)代码2中加了volatile关键字仍然子线程无法退出,这是因为主线程的对flag标志位的改,已经不是通过volatile根对象先定位到主存中的地址,然后逐级索引去找到堆内存,然后改地址,而是直接在线程中保存了一个sub对象,这样改掉的,实际上不是主存中的volatile根对象引用的ObjectASub对象再引用的flag标志位的值了,他改变的是本地线程中缓存的值;同理子线程中取的也是每次都取的本地线程中缓存的值;主线程的写没有及时刷新到主存中,子线程也没用从主存中去读,导致了数据的不一致性。

    总结:1)用volatile修饰数组和对象不是不可以,要注意一点:修改操作要从volatile变量逐级引用,去找到要修改的变量,保证修改是刷新到主存中的值对应的变量;读取操作,也要以volatile变量为根,逐级去定位,这样保证修改即使刷新到主存中volatile变量指向的堆内存,读取能够每次从主存的volatile变量指向的堆内存去读,保证数据的一致性。

                2)在保证了总结1)的前提下,因为大家读取修改的都是同一块内存,所以变相的符合happen-before规则中的程序顺序规则,具有happen-before性。

      3. volatile写-读建立的happens before关系

              对程序员来说,volatile对线程的内存可见性的影响比volatile自身的特性更为重要,也更需要我们去关注.

             happen-before规则

             程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
             监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。 
             volatile变量规则:对一个volatile域的写,happens- before 于任意后续对这个volatile域的读。
            传递性:如果A happens- before B,且B happens- before C,那么A happens- before C。
             Thread.start()的调用会happens-before于启动线程里面的动作。
             Thread中的所有动作都happens-before于其他线程从Thread.join中成功返回。

            

             进一步关注JMM如何实现volatile写/读的内存语义

            前文我们提到过重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。下面是JMM针对编译器制定的volatile

            重排序规则表:

是否能重排序 第二个操作
第一个操作 普通读/写 volatile读 volatile写
普通读/写     NO
volatile读 NO NO NO
volatile写   NO NO

举例来说,第三行最后一个单元格的意思是:在程序顺序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。


从上表我们可以看出:
当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

     eg:(对以上volatile的happen-before特性的利用)

java并发库ConcurrentHashMap中get操作的无锁弱一致性实现

V get(Object key, int hash) {   
    if (count != 0) { // read-volatile   
        HashEntry e = getFirst(hash);   
        while (e != null) {   
            if (e.hash == hash && key.equals(e.key)) {   
                V v = e.value;   
                if (v != null)   
                    return v;   
                return readValueUnderLock(e); // recheck   
            }   
            e = e.next;   
        }   
    }   
    return null;   
}  
 V put(K key, int hash, V value, boolean onlyIfAbsent) {
            lock();
            try {
                int c = count;
                if (c++ > threshold) // ensure capacity
                    rehash();
                HashEntry[] tab = table;
                int index = hash & (tab.length - 1);
                HashEntry first = tab[index];
                HashEntry e = first;
                while (e != null && (e.hash != hash || !key.equals(e.key)))
                    e = e.next;

                V oldValue;
                if (e != null) {
                    oldValue = e.value;
                    if (!onlyIfAbsent)
                        e.value = value;
                }
                else {
                    oldValue = null;
                    ++modCount;
                    tab[index] = new HashEntry(key, hash, first, value);
                    count = c; // write-volatile
                }
                return oldValue;
            } finally {
                unlock();
            }
        }

get操作不需要锁。第一步是访问count变量,这是一个volatile变量,由于所有的修改操作在进行结构修改时都会在最后一步写count变量,通过这种机制保证get操作能够得到几乎最新的结构更新。对于非结构更新,也就是结点值的改变,由于HashEntry的value变量是volatile的,也能保证读取到最新的值。接下来就是对hash链进行遍历找到要获取的结点,如果没有找到,直接访回null。对hash链进行遍历不需要加锁的原因在于链指针next是final的。但是头指针却不是final的,这是通过getFirst(hash)方法返回,也就是存在table数组中的值。这使得getFirst(hash)可能返回过时的头结点,例如,当执行get方法时,刚执行完getFirst(hash)之后,另一个线程执行了删除操作并更新头结点,这就导致get方法中返回的头结点不是最新的。这是可以允许,通过对count变量的协调机制,get能读取到几乎最新的数据,虽然可能不是最新的。要得到最新的数据,只有采用完全的同步。

 

你可能感兴趣的:(并发编程)