Java内存模型和线程安全

Posted 热爱编程的大忽悠

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java内存模型和线程安全相关的知识,希望对你有一定的参考价值。

Java内存模型和线程安全

Java内存模型

引言

对于多核处理器而言,每个核都会有自己单独的高速缓存,又因为这多个处理器共享同一块主内存,为了在并行运行的情况下,包装各个缓存中缓存的结果的一致性,需要引用缓存一致性协议。

注意: 处理器只和自己的高速缓存交换,如果修改了高速缓存中的数据,就需要同步回主内存,并且通过缓存一致性协议让其他核的高速缓存失效。

高速缓存的出现主要是为了解决CPU运算速度和主内存速度不匹配而引入的缓冲模块


上图是java的内存模型,Java线程的数据读写都只能从工作内存获取,不同线程的工作内存是隔离的、

此处的工作内存主要对应线程私有的虚拟机栈部分,而主内存则对应Java堆中的对象实例数据部分。

同样JVM也必须通过一种一致性协议来保证多个工作内存间的数据一致性问题。


volatile关键字

volatile具有两个作用:

  • volatile关键字修饰的变量对所有线程立马可见
  • 禁止指令重排优化

为什么volatile变量具有上面两个作用呢? 是因为Java内存模型中对volatile变量定义了特殊处理规则:

  • 每次使用volatile变量前都必须从主内存中获取最新结果
  • 每次修改volatile变量后都必须立刻同步到主内存中
  • volatile修饰的变量不会被指令重排序优化

目前还存在两个问题,我们依次来解决一下:

  • 防止指令重排序如何实现的 ?

对应汇编代码:

volatile修饰的变量,赋值后,会生成一个lock addl指令,该指令作用是将本处理器的缓存写入内存,该写入通过缓存一致性协议也会使得其他高速缓存失效。

因为lock alld指令把修改同步到内存,那么意味着所有之前的操作都已经执行完成了,这样便形成了指令重排序无法越过内存屏障的效果。

指令重排序只会在多线程情况下存在并发问题


  • volatile修饰的变量一定是并发安全的吗?

volatile修饰符提供的两个作用并没有体现出其一定是并发安全的,上面的例子也证明了,那么为什么呢?

一个自增操作在字节码层面就对应字条指令,如果在算上解释执行每条指令需要使用到多行代码,或者编译执行对应多条机器码,那么光一个自增操作底层可能就需要执行十几条c代码或者几十条汇编指令,并且不保证这些c代码和汇编指令的原子执行,因此不是线程安全的。


synchronized关键字

synchronized关键字具有可见性,因为对一个变量执行lock前,必须从主内存获取最新值,对一个变量执行unlock前,必须先把此变量同步回主内存。


Java线程

线程调度方式:

  • 协同式调度: 线程执行时间由线程本身控制
  • 抢占式调度: 线程执行时间由系统进行分配

java线程采用1:1内核线程方式实现,因此采用的是抢占式调度。

状态转换:


Java线程安全

  • 不可变对象一定是线程安全的,如: String,Integer等
  • synchronized关键字实现互斥同步: 通过monitorEnter和monitorExit两个字节码指令完成抢锁和释放锁步骤,释放锁由JVM保证一定能够完成

  • java的Lock接口,在java层面实现的互斥锁,如: ReentrantLock, 优势: 等待可中断,实现了公平抢锁机制,可以关联多个条件队列。劣势: 需要在finally块中手动释放锁。
  • 如果要实现非阻塞同步,可以使用CAS加重试机制,CAS需要硬件方面提供支持,如: java提供的原子类,AtomicInteger 。CAS会产生ABA问题,可以通过添加版本号解决,如: AtomicStampedReference。
  • 对于不需要任何同步的场景有: 可重入代码(结果可预测),线程本地存储: ThreadLocal


synchronized锁优化

‘synchronized锁在jdk5之前最大的问题在于一旦抢不到锁就需要阻塞,而不能通过短暂的重试机制来避免阻塞造成的线程切换, 因为java线程是直接映射到内核线程的,因此线程上下文切换开销很大。

锁优化技巧列举

自旋锁

核心思想: 如果抢锁失败,那么通过CAS重新尝试多次,如果成功,那么就避免了阻塞造成的线程上下文切换,如果失败,那么就进行阻塞等待。

JDK 1.4.2中引入了最简单的自旋锁实现,说它简单是因为它的自旋次数默认是十次,不会根据运行状态动态变更。

JDK 6中引入了自适应自旋锁,相比于JDK 1.4.2简单的自旋锁实现而言,对自旋次数进行动态变更。

  • 如果上一次自旋等待过程中成功获得某个对象锁,那么这一次会动态调大自旋次数。
  • 如果多次自旋等待过程中都没能成功获得某个对象锁,那么下一次可能会跳转自旋过程,避免浪费吹起资源。

锁消除

虚拟机即时编译器运行时,对一些代码要求同步,但是通过逃逸分析检测发现当前方法内部所有在堆上的数据都不会逃逸出去,从而被其他线程访问到,那么就可以把它们认为是线程私有的,因此会消除该方法内部所有同步措施。

    public static String concatString(String ... str) 
        StringBuffer stringBuffer = new StringBuffer();
        for (String s : str) 
            stringBuffer.append(s);
        
        return stringBuffer.toString();
    

stringBuffer内部每个方法调用都会加锁,但是该方法本身执行是不存在线程安全问题的,因此可以忽略内部所有同步措施。


锁粗化

虽然推荐尽可能将同步范围缩小,但是如果某个方法内部存在下面的情况:

    private static Integer i = 0;

    private static void display1() 
        for (int i = 0; i < 10; i++) 
            synchronized (Main.class) 
                i++;
            
        
    

    private static void display2() 
        synchronized (Main.class) 
            i++;
        
        synchronized (Main.class) 
            i++;
        
        synchronized (Main.class) 
            i++;
        
    

上面举例的方法中都存在反复对一个对象加锁和解锁的步骤,这样即使在没有线程竞争的情况下,频繁地进行互斥同步操作也会导致性能损耗,因此会进行锁粗化优化:

    private static Integer i = 0;

    private static void display1() 
        synchronized (Main.class) 
            for (int i = 0; i < 10; i++) 
                i++;
            
        
    

    private static void display2() 
        synchronized (Main.class) 
            i++;
            i++;
            i++;
        
    

把多个同步代码块用一个同步块替换,如果是循环体内部的同步块,那么将同步块外提。


具体实现

synchronized锁最原始的实现如下:

该实现最大的问题在于忽略了多数同步代码块运行周期内是不存在竞争的,因此频繁的加锁和解锁设置也会导致性能损耗,并且还需要创建一个Monitor实例对象。

轻量级锁

因此,我们也称上面这种原始实现为重量级锁,为了对重量级锁进行优化,jvm推出了轻量级锁:

  • 轻量级锁的核心思想是在线程获取锁时只是简单标记一下锁被当前线程获取,而在释放锁时,再将标记移除
  • 如果当前线程持有轻量级锁期间出现了锁竞争情况,那么轻量级锁会退化为重量级锁

jvm轻量级锁和偏向锁实现都使用到了对象头,我们来看一下:

轻量级锁实现如下:

在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方为这份拷贝加了一个Displaced前缀,即Displaced Mark Word),这时候线程堆栈与对象头的状态如图所示:

然后,虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。这时候线程堆栈与对象头的状态如图所示:

如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。


偏向锁

轻量级锁可以在没有线程竞争的情况下,避免创建对应的监视器对象,但是如果锁总是被一个线程获取,那么就没有必要在获取锁前打上标记,而释放锁前撤销标记了,可以只打一次标记,如果下次还是这个同一个线程来获取锁,那么就没必要重复进行打标记和释放标记了。

上述锁的实现思路被称为偏向锁,偏的意思就是锁一直没有被其他线程获取,那么持有偏向锁的线程永远不需要再进行同步。

偏向锁原理如下:

当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作等)。

一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”),撤销后标志位恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态,后续的同步操作就按照上面介绍的轻量级锁那样去执行。偏向锁、轻量级锁的状态转化及对象Mark Word的关系如图所示:


在Java语言里面一个对象如果计算过哈希码,就应该一直保持该值不变(强烈推荐但不强制,因为用户可以重载hashCode()方法按自己的意愿返回哈希码),否则很多依赖对象哈希码的API都可能存在出错风险。而作为绝大多数对象哈希码来源的Object::hashCode()方法,返回的是对象的一致性哈希码(Identity Hash Code),这个值是能强制保证不变的,它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变。

因此,当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。

在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的ObjectMonitor类里有字段可以记录非加锁状态(标志位为“01”)下的Mark WVord,其中自然可以存储原来的哈希码。

偏向锁可以提高带有同步但无竞争的程序性能,但它同样是一个带有效益权衡(Trade Off)性质的优化,也就是说它并非总是对程序运行有利。如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。在具体问题具体分析的前提下,有时候使用参数-XX:—UseBiasedLocking来禁止偏向锁优化反而可以提升性能。

以上是关于Java内存模型和线程安全的主要内容,如果未能解决你的问题,请参考以下文章

多线程--线程安全

多线程--线程安全

Java并发程序设计 Java内存模型和线程安全

从Tomcat的处理web请求分析Java的内存模型

Java 高并发三 Java内存模型和线程安全详解

Java高并发程序设计—— java内存模型和线程安全