并发编程-并发关键字&理论篇🔥

小龙coding大约 20 分钟

Java对象内存布局和对象头及Moniter原理探析

1、创建对象的过程

Object object = new Object()

创建该对象类型在方法区,object 引用在栈区,new Object() 在堆区

img

在HotSpot虚拟机里,对象在堆内存的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data) 和对齐填充(Padding)保证 8 字节的倍数

img

2、对象在堆内存中的存储布局

2.1 对象头

对象头中包含两部分:

  • MarkWord:Mark Word 用于存储对象自身的运行时数据,如 HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID 等等。
  • 类型指针:虚拟机通过这个指针确定该对象是哪个类的实例。·如果是数组对象的话,对象头还有一部分是存储数组的长度。

注:多线程下 synchronized 的加锁就是对同一个对象的对象头中的 MarkWord 中的变量进行 CAS 操作。

对象标记 MarkWord【64位】

对象标记 MarkWord,默认存储(哈希值(HashCode )、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID即JavaThread、偏向时间戳)等信息

这些信息都是与对象自身定义无关的数据,所以 MarkWord 被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。

它会根据对象的状态复用自己的存储空间,也就是说在运行期间MarkWord里存储的数据会随着锁标志位的变化而变化。

Mark Word的内存布局如下:

img

GC年龄采用 4 位 bit 存储,最大为 15,例如 MaxTenuringThreshold 参数默认值就是 15,因为 GC 年龄占 4 位,最大就是 1111=15

所以假如我们手动设置最大分代年龄:-XX:MaxTenuringThreshold=16,JVM 会报错,JVM 启动失败

img

类元信息(又叫类型指针)【64位】

对象指向它的类元数据的指针

虚拟机通过这个指针来确定这个对象是哪个类的实例

img

2.2、实例数据

存放类的属性(Field)数据信息,包括父类的属性信息

2.3、对齐填充(保证 8 个字节的倍数)

虚拟机要求对象起始地址必须是 8 字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐,这部分内存按 8 字节补充对齐

3、Monitor 原理

Monitor 被翻译为监视器或管程,可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。

每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针

Monitor 结构如下

img

  • 刚开始 Monitor 中 Owner 为 null
  • 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一 个 Owner
  • 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入 EntryList BLOCKED Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争是非公平的
  • 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程

synchronized 必须是进入同一个对象的 monitor 才有上述的效果

不加 synchronized 的对象不会关联监视器,不遵从以上规则

监视器锁本质:依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为重量级锁。

随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。JDK 1.6中默认是开启偏向锁和轻量级锁的,我们也可以通过-XX:-UseBiasedLocking=false来禁用偏向锁

synchronized 使用范围

  • 修饰实例方法:锁的是当前对象实例
  • 修饰静态方法:锁当前类的Class对象
  • 修饰代码块:锁是Sychonized括号里配置的对象

synchronized 底层实现原理

  • 修饰同步代码块

    • javac编译时,会在代码块前后生成monitorenter和monitorexit,当执行遇到monitorenter指令,会尝试获取对象的monitor使用权,即尝试加锁,
    • 为了保证出现异常也能释放锁,会隐式添加一个try-finnaly,finnaly中有monitorexit命令释放锁
    • 任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态
  • 修饰方法

    • javac为方法的flags属性添加了一个ACC_SYNCHRONIZED关键字,在JVM进行方法调用时,发现调用的方法被ACC_SYNCHRONIZED修饰,则会先尝试获得锁
  • 两者本质都是获取对象监视器monitor

volatile底层原理?

基础知识:

volatile关键字在Java中的底层实现主要涉及到计算机硬件的内存嗅探技术、缓存一致性协议、总线锁以及lock指令等方面。

  1. 内存嗅探技术:在多处理器环境下,每个处理器都有自己的高速缓存(Cache),为了保证各个处理器的缓存是一致的,就有了内存嗅探技术。当某个处理器通过总线发出数据写入请求时,其他在总线上的处理器都会嗅探到这个请求,然后检查自己缓存的数据是否失效,如果失效则会从主内存中重新读取。
  2. 缓存一致性协议:这是一个协议,用于保证不同的CPU缓存之间的数据是一致的。最知名的就是MESI协议(Modified、Exclusive、Shared、Invalid)。当某个CPU修改了自己缓存中的数据,会发出一个信号,其他CPU接收到信号后,会使自己缓存中的这个数据无效,下次再使用这个数据时,会从主内存中重新读取。
  3. 总线锁:当一个CPU在执行操作时,会在总线上加锁,阻止其他CPU访问主内存,这样就保证了在修改变量时,其他CPU不能读取或者写入。
  4. lock指令:lock是一个指令前缀,表示接下来的指令会锁住这条指令操作的内存区域,直到指令结束后才会释放锁。在Java中,volatile的实现就是通过插入lock指令来实现的。

lock指令是x86架构中的一个指令前缀,它的作用是将接下来的指令变为原子操作,即这个指令在执行过程中不会被其他处理器或者线程打断。

在多处理器和多线程环境中,多个处理器或者线程可能会同时对同一块内存进行操作,如果没有适当的同步机制,就可能会导致数据的不一致。lock指令就是一种硬件级别的同步机制,它可以保证在lock指令修饰的指令执行过程中,其他处理器或者线程不能对这块内存进行操作。

在早期的多处理器系统中,lock指令是通过在总线上加锁来实现的,即在lock指令执行过程中,其他处理器不能通过总线访问内存。但是这种方式在处理器数量增加时,会严重影响系统的并行性能。因此,在现代的多处理器系统中,lock指令通常是通过缓存一致性协议来实现的。

总的来说,lock指令的作用是保证在多处理器和多线程环境中,对内存的操作能够正确地完成,防止数据的不一致。

原理:

  • 可见性原理

    • lock前缀+嗅探机制+缓存一致性协议
    • lock前缀作用:
      • 当前处理器的缓存行写入主内存
      • 写回内存的操作会导致其他处理器缓存失效
    • volatile在进行写操作时,JVM会向处理器发送一个lock前缀指令,将这个变量所在缓存行的数据写到主内存,每个处理器通过嗅探机制监听总线上的数据来检查自己缓存行的数据是否过期,要是发现自己缓存行对应的内存地址被改了,就会将当前缓存行设置为无效状态。当自己要用时再去主内存将数据读到自己工作内存中。完解!
  • 禁止指令重排原理

    • 重排序:指令重排序是为了在执行程序时提高性能**,编译器与处理器常常会对指令做重排序。**
    • 编译器生成字节码文件时,会在读写操作指令前后插入内存屏障,指令排序时不能把后面的指令重排到前面
    • 内存屏障:一组处理器指令
    • X86处理器只会对写-读操作重排序,因此JMM仅需在volatile后面插入一个StoreLoad屏障

volatile使用场景谈谈?

见《BAT高频场景&实战分析篇》

volatile和synchronized区别?

  1. volatile
    • volatile主要用于保证变量的可见性,当一个共享变量被volatile修饰时,它能保证一个线程对这个变量的修改对其他线程立即可见。
    • volatile只能修饰变量,不能修饰方法和类。
    • volatile不具备互斥性,也就是说它不能保证一段代码的原子性。
  1. synchronized
    • synchronized既可以保证变量的可见性,也可以保证一段代码的原子性。当一个线程进入synchronized修饰的方法或代码块时,其他线程不能访问这段代码,直到该线程释放锁。
    • synchronized可以修饰方法和代码块。
    • synchronized会影响程序执行效率,因为它会阻塞线程。

总的来说,volatile和synchronized都可以用于多线程环境下的变量可见性问题,但是如果需要保证一段代码的原子性,或者需要实现线程同步,那么应该使用synchronized。

ThreadLocal底层原理?使用注意事项?应用场景?

ThreadLocal的底层原理:

ThreadLocal为每个线程都提供了一个独立的变量副本,每个线程都可以独立地改变自己的副本,而不会影响其他线程所对应的副本。ThreadLocal的实现主要依赖于ThreadLocalMap,这是一个定制化的哈希映射,其键值为ThreadLocal对象,而值为线程局部变量。每个Thread都有一个ThreadLocalMap,用于存储该线程的ThreadLocal变量。

可能存在的问题及使用注意事项:

  1. 内存泄露:ThreadLocalMap的键是对ThreadLocal对象的弱引用,而值是对线程局部变量的强引用。这意味着在没有外部强引用的情况下,ThreadLocal对象可以被垃圾回收器回收。然而,由于ThreadLocalMap的生命周期与Thread相同,如果线程一直不结束,那么会导致ThreadLocalMap中的值无法被回收,从而产生内存泄露。因此,使用完ThreadLocal后,应该调用其remove()方法,以清除线程局部变量。
  2. 初始值问题:ThreadLocal通常需要通过重写initialValue()方法来指定线程局部变量的初始值。如果不重写该方法,那么线程局部变量的初始值为null。

应用场景举例:

  1. 数据库连接:在Web应用中,每个请求通常对应一个数据库连接。可以使用ThreadLocal来存储数据库连接,这样每个请求(通常对应一个线程)都会有自己独立的数据库连接。
  2. 用户会话信息:在Web应用中,可以使用ThreadLocal来存储用户会话信息,这样在处理用户请求时,可以方便地获取到用户的会话信息,而无需将会话信息作为参数传递。
  3. SimpleDateFormat:SimpleDateFormat不是线程安全的,但创建它的代价又相对较高。可以使用ThreadLocal来存储SimpleDateFormat,这样每个线程都会有自己的SimpleDateFormat对象,既保证了线程安全,又避免了频繁创建和销毁对象的开销。

什么是Java的内存模型(Java Memory Model)?

定义:

只是一个抽象的规范,避免不同硬件和操作系统下对内存访问逻辑有所差异带来同一套代码不同执行结果的问题

JMM关于同步的规定:

1:线程解锁前,必须把共享变量的值重新刷新回主内存当中

2:线程加锁前,必须将主内存中最新值读到工作内存

3:加锁解锁是同一把锁

JMM对内存的划分

JMM把内存主要分为主内存与工作内存,规定变量存储于主内存中,每个线程都有自己的工作内存,线程中保存了自己需要操作的变量的主内存的拷贝副本,对其进行操作后再刷回主内存,每个线程工作内存是独立的,线程操作数据只能在工作内存中进行,再刷回主内存,所以线程通信得依靠主内存。

Java线程<----->工作内存<----->主内存

说说主内存、工作内存和 happens-before 原则?

在Java内存模型中,主内存和工作内存是两个重要的概念:

  1. 主内存:主内存是所有线程共享的内存区域,它包含了所有的实例变量。主内存是真实的物理内存,也是所有线程共享的内存。
  2. 工作内存:每个线程都有自己的工作内存,它包含了该线程用到的变量的副本(拷贝)。线程对变量的所有操作都必须在工作内存中进行,然后再将变化同步回主内存。工作内存是CPU的高速缓存或者寄存器,也是每个线程独有的。

happens-before原则:这是Java内存模型中的一个重要原则,用于描述两个操作之间的内存可见性。如果一个操作happens-before另一个操作,那么第一个操作的结果对第二个操作是可见的。happens-before原则包括以下几个规则:

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  2. 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  3. volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  4. 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  5. start()规则:如果线程A执行操作ThreadB.start(),那么A线程的此操作happens-before于线程B中的任意操作。
  6. join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

通过happens-before原则,Java内存模型为程序员提供了在没有同步的情况下,如何进行线程间通信的规则。

什么是指令重排序?为什么要重排序?

指令重排序是指CPU为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个指令的执行先后顺序与代码中的顺序一致,但是它会保证程序最终执行结果与代码顺序执行的结果一致。

为什么要进行指令重排序呢?主要有以下几个原因:

  1. 充分利用处理器资源:现代的计算机处理器都采用了流水线技术,可以同时处理多条指令。如果一条指令的执行需要等待前一条指令执行完毕,那么处理器的其他部分就会空闲,浪费了处理器资源。通过指令重排序,可以让处理器的各个部分都尽可能地忙碌,从而提高处理器的利用率。
  2. 避免数据冒险:在处理器执行指令时,如果一条指令需要用到前一条指令的结果,那么就会产生数据冒险。通过指令重排序,可以将这两条指令的执行顺序调整,使得后一条指令在前一条指令的结果准备好之后再执行,从而避免数据冒险。
  3. 提高内存访问效率:内存访问的速度远低于处理器的速度,如果一条指令需要访问内存,那么处理器就需要等待。通过指令重排序,可以将访问内存的指令尽可能地提前,使得处理器在等待内存数据的同时,可以执行其他指令,从而提高内存访问效率。

需要注意的是,虽然指令重排序可以提高程序运行效率,但是在多线程环境下,指令重排序可能会导致程序运行结果的不确定性。因此,在多线程编程时,需要通过内存屏障(Memory Barrier)、锁等手段,来防止指令重排序带来的问题。

什么是内存可见性问题?

内存可见性问题主要包括以下几个方面:

  1. 工作内存与主内存:在多线程环境下,每个线程可能会有自己的工作内存(例如CPU的缓存或寄存器),线程对变量的读写操作可能会先在自己的工作内存中进行,然后再同步回主内存。
  2. 数据不一致:由于线程对变量的修改可能首先在工作内存中进行,如果这个新值还没有同步回主内存,其他线程就无法看到这个新值,这就可能导致数据的不一致。
  3. 解决方法:为了解决内存可见性问题,可以使用一些同步机制,如Java的volatile、synchronized等关键字。这些关键字可以确保一个线程对变量的修改,对其他线程立即可见。
  4. 影响:如果没有正确处理内存可见性问题,可能会导致程序行为的不确定性,例如一个线程无法看到其他线程对变量的修改,从而导致错误的结果。

单例模式的双重检查锁模式为啥要加volatile?

单例模式的双重检查锁模式中,使用volatile关键字的主要原因是为了解决内存可见性问题和防止指令重排序。

  1. 内存可见性:volatile关键字可以保证所有线程都能看到共享变量的最新值。如果不使用volatile,那么在某个线程中修改了instance的值,其他线程可能看不到这个修改。
  2. 防止指令重排序:在Java中,创建对象的过程可能会被编译器优化为重排序,这样就可能导致一个线程获取到一个还没有初始化完成的对象。具体来说,创建对象的过程可以分为三步:分配内存空间、初始化对象、将内存空间的地址赋给变量。但是由于JVM的优化,第二步和第三步可能会发生重排序,如果在多线程环境下,一个线程执行了第一步和第三步,还没来得及执行第二步,另一个线程就可能获取到一个还没有初始化完成的对象。而volatile关键字可以禁止指令重排序,从而避免这个问题。

所以,在单例模式的双重检查锁模式中,需要使用volatile关键字来保证线程安全。

说一下 CAS 底层原理?可能存在什么问题

  • 乐观锁与悲观锁

    • 乐观锁
      • CAS或则version版本机制
      • 操作数据不加锁,最后更新数据时检查数据有没有被修改,没有的话才更新
    • 悲观锁:加锁实现
  • 内存值,旧的预期值,要修改的值

  • 每次比较内存中值与预期值是否相同,不同就自旋,相同就修改

  • unsafe(里面全是native修饰的本地方法,可以直接调用操作系统)+lock cmpxchg(底层依靠硬件指令

  • 一种乐观锁实现机制

  • 缺点:

    • ABA(version)——当一个值从A被更新为B,然后又改回来,普通CAS机制发现不了。
    • 一直while浪费资源:若并发量高,许多线程反复尝试更新变量又更新不成功,循环往复,会给CPU带来高消耗
    • 不能保证代码块原子性:只能保证一个变量的原子操作,代码块要用sychronized
  • 场景:读多写少。对于资源竞争严重的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized

CAS 存在什么问题?

CAS(Compare and Swap)操作是一种无锁算法,在硬件层面上保证了操作的原子性。但是,CAS也存在一些问题:

  1. ABA问题:CAS需要在操作值的时候检查值有没有发生变化,如果没有变化则更新,但是如果一个值原来是A,变成了B,然后又变成了A,那么使用CAS进行检查时会认为没有发生变化,但是实际上是有变化的。这就是所谓的ABA问题。解决ABA问题的常见方案是使用版本号。在Java中,AtomicStampedReference和AtomicMarkableReference类提供了此类解决方案。
  2. 循环时间长开销大:如果CAS失败,会一直进行尝试。如果长时间不成功,可能会给CPU带来非常大的执行开销。
  3. 只能保证一个共享变量的原子操作:CAS只对单个共享变量有效,当操作涉及跨多个共享变量时CAS无效。但是可以通过把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i = 2,j = 20,可以把它们合并成一个数值:i * 10 + j = 40。然后使用CAS进行操作,操作之后再提取出共享变量的值。也可以使用锁,或者利用类似AtomicReference类来保证多个共享变量的原子性。
  4. 引起线程饥饿:由于CAS是一种乐观锁,如果一个线程长时间尝试更新某一变量但总是失败(因为其他线程总是在它之前更新了该变量),那么可能导致这种线程饥饿。这是一种由于线程在"操作-比较-更新"循环中花费过多时间,导致其他线程无法获得足够CPU时间片的情况。