【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的偏向锁来表达。

相关文章
|
2天前
|
Java 开发工具
课时6:Java编程起步
课时6:Java编程起步,主讲人李兴华。课程摘要:介绍Java编程的第一个程序“Hello World”,讲解如何使用记事本或EditPlus编写、保存和编译Java源代码(*.java文件),并解释类定义、主方法(public static void main)及屏幕打印(System.out.println)。强调类名与文件名一致的重要性,以及Java程序的编译和执行过程。通过实例演示,帮助初学者掌握Java编程的基本步骤和常见问题。
|
3月前
|
存储 缓存 Java
Java 并发编程——volatile 关键字解析
本文介绍了Java线程中的`volatile`关键字及其与`synchronized`锁的区别。`volatile`保证了变量的可见性和一定的有序性,但不能保证原子性。它通过内存屏障实现,避免指令重排序,确保线程间数据一致。相比`synchronized`,`volatile`性能更优,适用于简单状态标记和某些特定场景,如单例模式中的双重检查锁定。文中还解释了Java内存模型的基本概念,包括主内存、工作内存及并发编程中的原子性、可见性和有序性。
Java 并发编程——volatile 关键字解析
|
3月前
|
安全 Java Kotlin
Java多线程——synchronized、volatile 保障可见性
Java多线程中,`synchronized` 和 `volatile` 关键字用于保障可见性。`synchronized` 保证原子性、可见性和有序性,通过锁机制确保线程安全;`volatile` 仅保证可见性和有序性,不保证原子性。代码示例展示了如何使用 `synchronized` 和 `volatile` 解决主线程无法感知子线程修改共享变量的问题。总结:`volatile` 确保不同线程对共享变量操作的可见性,使一个线程修改后,其他线程能立即看到最新值。
|
3月前
|
安全 Java 编译器
深入理解Java中synchronized三种使用方式:助您写出线程安全的代码
`synchronized` 是 Java 中的关键字,用于实现线程同步,确保多个线程互斥访问共享资源。它通过内置的监视器锁机制,防止多个线程同时执行被 `synchronized` 修饰的方法或代码块。`synchronized` 可以修饰非静态方法、静态方法和代码块,分别锁定实例对象、类对象或指定的对象。其底层原理基于 JVM 的指令和对象的监视器,JDK 1.6 后引入了偏向锁、轻量级锁等优化措施,提高了性能。
86 3
|
3月前
|
存储 安全 Java
Java多线程编程秘籍:各种方案一网打尽,不要错过!
Java 中实现多线程的方式主要有四种:继承 Thread 类、实现 Runnable 接口、实现 Callable 接口和使用线程池。每种方式各有优缺点,适用于不同的场景。继承 Thread 类最简单,实现 Runnable 接口更灵活,Callable 接口支持返回结果,线程池则便于管理和复用线程。实际应用中可根据需求选择合适的方式。此外,还介绍了多线程相关的常见面试问题及答案,涵盖线程概念、线程安全、线程池等知识点。
269 2
|
9月前
|
Java C++
关于《Java并发编程之线程池十八问》的补充内容
【6月更文挑战第6天】关于《Java并发编程之线程池十八问》的补充内容
65 5
|
6月前
|
缓存 监控 Java
Java中的并发编程:理解并应用线程池
在Java的并发编程中,线程池是提高应用程序性能的关键工具。本文将深入探讨如何有效利用线程池来管理资源、提升效率和简化代码结构。我们将从基础概念出发,逐步介绍线程池的配置、使用场景以及最佳实践,帮助开发者更好地掌握并发编程的核心技巧。
|
8月前
|
安全 Java 开发者
Java中的并发编程:深入理解线程池
在Java的并发编程中,线程池是管理资源和任务执行的核心。本文将揭示线程池的内部机制,探讨如何高效利用这一工具来优化程序的性能与响应速度。通过具体案例分析,我们将学习如何根据不同的应用场景选择合适的线程池类型及其参数配置,以及如何避免常见的并发陷阱。
76 1
|
8月前
|
监控 Java
Java并发编程:深入理解线程池
在Java并发编程领域,线程池是提升应用性能和资源管理效率的关键工具。本文将深入探讨线程池的工作原理、核心参数配置以及使用场景,通过具体案例展示如何有效利用线程池优化多线程应用的性能。
|
7月前
|
Java 数据库
Java中的并发编程:深入理解线程池
在Java的并发编程领域,线程池是提升性能和资源管理的关键工具。本文将通过具体实例和数据,探讨线程池的内部机制、优势以及如何在实际应用中有效利用线程池,同时提出一个开放性问题,引发读者对于未来线程池优化方向的思考。
56 0

热门文章

最新文章