一、Java共享内存模型带来的线程安全问题
代码示例:
public class SyncDemo {
private static int counter=0;
public static void increment(){
counter++;
}
public static void decrement(){
counter--;
}
public static void main(String[] args) throws InterruptedException {
Thread t1= new Thread(()->{
for (int i = 0; i < 5000; i++) {
increment();
}
},"t1");
Thread t2 =new Thread(()->{
for (int i = 0; i < 5000; i++) {
decrement();
}
},"t2");
t1.start();
t2.start();
//t1 t2执行完以后 main线程再执行
t1.join();
t2.join();
System.out.println("静态变量counter的值:"+counter);
}
}
以上的结果可能是正数、负数、零。因为 Java 中对静态变量的自增,自减并不是原子操作如果是单线程以上代码是顺序执行(不会交错)没有问题。但多线程下代码可能交错运行!
1.临界区(Critical Section)
一个程序运行多个线程本身是没有问题的,多个线程访问共享资源多个线程读共享资源其实也没有问题问题在多个线程对共享资源读写操作时发生指令交错,就会出现问题,一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区,其共享资源为临界资源。
2.竞态条件(Race Condition )
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
为了避免临界区的竞态条件发生,有多种手段可以达到目的:
阻塞式的解决方案:synchronized,Lock
非阻塞式的解决方案:原子变量(cas)
备注:
java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的: 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码,同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点
二、synchronized的使用
synchronized 同步块是 Java 提供的一种原子性内置锁,Java 中的每个对象都可以把它当作一个同步锁来使用,这些 Java 内置的使用者看不到的锁被称为内置锁,也叫作监视器锁。
1.加锁方式
synchronized 实际是用对象锁保证了临界区内代码的原子性
2.synchronized底层原理
synchronized是JVM内置锁,基于Monitor机制实现,依赖底层操作系统的互斥原语Mutex(互斥量),它是一个重量级锁,性能较低。JVM内置锁在1.5之后版本做了重大优化,如:锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、自适应自旋(Adaptive Spinning)等技术来减少锁操作的开销,内置锁的并发性能已经基本与Lock持平。
java虚拟机通过一个同步结构支持方法和方法中的指令序列的同步:monitor。 同步方法是通过方法中的access_flags中设置ACC_SYNCHRONIZED标志来实现;同步代码块是通过monitorenter和monitorexit来实现。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。
Monitor(管程/监视器)
Monitor,直译为“监视器”,而操作系统领域一般翻译为“管程”。管程是指管理共享变量以及对共享变量操作的过程,让它们支持并发。在Java 1.5之前,Java语言提供的唯一并发语言 就是管程,Java 1.5之后提供的SDK并发包也是以管程为基础的。除了Java之外,C/C++、C#等 语言也都是支持管程的。synchronized关键字和wait()、notify()、notifyAll()这三个方法是 Java中实现管程技术的组成部分。
MESA模型
管程的发展史上,先后出现过三种不同的管程模型,分别是Hasen模型、Hoare模型和 MESA模型。现在正在广泛使用的是MESA模型
等待队列:线程超过资源数,大量线程过来,假设只有一个资源可以访问,只有一个线程能获取到资源,其他线程就都放入队列,进入阻塞状态
条件变量:解决同步问题,可能业务逻辑执行需要另外一个结果,进入wait释放锁资源,另外一个线程执行完以后调用notify进行唤醒
条件队列:存放的释放了锁的线程,进入等待,等待唤醒
wait()的使用:
一般最好加上while条件,如果条件不满足,继续wait阻塞。避免类似notifyAll()虚假唤醒
whil(condition does not hold) {
obj.wait(timeout);
唤醒的时间和获取到锁继续执行的时间是不一致的,被唤醒的线程再次执行时可能条件又不满足了,所以循环检验条件。MESA模型的wait()方法还有一个超时参数,为了避免线程进入等待队列永久阻塞。
3.Java语言的内置管程synchronized
Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简。MESA 模型中,条件变量可以有多个,Java 语言内置的管程里只有一个条件变量。模型如下图所示。
在获取锁时,是将当前线程插入到cxq的头部,而释放锁时,默认策略(QMode=0)是:如果EntryList为空,则将 cxq中的元素按原有顺序插入到EntryList,并唤醒第一个线程,也就是当EntryList为空时,是后来的线程先获取 锁。_EntryList不为空,直接从_EntryList中唤醒线程。
相当于有两个入口等待队列,一个条件队列
public class SyncQModeDemo {
public static void main(String[] args) throws InterruptedException {
SyncQModeDemo demo = new SyncQModeDemo();
demo.startThreadA();
//控制线程执行时间
Thread.sleep(100);
demo.startThreadB();
Thread.sleep(100);
demo.startThreadC();
}
final Object lock= new Object();
private void startThreadA(){
new Thread(()->{
synchronized (lock){
System.out.println("A get Lock");
try {
// Thread.sleep(300);
lock.wait(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("A release Lock");
}
},"thread-A").start();
}
private void startThreadB() {
new Thread(()->{
synchronized (lock){
try {
System.out.println("B get Lock");
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("B release Lock");
}
},"thread-B").start();
}
private void startThreadC(){
new Thread(()->{
synchronized (lock){
System.out.println("C get Lock");
}
},"thread-C").start();
}
}
示例结论:A线程进行wait()的时候B获取到锁,B执行完以后释放锁,A线程此时进入的EntryList队列,优先获取到锁,继续执行,最后释放锁,C线程在cxq队列里面,最后获取锁
示例:模拟三个线程竞争的情况,A B C依次进入
示例结论:A最开始获取锁,cxq是栈结构,在EntryList队列里面没有被唤醒的线程情况下,从cxq队列里面获取,所以C线程先获取到锁,最后是B
Synchronized加锁是加在对象上的,锁对象是如何记录锁状态的?
对象的内存布局
Hotspot虚拟机中,对象在内存中存储的布局可以分为三块区域:
对象头(Header):
比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID, 偏向时间,数组长度(数组对象才有)等
对象头中又有MarkWord(8字节) 元数据指针(4字节) 数组长度(4字节)
实例数据(Instance Data,对象的属性):
存放类的属性数据信息,包括父类的属性信息
对齐填充(Padding):
由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐
对象头详解
Mark Word
用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(占4位)、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称它为“Mark Word”。
Klass Pointer
对象头的另外一部分是klass类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指 针来确定这个对象是哪个类的实例。32位4字节,64位开启指针压缩或最大堆内存<32g时4字 节,否则8字节。jdk1.8默认开启指针压缩后为4字节,当在JVM参数中关闭指针压缩(-XX:- UseCompressedOops)后,长度为8字节。
数组长度(只有数组对象有)
如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度。 4字节
解释:如果是new Object(),这个对象 8字节MarkWord 4个字节元数据指针(一般压缩后),不是数组对象,无实例数据,必须是8的倍数,对齐填充4字节,这个对象大小一共16字节。
64位JVM下的对象结构描述
MarkWord 表 分代年龄一共占4bit,意味着GC的时候 这里最多存到15,这也是为什么GC模型中,会有一个GC15次后进入老年代。
4.锁
偏向锁
偏向锁是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了消除数据在无竞争情况下锁重入(CAS操作)的开销而引入偏向锁。对于没有锁竞争的场合,偏向锁有很好的优化效果
偏向锁延迟偏向
偏向锁模式存在偏向锁延迟机制:HotSpot 虚拟机在启动后有个 4s 的延迟才会对每个新建的对象开启偏向锁模式。JVM启动时会进行一系列的复杂活动,比如装载配置,系统类初始化等等。在这个过程中会使用大量synchronized关键字对对象加锁,且这些锁大多数都不是偏向锁。
为了减少初始化时间,JVM默认延时加载偏向锁。
//关闭延迟开启偏向锁
‐XX:BiasedLockingStartupDelay=0
//禁止偏向锁
‐XX:‐UseBiasedLocking
//启用偏向锁
‐XX:+UseBiasedLocking
如果关闭延迟偏向,直接启用了偏向锁模式,新创建对象的Mark Word中的Thread id为0,此时处于可偏向状态但未偏向任何线程,也叫作匿名偏向状态(anonymously biased)
偏向锁撤销
调用对象的HashCode
1.轻量级锁会在锁记录中记录 hashCode
2.重量级锁会在 Monitor 中记录 hashCode
3.当对象处于可偏向(也就是线程ID为0)和已偏向的状态下,调用HashCode计算将会使对象再也无法偏向
4.当对象可偏向时,MarkWord将变成未锁定状态,并只能升级成轻量锁
5.当对象正处于偏向锁时,调用HashCode将使偏向锁强制升级成重量锁
轻量级锁
倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段,此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁所适应的场景是线程交替执行同步块的场合(轻微的竞争),如果存在同一时间多个线程访问同一把锁的场合,就会导致轻量级锁膨胀为重量级锁
Synchronized 为了进行了优化,不直接切换到内核态进行park,偏向锁和轻量级锁都是操作对象的Mark Word ,其实都是在用户态进行的,可以用CAS 操作,不存在性能问题,如果到了内核态,代价就会非常高,就是重量级锁。