Comprensión profunda de volátiles (Java)

¡Acostúmbrate a escribir juntos! Este es el día 11 de mi participación en el "Nuevo plan diario de Nuggets · Desafío de actualización de abril", haga clic para ver los detalles del evento .

prefacio

Además de la palabra clave sincronizar mencionada en el artículo anterior, que puede lograr la sincronización, hay otra palabra clave volátil en Java que puede lograr una sincronización simple. La comprensión del conocimiento sincronizado se puede ver comprensión profunda de sincronizado (1) - primera comprensión de sincronizado .

definición

Volatile es un modificador de características. El papel de volatile es como una palabra clave de instrucción para garantizar que esta instrucción no se omita debido a la optimización del compilador y requiere la lectura directa del valor cada vez. Tiene aplicaciones en muchos lenguajes, y es una palabra clave en java, sus principales funciones son las siguientes:

  1. Garantiza la visibilidad de esta variable para todos los hilos. Es decir, después de que un subproceso modifica volátil, otros subprocesos pueden percibir inmediatamente el nuevo valor de esta variable.
  2. Deshabilite las optimizaciones de reordenamiento de instrucciones. Reordenamiento de instrucciones: Significa que la CPU adopta una variable modificada volátil que permite enviar por separado múltiples instrucciones a las unidades de circuito correspondientes fuera del orden especificado por el programa. Luego de la asignación, una más "load addl $0x0, (%esp ) se ejecuta ", esta operación es equivalente a una barrera de memoria (cuando se reordenan las instrucciones, las instrucciones posteriores no se pueden reordenar a la posición anterior a la barrera de memoria). Cuando solo una CPU accede a la memoria, no se requiere una barrera de memoria.

Hablemos de cómo volatile implementa las funciones anteriores Para una mejor comprensión, primero expliquemos cómo volatile hace que se prohíba el reordenamiento de instrucciones.

reordenación de instrucciones y volátiles

Primero entendemos el concepto de reordenamiento de instrucciones.

reordenación de instrucciones

大家有没有想过,我们编写的java代码,机器在执行的时候一定按我们编写的顺序一条一条执行吗?答案是否定的,因为我们编写的代码顺序很可能不是机器cpu执行较优的顺序,因此java内存模型允许编译器和处理器对在编译器或运行时对指令重排序以提高性能,并且只会对不存在的数据依懒性的指令进行重排序。

虽然指令排序能提高性能,但不代表所有的语句都会进行指令重排序,上面也说了指令间需要不存在数据依赖性,数据依赖性有以下3种类型:

类型 代码举例 说明
写后读 a=1;b=a 写一个变量后,在读这个变量
写后写 a=1;a=2 写一个变量后,又改变(写)这个变量的值
读后写 b=a;a=2 读一个变量后,又改变(写)这个变量的值
对于上面这三种类型,如果改变代码的执行顺序,很明显执行结果不符合预期。所以,编译器和处理器在重排顺序的时候,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。也就是说:在单线程环境下,指令执行的最终效果应当与器在顺序执行下的效果一致,否则这种优化便会失去意义。这句话有个专业术语叫做as-if-serial语义(有兴趣的同学可查略资料进一步了解)。

听起来指令重排序能提高性能,岂不妙哉,但与此同时也会带来一些问题,很容易想到在多线程情况下,会出现由于指令重排序导致一些非预期结果的出现。 而被volatile修饰的变量,在汇编层指令会对volatile变量操作的指令加一个lock前缀的汇编指令。若变量被修改后,会立刻将变量由工作内存回写到主存中。那么意味了之前的操作已经执行完毕。这就是内存屏障。

可见性

我们首先看看下面的例子来理解变量对所有线程的可见性,我们定义了一个类变量num初始值为0,使用addNum()对num加1,当我们开启N个线程(n>2)去执行addNum(),发现结果num的值并不一定等于N,当调大N时可以明显感知到结果很可能小于N。

/**
 * 测试指令重排序
 */
public class ReadThread {

    private static int num = 0;

    public static void addNum() {
        num++;
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10000; i++) {
            Thread addThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    addNum();
                }
            });
            addThread.start();
        }
        System.out.println(num);

    }

}
复制代码

运行结果不等于N的原因,是因为num++并不是一个原子操作,反编译后可以看到i++的执行指令如下:

imagen.png 各指令含义如下:

  • getstatic:获取指定类的静态域, 并将其压入栈顶
  • iconst_1:将int型1推送至栈顶
  • iadd:将栈顶两int型数值相加并将结果压入栈顶
  • putstatic:为指定类的静态域赋值
  • return:从当前方法返回void

通过上面指令解释大概可以猜测问题出现指令getstatic上,线程获取num值时,没有获取到num被其他线程修改后的最新值,导致最终结果不一致,这里面原因与java内存模型JMM有关,JMM中规定所有的变量都存储在主内存(Main Memory)中,每条线程都有自己的工作内存(Work Memory),线程的工作内存中保存了该线程所使用的变量的从主内存中拷贝的副本。线程对于变量的读、写都必须在工作内存中进行,而不能直接读、写主内存中的变量。同时,本线程的工作内存的变量也无法被其他线程直接访问,必须通过主内存完成。

imagen.png 对于普通共享变量,线程A将变量修改后,体现在此线程的工作内存。在尚未同步到主内存时,若线程B使用此变量,从主内存中获取到的是修改前的值,便发生了共享变量值的不一致,也就是出现了线程的可见性问题,这也是上述代码结果不符合预期的原因。

而当我们给变量num加上volatile后,便可以解决此问题,这是因为当对volatile变量执行写操作后,JMM会把工作内存中的最新变量值强制刷新到主内存,并且写操作会导致其他线程中的缓存无效。使得其他线程从主存中获取到volatile变量的最新值。

volatile为什么没有原子性

Volatile garantiza la consistencia de lectura y escritura. Pero cuando el subproceso 2 ha completado la instrucción de operación con el valor anterior y está a punto de volver a escribirlo en la memoria, no se puede garantizar la atomicidad.

Pequeño ejemplo: cuando se usa git o svn para desarrollar proyectos en el desarrollo diario, hay troncales y ramas. Hay una clase de enumeración utilizada por todo el proyecto. Cuando Xiaohong modifica esta clase, envía inmediatamente el troncal e informa a otros socios pequeños: " Usas esta clase. Debes ponerla en el maletero cuando la necesites", pero en este momento Xiaoming ha completado el desarrollo de la versión anterior y está enviando esta clase, lo que genera un conflicto.

rendimiento volátil de lectura y escritura

El consumo de rendimiento de lectura de volatile es casi el mismo que el de las variables ordinarias, pero la operación de escritura es un poco más lenta porque requiere que se inserten muchas instrucciones de barrera de memoria en el código nativo para garantizar que el procesador no se ejecute fuera de orden. ejecución.

Epílogo

Este artículo presenta el papel de volátil, principalmente incluida la visibilidad y la prohibición del reordenamiento de instrucciones, y las razones de su visibilidad.En cuanto a cómo realizar la prohibición del reordenamiento de instrucciones, no se ha presentado en detalle, involucrando código Java, código de bytes, código fuente Jdk, nivel de ensamblaje, nivel de hardware y otras implementaciones subyacentes, si está interesado, puede verificar la información usted mismo.

Supongo que te gusta

Origin juejin.im/post/7085403237964070942
Recomendado
Clasificación