对初学者来讲,在多线程环境下编程,为了保证线程安全我们想到最直接办法是给代码加锁。使用synchronized关键字修饰一段代码。
我们给出最常见的demo,创建100个线程对全局变量count进行自增操作。
int count = 0;
public void addOperator(){
for(int i= 0;i<100;i++ ){
new Thread(){
@Override
public void run() {
count++;
}
}.start();
}
}
@Test
public void testAddOperator(){
addOperator();
System.out.print(count);
}
我们执行单元测试,查看结果发现有时输出100有时输出99.这是什么情况,为什么同样的代码每次的执行的结果却不同。
这引出java 内存模型(java memory model),线程如何协同执行的。
当创建一个线程时,jvm 会开辟一块内存区域存放他。 这块内存区域又被划分成5个不同数据区域:java栈、本地方法栈、方法区、堆、程序计数器.
整体上。分为三部分:栈,堆,程序计数器,他们每一部分有其各自的用途;虚拟机栈保存着每一条线程的执行程序调用堆栈;堆保存着类对象、数组的具体信息;程序计数器保存着每一条线程下一次执行指令位置。这三块区域中栈和程序计数器是线程私有的。也就是说每一个线程拥有其独立的栈和程序计数器。
java并发内存模型以及内存操作规则
java内存模型中规定了所有变量都存贮到主内存(如虚拟机物理内存中的一部分)中。每一个线程都有一个自己的工作内存(如cpu中的高速缓存)。线程中的工作内存保存了该线程使用到的变量的主内存的副本拷贝。线程对变量的所有操作(读取、赋值等)必须在该线程的工作内存中进行。不同线程之间无法直接访问对方工作内存中变量。线程间变量的值传递均需要通过主内存来完成。
关于主内存与工作内存之间的交互协议,即一个变量如何从主内存拷贝到工作内存。如何从工作内存同步到主内存中的实现细节。java内存模型定义了8种操作来完成。这8种操作每一种都是原子操作。8种操作如下:
- lock(锁定):作用于主内存,它把一个变量标记为一条线程独占状态;
- unlock(解锁):作用于主内存,它将一个处于锁定状态的变量释放出来,释放后的变量才能够被其他线程锁定;
- read(读取):作用于主内存,它把变量值从主内存传送到线程的工作内存中,以便随后的load动作使用;
- load(载入):作用于工作内存,它把read操作的值放入工作内存中的变量副本中;
- use(使用):作用于工作内存,它把工作内存中的值传递给执行引擎,每当虚拟机遇到一个需要使用这个变量的指令时候,将会执行这个动作;
- assign(赋值):作用于工作内存,它把从执行引擎获取的值赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的指令时候,执行该操作;
- store(存储):作用于工作内存,它把工作内存中的一个变量传送给主内存中,以备随后的write操作使用;
- write(写入):作用于主内存,它把store传送值放到主内存中的变量中。
我的理解是线程操作是线程读取(read)主线程的变量,加载(load)到线程栈中。使用(use)这个局部变量并赋值(assign)。再存储(store),线程栈同步到主线程。
多线程操作存在线程读取主线程的变量并不是最新的情况。线程读取主线程的变量后,而这个变量却被其他的线程修改。
锁的释放和获取的内存语义
锁是Java并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的
线程向获取同一个锁的线程发送消息。当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的 临界区代码必须从主内存中读取共享变量
对比锁释放-获取的内存语义与volatile写-读的内存语义可以看出:锁释放与volatile写有 相同的内存语义;锁获取与volatile读有相同的内存语义。
Synchronized 实现原理
synchronized是基于Monitor来实现同步的。
Monitor从两个方面来支持线程之间的同步:
-
互斥执行
-
协作
1、Java 使用对象锁 ( 使用 synchronized 获得对象锁 ) 保证工作在共享的数据集上的线程互斥执行。
2、使用 notify/notifyAll/wait 方法来协同不同线程之间的工作。
3、Class和Object都关联了一个Monitor。
继续深入学习前,了解两个重要概念。
Java对象头、monitor
Java对象头和monitor是实现synchronized的基础。
synchronized 用的锁存在java对象头里。Hotspot 虚拟机的对象头主要包括两部分数据 标记字段 mark word , 类型指针klass pointer。 klass pointer 是描述对象指向它的类元数据指针。虚拟机通过这个指针确认这个对象是哪个类的实例。
mark word 记录对象的运行时数据。它是实现轻量级锁和偏向锁关键。
mark word 记录对象自身的运行时数据, 比如hashCode GC分代年龄,锁状态标志,线程持有的锁,偏向线程id,偏向时间戳。
java 对象头一般占用2个字节码。
什么叫monitor, 一种同步工具。
与一切皆对象一样,所有的java对象都是天生的monitor,
在java设计有看不见的锁,monitor锁。
Monitor 是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。
每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。其结构如下:
Owner:初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;
EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。
RcThis:表示blocked或waiting在该monitor record上的所有线程的个数。
Nest:用来实现重入锁的计数。
HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。
Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁。
Monitor 工作原理
- 当一个线程进入同步代码,为了继续执行临界代码,必须争抢Monitor锁,如果获得锁成功,则成为该对象监视器使用者。对象的监视器只属于一个活动线程。Monitor对象的Owner设置成线程标志。
- 拥有监视器对象的线程,可以调用wait进入waitSet。释放当前Monitor对象锁Owner, 进入等待状态。
- 其他线程调用该对象的notify/notifyAll方法,可以释放waitSet集合的线程。
- 同步代码执行完毕后,线程退出临界区,并释放监视锁。Monitor 对象Owner 设置为null。
synchonzied 具体实现有
锁优化
jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
自旋锁: