Java并发编程底层实现原理(不了解这个不敢说懂并发)

简介: Java并发编程底层实现原理(不了解这个不敢说懂并发)

写在前面

Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM中,JVM执行字节码,最终需要转换为汇编指令在CPU上执行,Java中所有的并发机制依赖于JVM的实现和CPU的指定。


1、volatile 的应用

在并发编程中synchronized和volatile关键字都扮演着重要的角色,volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改了一个共享变量时,另一个线程能读到这个修改的值,如果volatile关键字使用得当的话,它会比synchronized使用的成本更低,因为它不会引起上下文切换和调度


1.1 连接CPU的相关术语与说明

image.png

1.2 volatile是如何保证内存可见性的

原理:被volatile修饰的变量,通过jvm最终生成的汇编指令会多出一行汇编代码,这行代码是Lock前缀的。image.pngLock 前缀的指令在多核处理器下会引发两件事情


将处理器缓存行的数据写回到系统内存

这个写回内存的操作会使在其他CPU缓存了该内存地址的数据无效

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存中的数据读到内存缓存(L1 L2或其他)后在进行操作,但操作完不知道何时写会内存。如果对申明了volatile的变量进行操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧值,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线传播的数据来检查自己的缓存是否过期了,当处理器发现自己缓存的数据对应的内存地址被修改,就会将当前处理器的缓存行设置为无效,当处理器对这个数据进行修改时,就会重新从内存中读取数据到缓存中。


2、synchronized的实现原理与应用

在多线程并发编程中synchronized一直是元老角色,很多人称呼它为重量级。但是随着Java SE 1.6对synchronized进行了各种优化之后,有些情况synchronized它不在那么重了。接下来阐述的知识点是关于偏向锁、轻量级锁,以及锁的存储结构和升级过程。


synchronized在Java中三种表现形式


对于普通同步方法,锁的是当前实例对象

对于静态同步方法,锁的是类的class对象

对于同步方法块,锁的是synchronized括号里配置的对象

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或者抛出异常时,必须释放锁。name锁到底存在哪里呢?锁里面存的又是什么信息呢?


在JVM规范中可以看到synchronized在JVM中的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,两者实现的细节不一样。代码块使用monitorenter和monitorexit指令实现,而方法同步使用的是另外一种情况,这个在JVM规范中并没有讲解。但是,方法的同步也可使用这两个指令来实现。


monitorenter指令是在编译后插入到同步代码块的开始位置

monitorexit指令插入到方法结束的位置和异常处

JVM要保证每个monitorenter必须有与之对应的monitorexit配对

任何对象都有一个monitor与之关联

当一个monitor被持有后,它将处于锁定状态

线程执行monitorenter指令时,将会尝试获取对象对应的monitor的所有权,即尝试获得对象的锁

2.1 Java对象头

synchronize用的锁存在Java对象头里。如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数据类型,则虚拟机用2个字宽存储对象头 。在32位虚拟机中1字宽等于4字节,即32bit。


Java对象头的长度

image.png2.2 锁升级与对比

Java SE 1.6 为了减少获得锁和释放锁带来性能消耗,引入了“偏向锁”和“轻量级锁”,在Java SE 1.6中,锁一共有四种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,这几个状态会随着竞争情况逐渐升级。注意:锁的升级是不可逆的,意味着偏向锁升级为轻量级锁之后是不能降级为偏向锁的。


2.1.1 偏向锁

HotSpot的作者研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获取的,为了让线程获得锁的代价更低,引入了偏向锁。 注意:这个是设计偏向锁的原因和解决思路


2.1.1.1 偏向锁的获取

当一个线程访问同步代码块并获取锁时,会在对线头和栈帧中的锁记录中存储锁偏向的线程ID,以后该线程在进入和退出同步代码块的时候,不需要进行CAS操作来加锁和解锁,只需要简单的测试一下对线头Mark Word里是否存储了当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要在测试一下Mark Word中偏向锁标识是否被设置成立1(表示当前是偏向锁):如果没有设置则使用CAS竞争锁;如果这事了,则尝试使用CAS将当前对象头的偏向锁指向当前线程。


2.1.1.2 偏向锁的撤销(非常妙这里)

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁是,持有偏向锁的线程才会释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它首先会暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向其他线程,要么恢复到无锁标记活着标记该对象不适合作为偏向锁,最后唤醒暂停的线程


2.1.1.3 关闭偏向锁

Java 1.6 1.7偏向锁是默认开启的,但是它在应用程序启动几秒钟后才会激活,我们可以修改JVM参数来关闭延迟,或者确定应用程序里所有的锁通常情况下都是出于竞争状态,可以直接关闭偏向锁


image.png

image.png2.1.2 轻量级锁

2.1.2.1 轻量级锁加锁

线程在执行同步代码块之前,JVM会在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁;如果失败,表示其他线程竞争锁,当前线程便尝试自旋获得锁。


2.1.2.2 轻量级锁解锁

轻量级锁解锁时,使用的是CAS操作将Displaced Mark Word替换回对象头,如果成功则表示没有竞争;如果失败,表示当前锁存在竞争,锁就会膨胀为重量级锁。 注意:由于自旋过程消耗CPU,为了避免无用的自旋,一当升级为重量级锁,那么就不会再恢复到轻量级锁状态。当前锁出于重量级锁状态时,其他线程尝试获取锁,都会被阻塞,当持有锁的线程释放锁后会唤醒这些线程,重新进行锁的争夺。


2.1.3 锁的优缺点对比

锁的优缺点对比

image.png3、Java中如何实现原子操作

在Java中可以通过锁和循环CAS的方式实现原子操作


3.1 使用循环CAS实现原子操作

JVM中的CAS操作利用了处理器提供的CMPXCHG指令实现。自旋CAS实现的基本思路就是循环进行CAS操作知道成功为止,示例代码实现一个安全的计数器和非安全的计数器。

package com.liziba;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
/**
 * @auther LiZiBa
 * @date 2021/2/28 17:39
 * @description: 计数器实现
 **/
public class Counter {
    // 安全计数器统计数
    private AtomicInteger atomicInteger = new AtomicInteger(0);
    // 非安全计数器统计数
    private int i = 0;
    public static void main(String[] args) {
        final Counter cas = new Counter();
        List<Thread> threads = new ArrayList<>(600);
        long start = System.currentTimeMillis();
        for (int j = 0; j < 100; j++) {
            Thread t = new Thread(() -> {
                for (int i = 0; i < 10000; i++) {
                    // 非安全计数器
                    cas.count();
                    // 安全计数器
                    cas.safeCount();
                }
            });
            threads.add(t);
        }
        // 启动线程
        threads.forEach(t -> t.start());
        // join等待所有线程执行完毕
        for (Thread t : threads) {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 输出不安全计数器结果、安全计数器结果、程序执行时间
        System.out.println(cas.i);
        System.out.println(cas.atomicInteger.get());
        System.out.println(System.currentTimeMillis() - start);
    }
    /**
     * 安全计数器
     */
    private void safeCount() {
        i++;
    }
    /**
     * 非安全计数器
     */
    private void count() {
        for (;;) {
            int i = atomicInteger.get();
            // CAS 增加
            // 注意使用 ++i
            boolean set = atomicInteger.compareAndSet(i, ++i);
            // 设置成功退出死循环
            if (set) {
                break;
            }
        }
    }
}

image.pngJDK 1.5开始,JDK并发包提供了一些类支持原子操作,如AtomicBoolean,AtomicInteger,AtomicLong,对应不同类型的原子操作,这些类提供了非常有用的工具方法,比如原子自增和自减等等。


3.1.1 CAS 实现原子操作的三大问题

ABA问题:因为CAS需要在操作值的时候,检查值是否发生了变化,如果没有变化则更新,但是如果一个值从A修改为B又修改为A,那么使用CAS就无法发现值发生了变化,但实际上发生了变化。解决方案如下


使用版本号解决,将原本的A–>B–>A问题变成1A–>2B–>3A则可以解决

使用JDK Atomic包里提供的AtomicStampedReference来解决ABA问题,这个类compareAndSet方法会首先比较当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等则以原子方式替换,源码如下。

image.png

循环时间长开销大:自旋CAS如果长时间不成功,会给CPU带来非常大的开销。解决这个问题需要JVM能支持处理器提供的pause指令,效率会有一定的提升。pause指令的两个作用如下


延迟流水线执行指令(de-pipeline),使CPU不会消耗过多执行资源

避免在退出循环时内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空

只能保证一个共享变量的原子操作:对多个共享变量进行操作的时候CAS无法保证原子性,解决方案如下


使用锁

变量合并

使用JDK提供的AtomicReference类来保证引用对象之间的原子性,将多个变量放置于对象中

3.2 使用锁机制实现原子操作

锁机制保证了只有获得了锁的线程才能操作锁定的内存区域。JVM内部实现了很多锁,偏向锁、轻量级锁、重量级锁。但是除了偏向锁,JVM实现锁的方式都使用了循环CAS机制,即一个线程想进入同步代码块的时候,使用循环CAS的方式获取锁,当它退出同步代码块的时候使用循环CAS来释放锁


参考资料


《Java并发编程的艺术》-- 方腾飞 魏鹏 程晓明 著

百度百科

CSDN部分博客


目录
相关文章
|
5天前
|
设计模式 安全 Java
Java编程中的单例模式:理解与实践
【10月更文挑战第31天】在Java的世界里,单例模式是一种优雅的解决方案,它确保一个类只有一个实例,并提供一个全局访问点。本文将深入探讨单例模式的实现方式、使用场景及其优缺点,同时提供代码示例以加深理解。无论你是Java新手还是有经验的开发者,掌握单例模式都将是你技能库中的宝贵财富。
12 2
|
2天前
|
安全 Java 编译器
JDK 10中的局部变量类型推断:Java编程的简化与革新
JDK 10引入的局部变量类型推断通过`var`关键字简化了代码编写,提高了可读性。编译器根据初始化表达式自动推断变量类型,减少了冗长的类型声明。虽然带来了诸多优点,但也有一些限制,如只能用于局部变量声明,并需立即初始化。这一特性使Java更接近动态类型语言,增强了灵活性和易用性。
80 53
|
2天前
|
存储 算法 Java
大厂面试高频:什么是自旋锁?Java 实现自旋锁的原理?
本文详解自旋锁的概念、优缺点、使用场景及Java实现。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:什么是自旋锁?Java 实现自旋锁的原理?
|
2天前
|
Java
Java之CountDownLatch原理浅析
本文介绍了Java并发工具类`CountDownLatch`的使用方法、原理及其与`Thread.join()`的区别。`CountDownLatch`通过构造函数接收一个整数参数作为计数器,调用`countDown`方法减少计数,`await`方法会阻塞当前线程,直到计数为零。文章还详细解析了其内部机制,包括初始化、`countDown`和`await`方法的工作原理,并给出了一个游戏加载场景的示例代码。
Java之CountDownLatch原理浅析
|
4天前
|
Java 索引 容器
Java ArrayList扩容的原理
Java 的 `ArrayList` 是基于数组实现的动态集合。初始时,`ArrayList` 底层创建一个空数组 `elementData`,并设置 `size` 为 0。当首次添加元素时,会调用 `grow` 方法将数组扩容至默认容量 10。之后每次添加元素时,如果当前数组已满,则会再次调用 `grow` 方法进行扩容。扩容规则为:首次扩容至 10,后续扩容至原数组长度的 1.5 倍或根据实际需求扩容。例如,当需要一次性添加 100 个元素时,会直接扩容至 110 而不是 15。
Java ArrayList扩容的原理
|
1天前
|
存储 安全 Java
Java多线程编程的艺术:从基础到实践####
本文深入探讨了Java多线程编程的核心概念、应用场景及其实现方式,旨在帮助开发者理解并掌握多线程编程的基本技能。文章首先概述了多线程的重要性和常见挑战,随后详细介绍了Java中创建和管理线程的两种主要方式:继承Thread类与实现Runnable接口。通过实例代码,本文展示了如何正确启动、运行及同步线程,以及如何处理线程间的通信与协作问题。最后,文章总结了多线程编程的最佳实践,为读者在实际项目中应用多线程技术提供了宝贵的参考。 ####
|
1天前
|
Java UED
Java中的多线程编程基础与实践
【10月更文挑战第35天】在Java的世界中,多线程是提升应用性能和响应性的利器。本文将深入浅出地介绍如何在Java中创建和管理线程,以及如何利用同步机制确保数据一致性。我们将从简单的“Hello, World!”线程示例出发,逐步探索线程池的高效使用,并讨论常见的多线程问题。无论你是Java新手还是希望深化理解,这篇文章都将为你打开多线程的大门。
|
2天前
|
安全 Java 编译器
Java多线程编程的陷阱与最佳实践####
【10月更文挑战第29天】 本文深入探讨了Java多线程编程中的常见陷阱,如竞态条件、死锁、内存一致性错误等,并通过实例分析揭示了这些陷阱的成因。同时,文章也分享了一系列最佳实践,包括使用volatile关键字、原子类、线程安全集合以及并发框架(如java.util.concurrent包下的工具类),帮助开发者有效避免多线程编程中的问题,提升应用的稳定性和性能。 ####
18 1
|
5天前
|
存储 设计模式 分布式计算
Java中的多线程编程:并发与并行的深度解析####
在当今软件开发领域,多线程编程已成为提升应用性能、响应速度及资源利用率的关键手段之一。本文将深入探讨Java平台上的多线程机制,从基础概念到高级应用,全面解析并发与并行编程的核心理念、实现方式及其在实际项目中的应用策略。不同于常规摘要的简洁概述,本文旨在通过详尽的技术剖析,为读者构建一个系统化的多线程知识框架,辅以生动实例,让抽象概念具体化,复杂问题简单化。 ####
|
5天前
|
设计模式 安全 Java
Java编程中的单例模式深入解析
【10月更文挑战第31天】在编程世界中,设计模式就像是建筑中的蓝图,它们定义了解决常见问题的最佳实践。本文将通过浅显易懂的语言带你深入了解Java中广泛应用的单例模式,并展示如何实现它。
下一篇
无影云桌面