从Java内存模型理解synchronized、volatile和final关键字

        你是否真正理解并会用volatile, synchronized, final进行线程间通信吗,如果你不能回答下面的几个问题,那就说明你并没有真正的理解:

        1、对volatile变量的操作一定具有原子性吗?(原子操作是不需要synchronized来保护的,所谓原子操作是指不会被线程调度机制打断的操作,这种操作一旦开始,就一直运行到结束,中间不会有任何线程切换)

        2、synchronized所谓的加锁,锁住的是什么?

        3、final定义的变量不变的到底是什么?

 
 
 

1、Java内存模型

 

        看Java内存模型之前,我们先来了解什么是内存模型?

        在多处理器系统中,处理器通常有多级缓存,因为这些缓存离处理器更近并且可以存储一部分数据,所以缓存可以改善处理器获取数据的速度和减少对共享内 存数据总线的占用。缓存虽然能极大的提高性能,但是同时也带来了诸多挑战。例如,当两个处理器同时操作同一个内存地址的时候,该如何处理?这两个处理器在 什么条件下才能看到相同的值?

        对于处理器而言,一个内存模型就是定义一些充分必要的规范,这些规范使得其他处理器对内存的写操作对当前处理器可见,或者当前处理器的写操作对其他处理器可见。

        其他处理器对内存的写一定发生在当前处理器对同一内存的读之前,称之为其他处理器对内存的写对当前处理器可见。

        知道了内存模型,那么应该可以更好的理解java内存模型。简单的讲,java内存模型指的就是一套规范,现在最新的规范为JSR-133。这套规范包含:

        1、线程之间如何通过内存通信;

        2、线程之间通过什么方式通信才合法,才能得到期望的结果。

        我们已经知道 java 内存模型就是一套规范,那么在这套规范中,规定的内存结构是什么样的呢?java内存模型中的内存结构如下图所示:

        简单的讲,Java 内存模型将内存分为共享内存和本地内存。共享内存又称为堆内存,指的就是线程之间共享的内存,包含所有的实例域、静态域和数组元素。每个线程都有一个私有的,只对自己可见的内存,称之为本地内存

        共享内存中共享变量虽然由所有的线程共享,但是为了提高效率,线程并不直接使用这些变量,每个线程都会在自己的本地内存中存储一个共享内存的副本,使用这个副本参与运算。由于这个副本的参与,导致了线程之间对共享内存的读写存在可见性问题

2、并发编程三大特性

 
        在并发编程中,我们通常会遇到以下三个问题:  原子性问题   可见性问题   有序性问题 
 
        原子性: 即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
 
        举个最简单的例子,大家想一下,假如为一个32位的变量赋值过程不具备原子性的话,会发生什么后果?
int i = 9;

        假若一个线程执行到这个语句时,我们暂且假设为一个32位的变量赋值包括两个过程:为低16位赋值,为高16位赋值。那么就可能发生一种情况:当将低16位数值写入之后,突然被中断,而此时又有一个线程去读取 i 的值,那么读取到的就是错误的数据,导致 数据不一致性 问题。

 
       可见性:是指当多个线程访问同一个共享变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
举个简单的例子:
//线程1执行的代码
int i = 1;
i = 100;


//线程2执行的代码
int j = i;

        假若执行线程1的是CPU的第1个核,执行线程2的是CPU的第2个核。由上面的分析可知,当 线程1执行i=100这句时,会先把i的初始值加载到核1的高速缓存中,然后赋值为100,那么在核1的高速缓存当中i的值变为100了,却没有立即写入到主存当中。此时,核2执行j=i,它会先去主存读取i的值并加载到核2的缓存当中,注意此时内存当中i 的值还是1,那么就会使得j的值为1,而不是100。

       这就是可见性问题,线程1 对变量 i 修改了之后,线程2 没有立即看到 线程1 修改后的值。

       有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子:

int i = 0;              
Long temp = 0.1L;
i = 1;                //语句1  
temp = 0.2L;          //语句2

 

        上面代码定义了一个 int型变量,定义了一个Long型变量,然后分别对两个变量进行赋值操作。从写的代码顺序上看,语句1 是在语句2前面的,但是JVM 在真正执行这段代码的时候并不会保证语句1一定会在语句2 前面执行,这里可能会发生 指令重排序(Instruction Reorder)。

        下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的(单线程情形下)

      虽然重排序不会影响单个线程内程序执行的结果,但是多线程就会。

//线程1:
boolean finished = false;
int temp = 0;
....
temp = 1;  					 //语句1
finished = true;             //语句2


//线程2:
while(!finished ){
  sleep()
}
add(temp, 10);

       上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此时线程2会以为工作已经完成,那么就会跳出 while循环 ,去执行add(temp, 10)方法,就会导致程序出错。由此可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。也就是说,要想使并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

 

那么基于Java的内存模型怎样实现以上三大并发特性呢?

对于原子性,在 Java 中,对基本数据类型的变量的读取 和 赋值 操作是原子性操作,即这些操作是不可被中断的: 要么执行,要么不执行。也就是说Java内存模型只保证了基本类型数据的读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过 synchronized 和 Lock 来实现。由于 synchronized 和 Lock 能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

对于可见性,Java 提供了 volatile关键字 来保证可见性。当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。另外,通过 synchronized 和 Lock 也能够保证可见性,synchronized 和 Lock 能保证同一时刻只有一个线程获取锁然后执行同步代码,并且 在释放锁之前会将对变量的修改刷新到主存当中,因此可以保证可见性

 

对于有序性。Java内存模型允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。在 Java 中,可以通过 volatile 关键字来保证一定的“有序性”。另外,我们千万不能想当然地认为,可以通过synchronized 和 Lock 来保证有序性,也就是说,不能由于 synchronized 和 Lock 可以让线程串行执行同步代码,就说它们可以保证指令不会发生重排序,这根本不是一个粒度的问题


        为了方便线程之间的通信,java 提供了 volatile, synchronized, final 三个关键字供我们使用,下面我们来看看如何使用它们进行线程间通信。

 

3、volatile关键字

 

     一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰后,那么就具备了两层语义:

  1)保证了不同线程对共享变量进行操作时的可见性,即一个线程修改了某个变量的值,这个新值对其他线程来说是 立即可见 的;

  2)禁止进行指令重排序;

        一个线程对 volatile 变量的读一定能看见在它之前最后一个线程对这个变量的写。

        为了实现这些语义,Java 规定,(1)当一个线程要使用共享内存中的 volatile 变量时,如图中的变量a,它会直接从主内存中读取,而不使用自己本地内存中的副本。(2)当一个线程对一个 volatile 变量进行写时,它会将这个共享变量的值刷新到共享内存中。

        我们可以看到,其实 volatile 变量保证的是一个线程对它的写会立即刷新到主内存中,并置其它线程的副本为无效,但它并不保证对 volatile 变量的操作都是具有原子性的。

 

public void add(){
     a++;         #1
 }

        等价于

 

 

public void add() {   
    temp = a;        
    temp = temp +1;  
    a = temp;         
 }

        代码1并不是一个原子操作,所以类似于 a++ 这样的操作会导致并发数据问题。

 

        volatile 变量的写被保证是可以被之后其他线程的读看到的,因此我们可以利用它进行线程间的通信。如:

 

volatile int a;
public void set(int b) {
    a = b; 
}
public void get() {    
    int i = a; 
}

        线程A执行set()后,线程B执行get(),相当于线程A向线程B发送了消息。

 

 

4、synchronized

 

        如果我们非要使用 a++ 这种复合操作进行线程间通信呢?java 为我们提供了synchronized。

public synchronized void add() {
    a++; 
 }

 

        synchronized 使得它作用范围内的代码对于不同线程是互斥的,并且线程在释放锁的时候会将共享变量的值刷新到主内存中。

 

        我们可以利用这种互斥性来进行线程间通信。看下面的代码:

 

public synchronized void add() {
    a++; 
}
public synchronized void get() {  
    int i = a; 
}

 

        当线程A执行 add(),线程B调用get(),由于互斥性,线程A执行完add()后,线程B才能开始执行get(),并且线程A执行完add(),释放锁的时候,会将a的值刷新到共享内存中。因此线程B拿到的a的值是线程A更新之后的。

 

 

5、volatile和synchronized的比较

 

        根据以上的分析,我们可以发现volatile和synchronized有些相似。

 

        1、当线程对 volatile变量写时,java 会把值刷新到共享内存中;而对于synchronized,指的是当线程释放锁的时候,会将共享变量的值刷新到主内存中。

        2、线程读取volatile变量时,会将本地内存中的共享变量置为无效;对于synchronized来说,当线程获取锁时,会将当前线程本地内存中的共享变量置为无效。

        3、synchronized 扩大了可见影响的范围,扩大到了synchronized作用的代码块。

        关键字volatile 与内存模型紧密相关,是线程同步的轻量级实现,其性能要比 synchronized关键字 好。在作用对象和作用范围上,volatile 用于修饰变量,而 synchronized关键字 用于修饰方法和代码块,而且 synchronized 语义范围不但包括 volatile拥有的可见性,还包括volatile 所不具有的原子性,但不包括 volatile 拥有的有序性,即允许指令重排序。因此,在多线程环境下,volatile关键字 主要用于及时感知共享变量的修改,并保证其他线程可以及时得到变量的最新值。

6、final变量

 

        final关键字可以修饰变量、方法和类,我们这里只讨论final修饰的变量。final变量的特殊之处在于:

 

        final 变量一经初始化,就不能改变其值。

        这里的值对于一个对象或者数组来说指的是这个对象或者数组的引用地址。因此,一个线程定义了一个final变量之后,其他任意线程都可以拿到这个变量。但有一点需要注意的是,当这个final变量为对象或者数组时,

        1、虽然我们不能将这个变量赋值为其他对象或者数组的引用地址,但是我们可以改变对象的域或者数组中的元素

        2、线程对这个对象变量的域或者数据的元素的改变不具有线程可见性。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值