Java核心技术面试精讲-10 Java内存模型、volatile变量、AQS队列同步器以及并发包java.util.concurrent

jdk5.0一很重要的特性就是增加了并发包java.util.concurrent.*,在说具体的实现类或接口之前,这里先简要说下Java内存模型中的volatile变量及AbstractQueuedSynchronizer(以下简称AQS同步器),这些都是并发包众多实现的基础。

1.Java内存模型

在了解volatile到底是什么东西之前,我们先来了解一下Java的内存模型。
Java内存模型
简单来说,Java的内存模型就是:
1.每个线程都有一个自己独占的工作内存。
2.所有线程工作的时候,都需要和主内存进行交互,这个交互的过程就是load和store的过程,load就是从主内存中取数据,store就是将数据存储到主内存中。

对于交互的过程我们可以详细地划分为八个操作:
1.lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占的状态。
2.unlock(解锁):作用于主内存的变量,把一个处于锁定的变量释放出来,释放变量才可以被其他线程锁定。
3.read(读取):作用于主内存的变量,把一个变量的值从主内存中传输到线程的工作内存中,以便随后的load动作使用。
4.load(载入):作用于工作内存的变量,把read操作从主内存中得到的变量值放入工作内存的变量副本中。

read和load
所谓“线程的工作内存”就是线程栈。上面的read操作和load操作,我理解实际整体是在一个操作步骤中,但是实际上是分为两步:所谓的read应是根据引用地址从堆中读出来;然后load过程,就是在线程运算需要使用此变量时,将变量的具体值(即引用指向的内存地址存储的数据)压入线程栈。
这也就是为什么如果在并发时,同一个变量不加锁会导致结果不可预料的问题。因为不同线程运算时,都会将变量值压入当前线程栈而不是直接对堆上变更直接进行操作,这样就存在一个时间窗口会导致不同的线程对堆中同一个变量值的不同“副本”进行操作,从而导致结果不可预料,也就引用了并发冲突问题。
综上:read为读取、load为压栈。但是他们一般是一个操作的两步,通常会一起进行。

5.use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的字节码指令时会执行这个操作。
6.assign(赋值):作用于工作内存的变量,把一个从执行引擎接收的值赋给工作内存的变量。
7.store(存储):作用于工作内存的变量,把工作内存中的一个变量存储到主内存中,以便随后的write操作使用。
8.write(写入):作用于主内存的变量,把store操作从工作内存中得到的值放入主内存中的变量值中。

store和write的关系类似于read和load。只不过read对应着store,load对应着write。

使用这8个操作时需要注意:
1.read和load,store和write必须成对出现。即从主内存中读数据的数据工作内存必须接受;传递到主内存的数据,也不可以被拒绝写入。
2.进行新赋值的对象必须被写回到主存。
3.未进行新赋值的对象并不允许被写回到主存。
4.新的变量只能在主内存中产生,且未完成初始化的对象不允许在工作内存中使用。
5.对象只允许被一条线程锁定,且可以被此线程多次锁定。
6.未被锁定的对象不允许执行unlock操作。
7.对一个对象执行lock之前,必须将对象写回主内存中。

2.volatile变量

当一个变量被定义为volatile时,它将具备两种特性:
第一是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立刻得知的。普通的变量做不到这一点。
关于volatile的可见性,经常被开发人员误解,认为以下描述成立:“valatile变量对于所有线程是立即可见的”,对于volatile变量所有的写操作都能立刻反应到其他线程之中,换句话说,volatile变量在各个线程之中是一致的,所以基于valatile变量的运算在并发之下是安全的
那么,这个结论真的正确吗?
结论是:这句话的前半部分是正确的,由于合并了read/load/use 以及 assign/store/write为两个独立的原子操作,所以其保证了上面我们说过的修改新值的立刻可见性,即在各个线程之中是一致的,但是其运算在并发之下并不是安全的
看下面这个例子:

public class Test6 {
	public static int a=0;//静态变量
	public static void increase(){//静态方法
		a++;
	}
	public static void main(String[] args){
		Thread t1=new Thread(new Runnable(){
			public void run(){
				for(int i=0;i<10;i++){
					increase();
					System.out.println("i:t1->"+i);
					try{
						Thread.sleep(1000);
					}catch(InterruptedException e){
						e.printStackTrace();
					}
				}
			}
		});
		Thread t2=new Thread(new Runnable(){
			public void run(){
				for(int i=0;i<10;i++){
					increase();
					System.out.println("i:t2->"+i);
					try{
						Thread.sleep(1000);
					}catch(InterruptedException e){
						e.printStackTrace();
					}
				}
			}
		});
		t1.start();
		t2.start();
	}
}

其输出为:

i:t1->0
i:t2->0//这里应该为1
i:t2->1//这里应该为2,下面依次类推
i:t1->1
i:t2->2
i:t1->2
i:t2->3
i:t1->3
i:t2->4
i:t1->4
i:t2->5
i:t1->5
i:t2->6
i:t1->6
i:t2->7
i:t1->7
i:t2->8
i:t1->8
i:t2->9
i:t1->9

通过以上案例可以发现,线程1对i执行了increase()操作后,线程2取到的i并不是最新的值,那么是为什么呢?
我们可以通过Java的内存模型来分析一下原因
在这里插入图片描述
从上图可以看出,线程2在从主存中read数据时,可能发生在线程1执行read、load、use、assign、store、write的任何时刻。假设线程1已经更新了i的值,正处在assign之后,此时正打算执行store操作,还没有写回内存,那么线程2从主存中read的数据肯定不是最新的。
下面我们将a设置为volatile变量,将会发生什么呢?
首先我们先搞清楚volatile的工作机制:
在这里插入图片描述
可以看出volatile的工作机制是:将read,load、use操作封装为一个原子操作,将assign、store、write封装为一个原子操作,那么在原子操作的外部的任何一个时间节点,工作内存和主内存的数据是同步的。
但是volatile依旧不总是线程安全的
比如当前线程1取到的a值为0,正打算执行assign-store-write原子操作,此时线程2读取了数据a(a=0),那么等到当前线程1更新了数据a(a = 1)并写回了主存中,此时线程2也更新了a(a = 1),并且同样执行了assign-store-write原子操作并也写回了主存,那么实际上是两个线程在对a执行简单的更新操作而已,并没有进行所谓的自增操作。
所以其只能保证可见性,并不能保证并发状态下的线程安全。

由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用synchronized或java.util.concurrent中的原子类)来保证原子性。
1.运算结束并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
2.变量不需要与其他的状态变量共同参与不变约束。

3.AbstractQueuedSynchronizer(AQS)队列同步器

3.1 什么是队列同步器

队列同步器AbstractQueuedSynchronizer(以下简称同步器),是用来构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作,并发包的作者(Doug Lea)期望它能够成为实现大部分同步需求的基础。

3.2 同步队列Node的数据结构

队列同步器是以来内部的同步队列(一个FIFO的双向队列)来完成同步状态的管理,同步队列中的节点用来保存获取同步状态失败的线程引用、等待状态、以及前驱节点和后继节点。
在这里插入图片描述
在这个链式队列结构中,没有成功获取同步状态的线程将会成为节点加入到队列的尾部,而队列中的节点获取锁资源是从头部开始的。

3.3 自定义同步组件的设计思路

同步器的主要使用方式就是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态,在抽象方法的实现过程中免不了要对同步状态进行更改,这时就需要使用同步器提供的3个方法(getState()setState(int newState)compareAndSetState(int expect,int update))来进行操作,因为它们能够保证状态的改变是安全的。
同步器是实现锁(也可以是任意同步组件)的关键。可以这样理解:锁是面向使用者的,它定义了使用者与锁交互的接口,却隐藏了实现的细节。同步器面向的是锁的实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程排队等待与唤醒等底层操作。锁和同步器都很好地隔离了使用者和实现者所关注的领域。
同步器的设计是基于模板方法模式的,也就是说,使用者需要继承同步器并重写指定的方法,随后
将同步器组合在自定义同步组件的实现中
,并调用同步器提供的模板方法,而这些模板方法将会调用使用者重写的方法。
重写同步器指定的方法时,需要使用同步器提供的如下3个方法来访问或修改同步状态。
getState():获取当前同步状态。
setState(int newState):设置当前同步状态。
compareAndSetState(int expect,int update):使用CAS设置当前状态,该方法能够保证状态设置的原子性。

4.并发包java.util.concurrent所提供的线程安全容器类

前面我们介绍了 Java 集合框架的典型容器类,它们绝大部分都不是线程安全的,仅有的线程安全实现,比如 Vector在性能方面也远不尽如人意。幸好 Java 语言提供了并发包(java.util.concurrent),为高度并发需求提供了更加全面的工具支持。
Java提供了不同层面的线程安全支持。大部分情况下,我们选择利用并发包提供的线程安全容器类,它提供了:
各种并发容器,比如 ConcurrentHashMap。
各种线程安全队列(Queue/Deque),如 ArrayBlockingQueue、SynchronousQueue等
关于并发容器,在JDK中,有一些线程不安全的容器,也有一些线程安全的容器。并发容器是线程安全容器的一种,但是并发容器强调的是容器的并发性,也就是说不仅追求线程安全,还要考虑并发性,提升在容器并发环境下的性能
加锁互斥的方式确实能够方便地完成线程安全,不过代价是降低了并发性,或者说是串行了。而并发容器的思路是尽量不用锁,比较有代表性的是以CopyOnWrite和Concurrent开头的几个容器。CopyOnWrite容器的思路是在更改容器的时候,把容器写一份进行修改,保证正在读的线程不受影响,这种方式用在读多写少的场景中会非常好,因为实质上是在写的时候重建了一次容器。而以Concurrent开头的容器的具体实现方式则不完全相同,总体来说是尽量保证读不加锁,并且修改时不影响读,所以达到比使用读写锁更高的并发性能。比如上面所说的ConcurrentHashMap,其他的并发容器的具体实现,可直接分析JDK中的源码。

5.ConcurrentHashMap分析

首先,HashTable本身比较低效,因为它的实现方式基本上就是将put、get等方法加上“synchronized”简单来说,这就导致了所有并发操作都要竞争同一把锁,一个线程在进行同步操作时,HashTable会将整个Map给锁住,其他线程只能等待,大大降低了并发操作的效率
ConcurrentHashMap 和 HashTable 类很相似,但 ConcurrentHashMap 能够提供比 HashTable 更好的并发性能。在你从中读取对象的时候 ConcurrentHashMap 并不会把整个 Map 锁住。此外,在你向其中写入对象的时候,ConcurrentHashMap 也不会锁住整个 Map。它的内部只是把 Map 中正在被写入的部分进行锁定。容器有多把锁,每一把锁用于锁住容器中一部分数据,多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效提高并发访问率,这就是ConcurrentHashMap的锁分段技术。将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一段数据的时候,其他段的数据也能被其他线程访问。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值