Sysnchrnoized

共享内存的线程安全问题

多线程并发的条件下,对共享变量的操作可能不是预期的结果

举例,对counter变量进行++和--的操作,最终输出counter的值

private static volatile 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 < 50000; i++) {
                increment();
            }
        }, "t1");

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                decrement();
            }
        }, "t2");

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.info("counter={}", counter);


    }

同一份代码,可以跑出结果,很显然是不符合预期和实际要求的。

分析多线程下运行产生以上结果的原因

1.产生结果1的原因是使用自增操作的次数多次覆盖了自减次数的结果

2.产生结果2的原因是自增次数操作和自减次数操作一样

3.产生结果3的原因是使用自减操作的次数多次覆盖了自增次数的结果

临界区

指的是程序中被多线程使用对共享资源的读写操作代码块

    //临界区1
    public static void increment() {
        counter++;
    }
    //临界区2
    public static void decrement() {
        counter--;
    }

临界资源

指的是程序中被多线程进行读写的共享资源。

//临界资源
private static volatile int counter = 0;

竞态条件

指的是多个线程在临界区执行,操作了临界资源,导致代码的执行结果无法预测的现象。

解决方式

  • 阻塞式:使用syschronized、Lock进行加锁
  • 非阻塞式:使用原子级别的变量进行操作

syschronized的解决方式

syschronized同步块是原子性的内置锁操作

加锁方式

分类

具体分类

被锁对象

代码

方法

实例方法

类的实例对象

publib void syschrnozied method(){}

静态方法

类对象

publib static void syschrnozied method(){}

代码块

实例对象

类的实例对象

syschrnozied(this){}

class对象

类对象

syschrnozied(A.class){}

任意实例对象Object

实例对象Object

String lock = "";

syschrnozied(lock){}

Synchronized原理

Synchronized是JVM内置锁,基于Monitor机制实现,依赖底层操作系统的互斥原语Mutex(互斥量),它是一个重量级锁,性能较低。当然,JVM内置锁在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、自适应自旋(Adaptive Spinning)等技术来减少锁操作的开销,内置锁的并发性能已经基本与Lock持平。

Monitor

管程是指管理共享变量以及对共享变量操作的过程,让它们支持并发。

MESA模型

在管程的发展史上,先后出现过三种不同的管程模型,分别是Hasen模型、Hoare模型和MESA模型。现在正在广泛使用的是MESA模型。

MESA支持并发的处理过程

  1. 判断是否满足条件变量,是则进入入口等待队列;
  2. 不满足则进入条件变量等待队列
  3. 进入入口等待队列后,若队列中只有一个线程,则访问共享变量,否则等待。

管程中引入了条件变量的概念,而且每个条件变量都对应有一个等待队列。条件变量和等待队列的作用是解决线程之间的同步问题。

wait()的使用

唤醒的时间和获取到锁继续执行的时间是不一致的,被唤醒的线程再次执行时可能条件又不满足了,所以循环检验条件。MESA模型的wait()方法还有一个超时参数,为了避免线程进入等待队列永久阻塞。

while(条件不满足){
     wait();
}

notify()和notifyAll()分别何时使用

满足以下三个条件时,可以使用notify(),其余情况尽量使用notifyAll():

1. 所有等待线程拥有相同的等待条件;

2. 所有等待线程被唤醒后,执行相同的操作;

3. 只需要唤醒一个线程。

Java语言的内置管程synchronized

它参考了MESA模型,但Syschronized只有一个条件变量。

Monitor机制的实现

java.lang.Object 类定义了 wait(),notify(),notifyAll() 方法,这些方法的具体实现,依赖于 ObjectMonitor 实现,这是 JVM 内部基于 C++ 实现的一套机制。

ObjectMonitor 中的数据结构

ObjectMonitor() { 
 _header = NULL; //对象头 markOop 
 _count = 0; 
 _waiters = 0, 
 _recursions = 0; // 锁的重入次数 
 _object = NULL; //存储锁对象 
 _owner = NULL; // 标识拥有该monitor的线程(当前获取锁的线程)
 _WaitSet = NULL; // 等待线程(调用wait)组成的双向循环链表,_WaitSet是第一个节点 
 _WaitSetLock = 0 ; 
 _Responsible = NULL ; 
 _succ = NULL ; 
 _cxq = NULL ; //多线程竞争锁会先存到这个单向链表中 (FILO栈结构) 
 FreeNext = NULL ; 
 _EntryList = NULL ; //存放在进入或重新进入时被阻塞(blocked)的线程 (也是存竞争锁失败的线程) 
 _SpinFreq = 0 ; 
 _SpinClock = 0 ; 
 OwnerIsThread = 0 ; 
 _previous_owner_tid = 0; 
 }

个人理解,_cxq相当于MESA模型中的入口等待队列、_EntryList则相当于条件等待队列,而_WaitSet则是调用wait后的另一个条件等待队列

在获取锁时,是将当前线程插入到cxq的头部,而释放锁时,默认策略(QMode=0)是:如果EntryList为空,将

cxq中的元素按原有顺序插入到EntryList,并唤醒第一个线程,也就是当EntryList为空时,是后来的线程先获取锁。_EntryList不为空,直接从_EntryList中唤醒线程。

对象的内存布局

Hotspot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据 (Instance Data)和对齐填充(Padding)。

  • 对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象才有)等。
  • 实例数据:存放类的属性数据信息,包括父类的属性信息;
  • 对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

对象头

对象头主要由三部分组成

  • Mark Word: 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称它为“Mark Word”。
  • Klass Pointer :对象头的另外一部分是klass类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 32位4字节,64位开启指针压缩或最大堆内存<32g时4字节,否则8字节。jdk1.8默认开启指针压缩后为4字节,当在JVM参数中关闭指针压缩(-XX:- UseCompressedOops)后,长度为8字节。
  • 数组长度(只有数组对象有) :如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度,占 4字节

对象中锁状态

偏向锁

大多数情况下,锁对象都是由同一个线程获取,就是说不存在竞争,故不需要使用CAS进行自旋竞争,新创建的对象处因为没有线程ID(即对该对象加锁的线程),它处于一个可偏向但未偏向的状态,也称为匿名偏向;偏向锁有一个偏向锁延迟机制,因为JVM启动会有类装载和初始化操作,此时会有大量的syschronized加锁操作,为了减少初始化时间,在Hotspot启动后4秒才会对新建的对象开启偏向锁模式,jdk1.6是默认开启的。

相关参数

//关闭延迟开启偏向锁 
‐XX:BiasedLockingStartupDelay=0 
//禁止偏向锁 
 ‐XX:‐UseBiasedLocking 
//启用偏向锁 
‐XX:+UseBiasedLocking

调用锁对象的obj.hashCode()或System.identityHashCode(obj)方法会导致该对象的偏向锁被撤销,因为对于一个对象,其HashCode只会生成一次并保存,再次调用会因为要保存hashcode导致锁状态变为无锁。

hashcode的记录

轻量级锁会在锁记录中记录 hashCode

重量级锁会在 Monitor 中记录 hashCode

当对象处于可偏向(也就是线程ID为0)和已偏向的状态下,调用HashCode计算将会使对象再也无法偏向:

当对象可偏向时,MarkWord将变成未锁定状态,并只能升级成轻量锁。

当对象正处于偏向锁时,调用HashCode将使偏向锁强制升级成重量锁。

轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段,此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间多个线程访问同一把锁的场合,就会导致轻量级锁膨胀为重量级锁。

轻量级锁的产生是由于轻微竞争引起,若已有线程偏向,再有线程进来;竞争不大的情况下会对象中锁的状态会升级为轻量级。

轻量级锁膨胀流程

  • 调用omAlloc分配一个ObjectMonitor对象,先从线程私有的monitor集合omFreeList中分配对象,若集合中没有,从JVM的gFreeList中分配一批monitor到omFreeList中
  • 初始化monitor对象
  • 设置状态为膨胀中INFLATING
  • 设置monitor的header字段为displaced word ,owner为Lock Record,obj为锁字段
  • 设置锁对象头的MarkWord的锁状态为重量级锁并指向monitor对象

无锁膨胀流程

  • 调用omAlloc分配一个ObjectMonitor对象
  • 初始化monitor对象
  • 设置monitor的header字段为markword ,owner为null,obj为锁字段
  • 设置锁对象头的MarkWord的锁状态为重量级锁并指向monitor对象

偏向锁批量----重偏向\撤销

单线程重复对对象进行加锁时,偏向锁性能损耗不大;但当多线程竞争频繁的情况下,需要在安全点,将对象的偏向锁撤销为无锁或升级为轻量级锁,这个操作会消耗一定的性能。

原理

  • 每个class对象中有一个偏向锁撤销计数器,记录了对象发生撤销操作的次数,当次数达到阈值(20),该对象的偏向就会被判定有问题,从而执行批量重偏向。
  • 每个class对象有一个epoch字段,类中每个对象的对象头中MarkWord里也有一个epoch字段,每次发生批量重偏向时,该值+1,同时遍历jvm中的所有线程栈,更新该class处于偏向锁的epoch的值。
  • 下次获取锁时,若class和对象MarkWord中的epoch值不等,不撤销直接进行偏向,通过CAS将其MarkWord中的threadid改为当前线程ID。
  • 当达到重偏向阈值(默认20)后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级锁的逻辑。

作用

批量重偏向(bulk rebias)机制是为了解决:一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作。

批量撤销(bulk revoke)机制是为了解决:在明显多线程竞争剧烈的场景下使用偏向锁是不合适的。

小结

1. 批量重偏向和批量撤销是针对类的优化,和对象无关。

2. 偏向锁重偏向一次之后不可再次重偏向。

3. 当某个类已经触发批量撤销机制后,JVM会默认当前类产生了严重的问题,剥夺了该类的新实例对象使用偏向锁的权利

自旋优化

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
  • 在 Java 6 之后自旋是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,比较智能。
  • Java 7 之后不能控制是否开启自旋功能注意:自旋的目的是为了减少线程挂起的次数,尽量避免直接挂起线(挂起操作涉及系统调用,存在用户态和内核态切换,这才是重量级锁最大的开销)。

锁粗化

假设一系列的连续操作都会对同一个对象反复加锁及解锁,甚至加锁操作是出现在循环体中的,即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果JVM检测到有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部。

锁消除

锁消除即删除不必要的加锁操作。锁消除是Java虚拟机在JIT编译期间,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间,简单来说是消除没有逃逸的对象的锁。

* 锁消除 
* ‐XX:+EliminateLocks 开启锁消除(jdk8默认开启) 
* ‐XX:‐EliminateLocks 关闭锁消除

逃逸分析(Escape Analysis)

逃逸分析,是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。逃逸分析的基本行为就是分析对象动态作用域。

方法逃逸(对象逃出当前方法)

当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。

线程逃逸((对象逃出当前线程)

这个对象甚至可能被其它线程访问到,例如赋值给类变量或可以在其它线程中访问的实例变量。

使用逃逸分析,编译器可以对代码做如下优化:

1.同步省略或锁消除(Synchronization Elimination)。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。

2.将堆分配转化为栈分配(Stack Allocation)。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。

3.分离对象或标量替换(Scalar Replacement)。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值