【Java并发编程】Synchronized关键字实现原理(一)

简介: 【Java并发编程】Synchronized关键字实现原理

【Java并发编程】Synchronized关键字实现原理

它的主要特性是同步锁、非公平锁、阻塞锁、可以保证线程安全(可见性、原子性、有序性)

JDK1.6之后对Synchronized有优化,有个锁升级过程

1、Synchronized之保障线程安全

多线程情况下保障线程安全的方法有很多,一般都是通过加锁去竞争同一个资源,来达到互斥的效果,那么Synchronized是如何保障线程安全的呢

1.1、原子性

它的主要含义是要么全部成功要么全部失败,不允许部分成功部分失败,多线程中原子性是指一个或者多个操作在CPU中执行的过程中出现了被中断的情况

原子性产生的原因主要是有两个:

  • CPU时间切换
    CPU处于空闲状态就会把时间片分配给其他线程进行处理,有两个线程对变量进行修改,会有一个A、线程先得到CPU的执行权,它将变量加载到寄存器后CPU切换为另一个B线程执行,B线程同样加载变量到寄存器,最后把结果写回内存,这时候两个变量值可能会一致
  • 程序本身执行不具备原子性
    这个可以用常见的i++来说明,i++本身不具备原子性,因为它分为了三个操作,先获取值,加一,赋值。这里每一步都是原子性,可是组合在一起就不具备原子性

解决原子性的办法有两个

  • 通过一个互斥条件来达到同时一刻只有一个线程执行
  • 使操作具有原子性,不允许执行过程被中断

为了保证原子性可以在方法上加上Synchronized关键字

1.2、可见性

1.2.1、为什么会存在可见性问题?

1.2.1.1、高速缓存

它的本质是因为,CPU是计算机的核心,它在做运算的时候无法避免从内存中读取数据和指令,即使存储在磁盘的数据也要加载到内存中CPU才能访问,CPU与内存之间无法避免IO操作。CPU向内存发起读取操作,需要等待内存返回结果,此时CPU处于等待状态,如果等待返回之后CPU再执行其他指令会浪费CPU资源。因此在硬件、操作系统、编译器都做了不少优化,正因为这些优化导致出现了可见性问题

例如加入了CPU高速缓存,高速的缓存的作用就是CPU在读取数据的时候会先从高速缓存中读取,如果高速缓存中没有就会从内存中读取

高速缓存又分为三部分:L1、L2、L3

每块CPU里有多个内核,而每个内核都维护了自己的缓存

L1和2属于CPU核内私有缓存,L3属于共享缓存,三块缓存从存储的数据大小排序来说L3>L2>L1,从访问速度来说L1>L2>L3

访问数据从L1中开始查找,然后是L2,最后访问L3如果还没有命中就会从内存中加载数据,加载数据会从L3到L2最后到L1

1.2.2、缓存一致性问题

虽然有高速缓存提高了访问速度,但是一个CPU有多核,每一个线程可能运行在不同的CPU核内,如果多个线程同时访问数据,那么同一份数据就有可能被缓存到多个CPU核内,就会存在缓存一致性问题:两个线程同时加载一块数据到CPU高速缓存中时,如何保证一个数据被修改后在其他缓存中的值也能保持一致,而不是获取到的初始值。CPU解决这个问题使用到了

1.2.2.1、总线锁

操作系统使用总线锁可以解决这个缓存一致性问题,它的原理就是在CPU与内存传输的通道上加了一个LOCK信号,这个信号确保同一时刻只有当前CPU才能访问共享内存,使得其他处理器对内存的操作请求都会被阻塞,但是这样又会使CPU的使用效率下降。

1.2.2.2、缓存锁

为了CPU使用效率下降解决这个问题,引入了缓存锁,当数据已经存在高速缓存中的某个CPU核内私有区域,不使用总线锁而使用缓存一致性解决问题

1.2.2.3、缓存一致性

缓存锁就是通过缓存一致性协议来保证一致性的,不同的CPU支持的缓存一致性协议不同,比较常见就是MSIMESIMOSIMESIF,最常用的就是MESI(Modify Exclusive Shared Invalid),它表示四种状态:

  • M(Modify) 表示共享数据只缓存在当前 CPU 缓存中, 并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
  • E(Exclusive) 表示缓存的独占状态,数据只缓存在当前 CPU 缓存中,并且没有被修改
  • S(Shared) 表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致
  • I(Invalid) 表示缓存已经失效

这四种状态会基于缓存行的状态而变化, 不同的状态会有不同的监听任务

  • 一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回内存
  • 一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I
  • 一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为S

监听过程使基于嗅探协议完成的,该协议要求每个CPU都可以监听到总线上的数据事件变化并作出反应,这个缓存一致性原理就是

  1. 首先CPU0发送一个指令从主内存中读取x变量,然后加载到了高速缓存中,这时候缓存的状态为E

  2. 如果CPU1这时候也要读取x变量的值,就会检测到本地含有该缓存发生冲突,CPU0会通过嗅探协议里面的Read Response消息响应给CPU1,这时候x变量存在于CPU0和CPU1中,缓存的状态变成了S

  3. CPU0拿到x变量的值以后进行修改为x=20,写入主内存中,这时候缓存的状态变为了E,缓存行变为共享状态,同时还需要发送一个Invalidate消息给其他缓存,其他缓存CPU1收到后缓存状态变为Invaild,CPU1里面的x变量值缓存失效,需要从主内存中重新获取值

这个就是基于缓存一致性保证缓存的一致性原理

synchronized就是基于该原理,对进入同一个锁(监视器)的线程保证可见性,在修改了本地内存中的变量后,解锁前会将本地内存修改的内容刷新到主内存中,确保了共享变量的值是最新的,也就保证了可见性

1.2.3、Happens-Before

在JMM内存模型中还定义了一个Happens-Before模型用来保证可见性,这个模型主要描述的就是两个指令操作之间的关系,如果 A happens-before B,意思就是A发生在B之前,那么A的结果对B可见,它主要有如下常见6种规则。

  • 程序顺序规则:一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作
  • 传递性规则:如果A happens-before B,且B happens-before C,那么A happens-before C
  • Volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁
  • start规则:这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作
  • join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回

我们只需要理解Happens-Before规则,既可以编写线程安全的程序了

1.3、有序性

CPU为了提升性能会对编译器、处理器以及代码指令重排序,这种排序在单线程下没问题结果不会受到影响,但如果是多线程操作下就不一定了,可能出现脏数据

就拿最容易复原的指令重排序如何影响程序执行的结果

int x=0;
int y=0;
void handleA(){
  int x=10;
  int y=20;
}
void handleB(){
  if(y==20){
     assert(x==10);
  }
}

指令重排序就是程序的执行顺序和代码的编写顺序不一定一致,两个线程同时执行可能会出现在handleB方法里面y== 20的情况,但是x==10断言失败,出现这种情况的原因是因为在执行handleA方法的时候,因为x、y没有依赖关系,有可能先赋值y=20,这时候刚好handleB()方法判断成功,而x这时候还没有赋值,导致断言失败,这就是多线程环境下的重排序问题,也会导致可见性问题

1.3.1、as-if-serial语义

它表示所有的程序指令都可以因为优化而被重排序,但是要保证在单线程环境下,重排序之后的运行结果和程序代码本身的执行结果一致,CPU指令重排序、Java编译器都需要保证在单线程环境下as-if-serial语义是正确的。存在依赖关系的不会被排序

int x=10;  //1
int y=20;  //2
int c=x+y; //3

按照正常执行顺序就是1、2、3,经过重排序之后可能是2、1、3,但绝对不会是3、2、1,因为as-if-serial语义可以保证排序后和之前结果一致

synchronized能够保证有序性的是因为单线程独占CPU,根据as-if-serial语义,无论编译器和处理器怎么优化或指令重排,单线程下的运行结果一定是正确的。

2、Synchronized原理

Synchronized有两种加锁方式:修饰方法、代码块,这两种方式实现的底层有些不同,但同样的是monitor对象头是实现Synchronized的关键

2.1、Synchronized修饰方法

public class Teacher {
    public static int i = 0;
    public static synchronized void syncLock() {
        i++;
    }
}

通过Java- v Teacher.class反编译之后

发现这个方法有三个标识,其中比较醒目的就是ACC_SYNCHRONIZED,它是用来标记当前方法为同步方法

2.2、Synchronized修饰代码块

public class Teacher {
    public static int i = 0;
    public static void main(String[] args) {
        synchronized (Teacher.class){
        }
    }
}

通过Java- v Teacher.class反编译之后

发现会在同步块的前后分别形成monitorentermonitorexit这两个指令包裹起来,后面还多一个monitorexit,这个作用是防止代码块里面有异常无法释放锁,所有会用第二个monitorexit指令来保证释放,这两个指令都是属于Monitor对象,底层是C ++的ObjectMonitor实现,也是依赖于操作系统的mutex lock来实现的。

倘若线程获取不到锁,通过一定次数的自旋最后阻塞升级为重量级锁,未抢占到锁的线程进入等待队列,那么这里又是如何实现的呢,参考ObjectMonitor的实现

2.3、ObjectMonitor源码

ObjectMonitor::ObjectMonitor() {  
  _header       = NULL;  
  _count       = 0;  
  _waiters      = 0,  
  _recursions   = 0;      
  _object       = NULL;  
  _owner        = NULL;  
  _WaitSet      = NULL; 
  _WaitSetLock  = 0 ;  
  _Responsible  = NULL ;  
  _succ         = NULL ;  
  _cxq          = NULL ;  
  FreeNext      = NULL ;  
  _EntryList    = NULL ;   
  _SpinFreq     = 0 ;  
  _SpinClock    = 0 ;  
  OwnerIsThread = 0 ;  
}

里面主要参数是:

  • WaitSet:阻塞后等待唤醒的队列,为双向循环链表
  • EntryList:准备获取锁的线程队列
  • owner:标识拥有锁的线程
  • count:线程重入次数

1、首先线程会进入EntryList,然后尝试获取Monitor对象,获取成功后把owner标记为当前线程,然后count次数+1,执行完毕后会释放Monitor对象,并把owner设置为null,然后count减1,只有当count等于0才能够获取到锁

2、假如线程进入EntryList后获取Monitor失败,就会进入WaitSet的尾部节点中,等待Monitor对象释放后,会根据操作唤醒一个或全部线程进入EntryList中,处于EntryList中的线程都会抢占锁

Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向),它也被称之为“监视器锁”,这就是为什么任意对象都可以作为锁的原因

3、Synchronized锁对象

3.1、Java对象内存布局

一个对象初始化之后会被存储在堆内存中,一个对象又分为三部分:对象头实例数据对其填充

对象头

其中对象头又分为三部分

  • Mark Word:记录了对象和锁相关的信息,主要包含了GC分代年龄、锁的状态标记、HashCode、epoch等等信息
  • Klass Pointer:代表指向类的指针,通过指针来找到具体的实例
  • Length:表示数组的长度,只有数组对象才会有这个属性值

实例数据

实例数据表示一个类里面所有的成员的变量

public class Student{
  int age=0;
  boolean state=false;
}

例如这些成员变量就存储在实例数据里面,实例数据占用的空间是由成员变量的类型决定的比如int占4个字节

对齐填充

对齐填充没有什么实际含义,主要是使得当前实例变量占用空间是8的倍数,这样做的目的是为了减少CPU访问内存的频率,为什么会频繁的访问内存?这个问题涉及到了高速缓存中的缓存行,CPU每次从内存加载8个字节的的数据到缓存行中,也意味着高速缓存存储的是连续的数据,每个缓存行大小是64位,意思如果是一个8个字节的变量,需要存储8个才能把该缓存行占满

但是存在这样一种情况,两个线程同时去读取该缓存上不同的值Long2、Long8,就会使同时缓存该缓存行,为了保证缓存一致性就会使一部分缓存失效,导致一个线程需要重新去获取,重新加载到缓存行,如果线程访问频繁就会使缓存反复失效,形成伪共享问题,为了减少CPU访问内存的频率,那么必须要变量不在于同一缓存行中,使用对其填充使两个变量分开,在一个变量前后填充7个填充变量,就可以使两个值分布于不同缓存行,比如在Long2前后填充七个

之所以要做前后填充是为了使无论Long2处于什么位置都可以保证它处于不同的缓存行,避免出现伪共享问题

还有一种作用,假如需要读取Long类型的数据的时候,它分布在两个缓存行中,如果没有对其填充需要读取缓存行A和缓存行B才可以获得真正的数据

使用对其填充之后,在缓存行B中可以直接读取到全部数据,减少了CPU访问次数

在对齐填充的布局中,虽然做了无效填充,但是访问内存次数少了,本质上来说是一种时间换空间的设计方式。

一个类对象在JVM中对象存储的布局

public  class Student(){
    private  String name;
    public static void main(String[] args) {
        Student stu=new Student();
        System.out.println(ClassLayout.parseInstance(stu).toPrintable());
    }
}

使用ClassLayout查看对象布局

  • OFFSET:偏移地址
  • SIZE:占用内存大小
  • TYPE DESCRIPTION:类型描述
  • value:内存中存储的值

对象字段代表为:

  • 对象头:TYPE DESCRIPTION(object header)类型的,总共3个占用12字节的内存,前两行SIZE加起来为8字节的代表Mark Word,第三行4字节的代表类型指针Klass Pointer,不压缩会占用8个字节
  • 对齐填充:TYPE DESCRIPTION((loss due to the next object alignment))类型的,本身4个字节,填充了12个字节总共16字节,主要为了保证是8的倍数
  • 实例数据:Instance size,总共16个字节

3.2、Java锁结构信息

Java锁包含在对象头里面,Mark Word中记录了对象和锁的信息,锁的标记和相关信息都存储在里面

锁状态 偏向锁标记 锁标记
无锁 0 01
偏向锁 1 01
轻量级锁 00
重量级锁 10
GC标记 11

Mark Word使用2bit来存储锁的标记,也就是两位数最多只能存储4个数:00、01、10、11。而锁的状态有五种,超出一种就多使用了1个bit的偏向锁来表达。

相关文章
|
10天前
|
JSON Java Apache
非常实用的Http应用框架,杜绝Java Http 接口对接繁琐编程
UniHttp 是一个声明式的 HTTP 接口对接框架,帮助开发者快速对接第三方 HTTP 接口。通过 @HttpApi 注解定义接口,使用 @GetHttpInterface 和 @PostHttpInterface 等注解配置请求方法和参数。支持自定义代理逻辑、全局请求参数、错误处理和连接池配置,提高代码的内聚性和可读性。
|
11天前
|
存储 安全 Java
Java多线程编程的艺术:从基础到实践####
本文深入探讨了Java多线程编程的核心概念、应用场景及其实现方式,旨在帮助开发者理解并掌握多线程编程的基本技能。文章首先概述了多线程的重要性和常见挑战,随后详细介绍了Java中创建和管理线程的两种主要方式:继承Thread类与实现Runnable接口。通过实例代码,本文展示了如何正确启动、运行及同步线程,以及如何处理线程间的通信与协作问题。最后,文章总结了多线程编程的最佳实践,为读者在实际项目中应用多线程技术提供了宝贵的参考。 ####
|
8天前
|
监控 安全 Java
Java中的多线程编程:从入门到实践####
本文将深入浅出地探讨Java多线程编程的核心概念、应用场景及实践技巧。不同于传统的摘要形式,本文将以一个简短的代码示例作为开篇,直接展示多线程的魅力,随后再详细解析其背后的原理与实现方式,旨在帮助读者快速理解并掌握Java多线程编程的基本技能。 ```java // 简单的多线程示例:创建两个线程,分别打印不同的消息 public class SimpleMultithreading { public static void main(String[] args) { Thread thread1 = new Thread(() -> System.out.prin
|
10天前
|
存储 缓存 安全
在 Java 编程中,创建临时文件用于存储临时数据或进行临时操作非常常见
在 Java 编程中,创建临时文件用于存储临时数据或进行临时操作非常常见。本文介绍了使用 `File.createTempFile` 方法和自定义创建临时文件的两种方式,详细探讨了它们的使用场景和注意事项,包括数据缓存、文件上传下载和日志记录等。强调了清理临时文件、确保文件名唯一性和合理设置文件权限的重要性。
26 2
|
5月前
|
缓存 安全 Java
《volatile使用与学习总结:》多层面分析学习java关键字--volatile
《volatile使用与学习总结:》多层面分析学习java关键字--volatile
32 0
|
6月前
|
安全 Java 编译器
Java多线程基础-6:线程安全问题及解决措施,synchronized关键字与volatile关键字(一)
线程安全问题是多线程编程中最典型的一类问题之一。如果多线程环境下代码运行的结果是符合我们预期的,即该结果正是在单线程环境中应该出现的结果,则说这个程序是线程安全的。 通俗来说,线程不安全指的就是某一代码在多线程环境下执行会出现bug,而在单线程环境下执行就不会。线程安全问题本质上是由于线程之间的调度顺序的不确定性,正是这样的不确定性,给我们的代码带来了很多“变数”。 本文将对Java多线程编程中,线程安全问题展开详细的讲解。
101 0
|
6月前
|
存储 安全 Java
【亮剑】Java并发编程涉及`ThreadLocal`、`Volatile`、`Synchronized`和`Atomic`四个关键机制
【4月更文挑战第30天】Java并发编程涉及`ThreadLocal`、`Volatile`、`Synchronized`和`Atomic`四个关键机制。`ThreadLocal`为每个线程提供独立变量副本;`Volatile`确保变量可见性,但不保证原子性;`Synchronized`实现同步锁,保证单线程执行;`Atomic`类利用CAS实现无锁并发控制。理解其原理有助于编写高效线程安全代码。根据业务场景选择合适机制至关重要。
43 0
|
存储 缓存 Java
Java中不可或缺的关键字「volatile」
Java中不可或缺的关键字「volatile」
241 0
|
缓存 安全 Java
Java并发编程中的四个关键字:ThreadLocal、Volatile、Synchronized和Atomic
Java并发编程中的四个关键字:ThreadLocal、Volatile、Synchronized和Atomic
267 0
|
缓存 安全 Java
Java 关键字特性增强-Volatile
Java高级特性增强-Volatile volatile关键字 volatile特性
172 0
Java 关键字特性增强-Volatile