CPU缓存及JVM关键字锁知识梳理

CPU的缓存核心知识

我们知道CPU也是存在缓存的,CPU缓存是位于CPU和内存之间的临时存储器,主要是为了解决CPU运行速度与内存读写速度不匹配。CPU在执行指令时需要从内存中获取指令和数据,但是CPU执行指令的速度要远远高于内存速度,这时候从内存中读写数据就会造成CPU一段时间的等待和浪费,因此引入了CPU三级缓存(L1,L2,L3),级别越小越靠近CPU,内存也越小。读取顺序L1->L2->L3->主内存。

如下图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QUIPrIAm-1647766355529)(\img\CPU.png)]

ps:CPU的主要硬件构成

1、PC程序计数器(Program Counter):记录当前指令地址。可以理解为我们的指令在内存中相当于一个字节数组,程序计数器记录的就是当前指令的内存地址,便于每次根据当前操作指令的占用大小进行向后移动。

2、Registers寄存器:cpu中有许多不同的寄存器。因为数据每次都从内存去取太慢了,所以把从内存取来的数据暂时存储在寄存器,便于CPU计算快速拿到需要用到的数据。现在说的64位计算机,它的每一个寄存器就是可以存储64位的数据。
JVM中的栈帧中的本地局部变量表其实就相当于“寄存器”的存在,但它是通过内存实现的,而这个寄存器是硬件级别的,效率要高的多。

3、ALU逻辑运算单元( Arithmetic & Logic Unit):主要用来进行运算。

缓存一致性:

引入了缓存虽然提高了CPU的运行性能,但同时也带来一个问题,我们知道CPU读取数据的时候总是遵循按块读取局部性原理,因为这两个特性,就会导致同一条数据被读取到两次,那么怎么去保证读取到的数据的一致性?

ps:

按块读取:

这里的块其实就是一个缓存行,大小为64字节。也就是说每次读取数据,都读取64字节大小的数据。但这样同时也会带来另一个问题,因为每次只读取了64字节的数据,如果我们的数据大小为20字节,两个线程同时进行修改,也就需要读取到两个缓存行中,假设我们同时对数据中10个字节进行修改(每个操作只修改该缓存行中的内容),为了保证数据一致性,每次修改都要触发一次缓存一致性协议,这也就是缓存的伪共享问题

为了解决这一问题,我们可以使用缓存行对齐策略。也就是让20字节的数据中每10个字节占满缓存行(10+54,其中54个字节由其他数据进行填补)。

缓存行的具体运用:

1、Disruptor框架。

2、在JDK1.8中对属性添加Contended注解,可以让属性独占一个缓存行。同时还要注意需要在JVM启动时设置-XX:-RestrictContended。

局部性原理:

时间局部性:如果某个数据被访问,那么不久的将来它还可能被再次访问。

空间局部性:如果某个数据被访问,那么它相邻的数据也可能被访问。

解决缓存一致性问题,

CPU层面通常有两个方法:

1、总线锁:由CPU总线进行LOCK加锁。这种方式在多 CPU 情况下,某个 CPU 对共享变量操作时,在总线上发出一个 #LOCK 信号,总线把 CPU 和内存之间的通信锁住了,其他 CPU 不能操该内存地址的数据。

2、缓存锁:降低锁的粒度,基于缓存一致性协议。(inter cpu中采用MESI协议。)

MESI协议—Modified修改,Exclusive独占,Shared共享,Invalid无效。

以下内容参考博客:https://zhuanlan.zhihu.com/p/375706879

明确几个概念:

单核CPU处理缓存一致性:

  • 通写法(Write Through):每次 CPU 修改了缓存内容,立即更新到内存,也就意味着每次 CPU 写共享数据,都会导致总线事务。
  • 回写法(Write BACK):每次 CPU 修改了缓存数据,不会立即更新到内存,而是等到某个合适的时机才会更新到内存中去。

多核CPU引入操作:

  • 写失效:当一个 CPU 修改了数据,如果其他CPU有该数据,则通知其为无效。
  • 写更新:当一个 CPU 修改了数据,如果其他CPU有该数据,则通知其更新数据。

缓存一致性协议满足特性:

  • 写传播(Write propagation):一个处理器对于某个内存位置所做的写操作,对于其他处理器是可见的
  • 写串行化(Write Serialization):对同一内存单元的所有写操作都能串行化。即所有的处理器能以相同的次序看到这些写操作

对于写串行化:总线上任意时间只能出现一个 CPU 的写事件,多核并发的写事件会通过总线仲裁机制将其转换成串行化的的写事件序列。

对于写传播:大致可以分为以下两种方式:

  • 嗅探(Snooping ):广播机制,即要监听总线上的所有活动。
  • 基于目录(Directory-based):点对点,总线事件只会发给感兴趣的 CPU (借助 directory)。

基于上述概念的MESI协议主要运用的操作有:

  • 回写法
  • 写失效
  • 缓存锁
  • 写传播 + 写串行化
  • 嗅探机制

MESI协议的大概流程:

有两个线程A、B分别去操作变量X。

1、当CPU A将主存中的变量x读入缓存中时,此时X副本的状态为E独占。

2、当CPU B将主存中的X 读入缓存中时,AB同时嗅探总线,得知X不止一个副本,此时X的状态变为S共享

3、当CPU A将CACHE A中的x修改为1后,Cache A中的X 的状态变为M修改,并发送消息给CPU B,CPU将X的状态变为I无效

4、当CPU A确认所有CPU缓存中的都提交了I无效状态,将修改后的值刷新到主存中,此时主存中的X变为了1,此时Cache A中的x变为E独享

5、当CPU B需要用到X,发出读取X指令,于是读取主存中的x,于是重复第二步。

MESI协议的失效问题:

1、CPU不支持缓存一致性协议。

2、该变量超过一个缓存行的大小。也就是说MESI是针对单个缓存行进行加锁,如果变量超过64字节,只能改用总线加锁的方式。

MESI协议带来的其他问题:

在 MESI 中,依赖总线嗅探机制,整个过程是串行的,可能会发生阻塞。

上面说到CPU A修改X值后,要等待其他的CPU中都提交了I状态,才会将修改后的值刷新到主内存中,也就是说在此期间,CPU A处于阻塞状态,同时其他的CPU修改X变量为I状态,如果此时CPU正处于忙碌状态,此失效事件可能会有延迟。

为了避免这两个问题发生,MESI引入了写缓冲区和失效队列。

写缓冲区(Load Buffer)

每个CPU中都有自己的写缓冲区,每当自身发生写事件后,不再对自身进行阻塞等待其他CPU的回执,而是将更新的值写入缓冲区,继续执行其他指令。再要进行自身读取时,先在写缓冲区中进行查询,如果有就直接进行获取。这一机制就是 Store Fowarding。

失效队列(Invalid Queue)

同样每个CPU都有自己的失效队列,当接收到需要将变量进行失效时,不再等失效事件发生后进行回执,而是放入失效队列后直接进行回执,后续CPU对失效队列进行处理。

MESI优化后带来的问题

引入 写缓冲区后,即使读写指令本身是按照顺序执行,但仍然有可能会乱序执行。

引入失效队列后,可能会读取到过时的数据。

也就是说本身MESI是强一致性协议,但引入性能优化后,退化成了最终一致性。

ps:指令重排序

一般来说指令重排有三种可能:

  1. **编译器层面:**编译器优化的重排序:编译器在不改变单线程程语义的前提下,可以重新安排语句的执行顺序
  2. **处理器层面:**指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP), 将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. **处理器层面:**内存系统的重排序:由于处理器使用缓存和读/写缓冲区,使得加载和存储过程看上去是在乱序执行。

这里就又引出了内存(读写)屏障,用来解决上面提出的一致性问题(指令重排序)。

  • 写屏障(Store Memory Barrier):告诉 CPU 在执行屏障之后的指令前,将所有在存储缓存(store buffer)中的数据同步到内存。
  • 读屏障(Load Memory Barrier):告诉 CPU 在执行任何的加载前,先处理所有在失效队列(Invalid)中的消息。

但是这里CPU只是提供了解决方案,具体什么时候执行,还需要用户主动调起才可以。

ps:内存屏障指令

  • sfence:在sfence指令 前的写操作必须在sfence指令后的写操作之前完成

  • lfence:在lfence指令前的读操作必须在lfence指令后的读操作前完成

  • mfence:在mfence指令前的读写操作必须在mfence指令后的读写操作之前完成。

LOCK指令:

  1. 开启总线锁或者缓存锁(通过在总线上发送 #LOCK 信号。联系上文所说,这里的缓存锁一般就是缓存一致性协议实现的)
  2. 与被修饰的汇编指令一起提供内存屏障的效果。

注意上面的所有内容都是基于CPU层面,还没有开始JVM层面。

JVM篇章

Volidate关键字解读

volatile是java虚拟机提供的一种轻量级同步机制,具备三大特性:

1、保证线程之间的可见性:如果某个线程修改了某个值,那么所有使用该变量的线程都会更新该变量值(当某个线程修改变量时,会将该值刷新至主内存中,其他线程再根据主内存中的值刷新至线程内存中)(synchronized也有该特性,详见synchronized底层实现原理)。

2、禁止指令重排 :代码编写后,其实要经过三次的指令重排(编译器重排、指令并行的重排、内存系统的重排),经过重新编排后可能和源码中的顺序不一致(但也要保证数据的依赖性),从而无法保证结果一致性,而volatile关键字这个特性就是解决指令重排问题。

3、不保证原子性:原子性指的是不可分割、完整性,即在执行的过程中,不可被打断。Volatile不保证原子性具体体现在多线程环境下,比如每个线程执行i++操作,执行了20000次,但最后i的值可能不是20000,出现值丢失现象。为什么?比如i++操作, class中其实分为3步进行完成,1、getfield获取原始值2、iadd执行+1操作,3、执行putfield把累加的值进行返回,多线程环境下,可能这3个步骤会出现重复线程导致最终值达不到预期。

JMM模型之可见性保证

我们知道线程自身维护有自己的工作内存,工作内存中存放的是从主内存中copy的变量副本,JVM层面主要是通过控制主内存和线程工作内存的交互来保证可见性。要注意的是这个特性不止volidate关键字拥有,因为这个特性是JMM控制,其他关键词也会有此特性。

volidate禁止指令重排之JVM内存屏障

上面已经说了CPU层面是如何进行内存屏障指令限制指令重排,在JVM中通过JSR规范也定义了内存屏障:

  • LoadLoad 屏障:操作序列 Load1,LoadLoad,Load2,在 Load2 及后续读取数据操作之前前,保证 Load1 要读取的数据被读取完毕,简单来说就是LoadLoad 后面的load指令要等LoadLoad 屏障之前的读操作指令完成才能执行。
  • LoadStore 屏障:操作序列 Load1,LoadStore,Store2,在 Store2 及其后续写入操作被刷出前,保证 Load1 要读取的数据被读取完毕。简单来说就是LoadStore后面的store指令要等LoadStore之前的load指令完成才能执行。
  • StoreStore 屏障:操作序列 Store1,StoreStore,Store2,在 Store2 及后续写入操作执行前,保证 Store1 的写入操作对其他处理器可见。简单来说就是StoreStore后面的store指令要等StoreStore之前的store指令完成后才能执行。
  • StoreLoad 屏障:操作序列 Store1,StoreLoad,Load2,在 Load2 及后续的读取操作执行前,保证 Store1 的写入对其他处理器可见。简单来说就是StoreLoad之后的load指令要等StoreLoad之前的store指令完成后才能执行。

volidate关键字正式使用内存屏障来保证禁止指令重排。

JMM层面约束指令重排其他规则

JMM 还提供了两组规则来约束重排序:as-if-serial 和 happens-before。它们保证了即使经过编译器和处理器优化后,程序执行的语义也不变。同时也更符合我们常规的认知。

as-if-serial规则

不管怎么进行重排序,程序的最终执行结果不能被改变。为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序。

happens-before规则

前面的一个操作的结果对后续操作时可见的,这两个操作可以在同一线程内,也可在不同线程之间。

一般常用的有下面 几种规则:

  • 程序次序规则:在一个线程内,按照程序代码顺序,前面的操作 happends-before 后续的任意操作。
  • 管程锁定规则:对于同一锁的解锁 happens-before 后续对这个锁的加锁。
  • volatile 变量规则:对于一个 volatile 变量的写操作,happens-before 后续对这个 volatile 变量的读操作。
  • 线程启动规则:若线程 A 调用 B 的 start() 方法,那么 start() 操作 happens-before 线程 B 中的任意操作。
  • 线程终止规则:若在线程 A 中,调用线程 B 的 join() 方法并成功返回,那么线程 B 中的任意操作 happens-before 该 join() 操作的返回。

同时 happens-before 具有传递性,A happends-before B,B happens-before C,则 A happens-before C。

原子引用

上面提到了volidate关键字并不保证原子操作,为了保证原子操作,JVM中提供了两种解决方案:

1、使用synchronized

2、使用JUC包下的Atomic*类(AtomicIntger/AtomicBoolean/AtomicLong/AtomicReference/AtomicStampedReference…)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-moPiCjV9-1647766355532)(\img\原子引用一览.png)]

我们先说说原子引用类的一些特性

Atomic类保证原子性底层使用了CAS技术。

通过查看Atomic源码可看到内部都是使用了final unsafe类进行操作(其内部方法均为native方法,用来直接操作底层系统资源),其中最主要的是compareAndSet方法:

unsafe.compareAndSwapInt(this, valueOffset, expect, update);

public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2); //获取最新的值
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }

this:当前对象

valueOffset:对象在内存中的地址(偏移量)

expect:预想中的值

update:新值

使用this和valueOffset得到内存中的值,与expect进行比较,如果一致,修改为新值,如果不一致,则一直进行循环。

CAS缺点:

我们可以看到cas其实是使用了do…while的一个自旋操作来保证原子性,这样做虽然解决了原子性,但在某些情况下,可能造成cpu开销过大,同时atomic只能保证一个共享变量的原子操作,而且会造成ABA问题的出现。(两个线程,其中一个线程将某个值修改了一次,又改为原来的值, 这时候另外一个线程进行比较的时候,还可以成功)。

解决ABA问题
//ABA问题:
public class Test {

    private final static String A = "A";
    private final static String B = "B";
    private final static AtomicReference<String> ar = new AtomicReference<>(A);
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            if(ar.compareAndSet(A,B)){
                System.out.println("我是线程1,我成功将A改成了B");
            }
        }).start();

        new Thread(() -> {
            if(ar.compareAndSet(A,B)){
                System.out.println("我是线程2,我成功将A改成了B");
            }
        }).start();

        new Thread(() -> {
            if(ar.compareAndSet(B,A)){
                System.out.println("我是线程3,我成功将B改成了A");
            }
        }).start();
        TimeUnit.SECONDS.sleep(5);
        System.out.println(ar.get());
    }
}
我是线程2,我成功将A改成了B
我是线程3,我成功将B改成了A
我是线程1,我成功将A改成了B
B

导致ABA问题的主要原因其实是cas的操作针对的是值修改,因此解决ABA核心就是在对值修改的同时比较版本。这里的版本可以是时间戳,也可以是一个标记。在JUC包下同样也提供了解决次方案的类AtomicStampedReference和AtomicMarkableReference。

AtomicMarkableReference解决

public class Test {

    private final static String A = "A";
    private final static String B = "B";
    private final static AtomicMarkableReference<String> ar = new AtomicMarkableReference<>(A,false);
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            if(ar.compareAndSet(A,B,false,true)){
                System.out.println("我是线程1,我成功将A改成了B");
            }else{
                System.out.println("我是线程1,修改失败");
            }
        }).start();

        new Thread(() -> {
            if(ar.compareAndSet(A,B,false,true)){
                System.out.println("我是线程2,我成功将A改成了B");
            }
        }).start();

        new Thread(() -> {
            if(ar.compareAndSet(B,A,ar.isMarked(), true)){
                System.out.println("我是线程3,我成功将B改成了A");
            }
        }).start();
        TimeUnit.SECONDS.sleep(5);
    }
}
我是线程2,我成功将A改成了B
我是线程3,我成功将B改成了A
我是线程1,修改失败

AtomicStampedReference解决

public class Test {

    private final static String A = "A";
    private final static String B = "B";
    private static AtomicInteger ai = new AtomicInteger(1);
    private final static AtomicStampedReference<String> ar = new AtomicStampedReference<>(A,1);
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            if(ar.compareAndSet(A,B,1,ar.getStamp())){
                System.out.println("我是线程1,我成功将A改成了B");
            }else{
                System.out.println("我是线程1,修改失败");
            }
        }).start();

        new Thread(() -> {
            if(ar.compareAndSet(A,B,1,ai.incrementAndGet())){
                System.out.println("我是线程2,我成功将A改成了B");
            }
        }).start();

        new Thread(() -> {
            if(ar.compareAndSet(B,A,ar.getStamp(),ai.incrementAndGet())){
                System.out.println("我是线程3,我成功将B改成了A");
            }
        }).start();


        TimeUnit.SECONDS.sleep(5);
        System.out.println(ar.getStamp());
    }
}
我是线程2,我成功将A改成了B
我是线程3,我成功将B改成了A
我是线程1,修改失败
3

看源码

AtomicMarkableReference:利用一个boolean类型的标记来记录,只能记录它改变过,不能记录改变的次数

AtomicStampedReference:利用一个int类型的标记来记录,它能够记录改变的次数。

public class AtomicStampedReference<V> {

    private static class Pair<T> {
        final T reference;
        final int stamp;
        private Pair(T reference, int stamp) {
            this.reference = reference;
            this.stamp = stamp;
        }
        static <T> Pair<T> of(T reference, int stamp) {
            return new Pair<T>(reference, stamp);
        }
    }
    .......
}
public class AtomicMarkableReference<V> {

    private static class Pair<T> {
        final T reference;
        final boolean mark;
        private Pair(T reference, boolean mark) {
            this.reference = reference;
            this.mark = mark;
        }
        static <T> Pair<T> of(T reference, boolean mark) {
            return new Pair<T>(reference, mark);
        }
    }
    .......
}

synchronized单机锁

上面说了解决原子操作有两种方法,这里开始对锁及锁对应的机制进行分析。

先大概了解一下锁的一些分类:

锁分类:
  • 乐观锁、悲观锁:两种锁只是一种概念。
  1. 乐观锁:乐观锁认为一个线程去拿数据的时候不会有其他线程对数据进行更改,所以不会上锁。实现方式:CAS机制、版本号机制。
  2. 悲观锁:悲观锁认为一个线程去拿数据时一定会有其他线程对数据进行更改。所以一个线程在拿数据的时候都会顺便加锁,这样别的线程此时想拿这个数据就会阻塞。比如Java里面的synchronized关键字的实现就是悲观锁。实现方式:就是加锁。
  • **独享锁、共享锁:**两种锁只是一种概念。
  1. 独享锁:该锁一次只能被一个线程所持有。例如:synchronized、ReentrantLock。

  2. 共享锁:该锁可以被多个线程所持有。

    读写锁ReentrantReadWriteLock中的读锁ReadLock是共享锁,写锁WriteLock是独享锁。

  • 互斥锁、读写锁:独享锁和共享锁的具体实现。
  1. 互斥锁的具体实现就是synchronized、ReentrantLock。
  2. 读写锁的具体实现就是读写锁ReadWriteLock。
  • 可重入锁(递归锁):同一线程获得最外层的锁后,内层递归函数仍能获取该锁的代码,在同一线程中拥有外部锁的同时,进入方法内部自动获取锁。也就是说线程可以进入任何它拥有的锁所同步的代码块。ReentrantLock和synchronized都是可重入锁。

  • 公平锁、非公平锁:synchronized–非公平锁,ReentrantLock:默认非公平锁,可在构造锁的时候传入true变为公平锁。

  • **分段锁:**一种锁的涉及。例如jdk1.7中的CurrentHashMap。底层使用的就是分段锁。

  • 偏向锁 -> 轻量级锁(自旋锁) ->重量级锁:在JDK 1.6里引入了4种锁的状态:无锁、偏向锁、轻量级锁和重量级锁,它会随着多线程的竞争情况逐渐升级,但不能降级。研究发现大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了不让这个线程每次获得锁都需要CAS操作的性能消耗,就引入了偏向锁。当一个线程访问对象并获取锁时,会在对象头里存Mark Word里进行标记,之后该线程再次尝试获取锁后只需要判断mark word中是否有这个线程的ID(标记),如果有就不需要进行CAS操作,这就是偏向锁。当线程竞争更激烈时,偏向锁就会升级为轻量级锁,轻量级锁认为虽然竞争是存在的,但是理想情况下竞争的程度很低,通过自旋方式等待一会儿上一个线程就会释放锁,但是当自旋超过了一定次数(10次),或者一个线程持有锁,一个线程在自旋,又来了第三个线程访问时(反正就是竞争继续加大了),轻量级锁就会膨胀为重量级锁,因此synchronized锁也并不一定就是重量级锁,只能等锁竞争到一定程度,才会上升为重量级锁。

锁的分类有很多种,根据各自的特性分为不同的锁,因此也没有必要全部记住。

synchronized底层实现

在了解synchronized底层实现前,先补充基础知识点。

JVM中对象结构

在jvm中,每个存放的对象其实是有三部分组成:对象头、实例数据、对其填充。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KCaATCJc-1647766355533)(\img\JVM对象构成.png)]

**实例数据:**存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。

**对齐填充:**由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

对象头:这一块是jvm实现synchronized锁的基础。在hostpot中对象头中主要分为2部分,1部分用于存储对象自身运行时数据,如哈希码、GC分代年龄等,这部分数据的长度在32位和64位的虚拟机中分别为32位和64位。官方称为Mark Word。另一部分用于存储指向对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分存储数组长度。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oKdlIIKN-1647766355533)(\img\JVM对象头.png)]

由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间。(以下为32/64位虚拟机markword示例)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IttKovpo-1647766355534)(\img\JVM对象头markword.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ehnF04Uz-1647766355534)(\img\JVM对象头markword64位.png)]

可以看到无论是32位还是64位虚拟机,都是在最后2字节上表达该对象是否加锁。

synchronized底层实现之monitor

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iYpUl3L9-1647766355535)(\img\synchronized字节码.png)]

通过字节码可以看到synchronized在字节码层次用了monitorenter、monitorexit两个指令,出现两次monitorexit,一次是正常退出,一次是异常确保退出。也就是说synchronized底层其实是通过monitor(监视器)对象来实现的。

mark word中锁标志位指向的其实是monitor对象的起始地址,每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kS8PMHsc-1647766355535)(\img\hotspot-monitor.png)]

ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。由此看来,monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因。

运行monitorenter指令:

  • 1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
  • 2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1。【可重入】
  • 3、如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

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

synchronized代码块和方法的实现区别:

synchronized代码块编译后可看到monitorenter和monitorexit指令,但synchronized方法没有这两条指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这便是synchronized锁在同步代码块和同步方法上实现的基本原理。

jvm对synchronized 的优化(锁升级)

在java6后,Java官方对从JVM层面对synchronized较大优化,主要是引进了偏向锁、轻量级锁,锁的级别从低到高总共有4种形态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,锁可以升级但不能降级。

1、**偏向锁:**经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁

2、**轻量级锁:**倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

3、**自旋锁:**轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。

4、**重量级锁:**Synchronized的重量级锁是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。
5、**锁消除:**消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。

LOCK锁

lock锁概念

java.util.concurrent.locks.Lock本质上只是java1.5之后引入的一个接口,是属于api层面用于多线程下资源的控制。Lock接口的实现类,基本上都是通过聚合了一个队列同步器(AQS)的子类(sync)完成线程访问的控制。要注意的是与synchronized不同的是lock锁必须手动进行关闭。

Lock接口中的主要方法:

//尝试获取锁,获取成功则返回,否则阻塞当前线程
void lock(); 

//尝试获取锁,线程在成功获取锁之前被中断,则放弃获取锁,抛出异常 
void lockInterruptibly() throws InterruptedException; 

//尝试获取锁,获取锁成功则返回true,否则返回false 
boolean tryLock(); 

//尝试获取锁,若在规定时间内获取到锁,则返回true,否则返回false,未获取锁之前被中断,则抛出异常 
boolean tryLock(long time, TimeUnit unit)  throws InterruptedException; 

//释放锁
void unlock(); 

//返回当前锁的条件变量,通过条件变量可以实现类似notify和wait的功能,一个锁可以有多个条件变量
Condition newCondition();
AQS(AbstractQueuedSynchronizer)

是用来构建锁或者其他同步器组件的重量级基础框架及整个JUC体系的基石。AQS使用了一个volatile的int类型的成员变量(state)来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作,将每条要去抢占资源的线程封装成一个NODE节点(内部维护一个waitStatus变量和前后指针)来完成锁资源的分配,通过**CAS、自旋、LockSupport.park()**等方式来完成对state值的修改。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ellvPFQM-1647766355536)(C:\Users\Administrator\Desktop\线程\new\img\AQS.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HWloVSyh-1647766355536)(C:\Users\Administrator\Desktop\线程\new\img\LOCK锁类关系.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OUv3e5fr-1647766355537)(C:\Users\Administrator\Desktop\线程\new\img\aqs-state关系.png)]

ReentrantLock加锁流程

这里以非公平锁(默认)加锁流程源码为例:

 static final class NonfairSync extends Sync {
        final void lock() {
        	//cas尝试加锁
            if (compareAndSetState(0, 1))
            //加锁成功设置当前拥有锁者为当前线程
                setExclusiveOwnerThread(Thread.currentThread());
            else
            //进入等待队列
                acquire(1);
        }
 }
 //加锁总步骤
  public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
    //第一步:tryAcquire,再次尝试加锁
     protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }
        
     final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            //获取state是volatile  state;
            int c = getState();
            if (c == 0) {
                if (compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            //可重入锁
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }

//第二步,添加到等待队列
private Node addWaiter(Node mode) {
        Node node = new Node(Thread.currentThread(), mode);
        // Try the fast path of enq; backup to full enq on failure
        Node pred = tail;
        if (pred != null) {
            node.prev = pred;
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        enq(node);
        return node;
    }
//第三步,再次尝试加锁
final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();
                //如果是队列头部
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                //shouldParkAfterFailedAcquire :这里运用Node中的waitstatus 状态来
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

//可以看到在parkAndCheckInterrupt 和cancelAcquire 中用到了lockSupport
   private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);
        return Thread.interrupted();
    }

LockSupport

LockSupport用于创建锁和其他同步类的基本线程阻塞原语。是对线程wait和notify机制的一种补充增强。

Support使用了一种名为Permit(许可证)的概念来做到阻塞和唤醒线程的功能,每个线程都有一个许可证,permit只有两个值1和0,默认是0;可以把许可证看成是一种(0,1) 的信号量,但与信号量(Semaphore)不同的是,permit的累加上限是1。

LockSupport中的等待和唤醒机制:主要方法:park()-等待和**unpark**(Thread thread)-唤醒。

与object的wait和notify,condition中的await和signal不同的是LockSupport中的park和unpark可以在任意地方出现,而且不必按照顺序。每次调用park就会消费permit,也就是从1到0,如果permit为0就会进行阻塞,直到unpark将permit变成1。线程阻塞需要消耗凭证,这个凭证最多只有一个(累加无效)。

LockSupport是一个线程阻塞工具类,所有的方法都是静态方法,可以让线程在任意位置进行阻塞,阻塞之后也有对应的唤醒方法,LockSupport方法内部其实是调用unsafe中的native代码。

Condition

在LockSupport的时候已经说过ReentrantLock是通过Condition对象来实现线程的await和signal。在使用Condition时必须是Lock实例的newCondition()返回,一个锁可以获取多个Condition,以达到控制多个线程交替唤醒的机制。

public static void main(String[] args) throws IOException {

    ReentrantLock reentrantLock = new ReentrantLock();
    Condition condition = reentrantLock.newCondition();
    Condition condition2 = reentrantLock.newCondition();

    new Thread(() -> {
        reentrantLock.lock();
        try {
            for (int i = 0; i < 10; i++) {
                condition.await();
                condition2.signal();
                TimeUnit.SECONDS.sleep(1);
                System.out.println("aaaaaaaaaaaaaa");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            reentrantLock.unlock();
        }
    }, "A").start();
    new Thread(() -> {
        reentrantLock.lock();
        try {
            for (int i = 0; i < 10; i++) {
                condition.signal();
                condition2.await();
                TimeUnit.SECONDS.sleep(1);
                System.out.println("bbbbbbbbbbbbbb");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            reentrantLock.unlock();
        }
    }, "B").start();


}
aaaaaaaaaaaaaa
bbbbbbbbbbbbbb
aaaaaaaaaaaaaa
bbbbbbbbbbbbbb
aaaaaaaaaaaaaa
bbbbbbbbbbbbbb
ReentrantReadWriteLock

实现ReadWriteLock接口,这个接口只有2个方法:

Lock readLock();

Lock writeLock();

可以看到提供了一个读锁(共享锁),写锁(互斥锁),无论是读锁还是写锁都是可重入锁。其内部也是通过AQS实现锁的原理。ReentrantReadWriteLock内部维护了两个内部类:

public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
public ReentrantReadWriteLock.ReadLock  readLock()  { return readerLock; }

而WriteLock和ReadLock内部同样是使用Sync extends AbstractQueuedSynchronizer实现lock和unlock等操作。

ThreadLocal

在了解ThreadLocal之前,先了解一下java中的四种引用类型:

四种引用类型
强引用

强引用就是普通引用,最常见的就是Object o = new Object()这种,及时jvm内存不足,也不会进行回收,而是抛出OOM错误,只有当o=null时,才可以被回收。

软引用

软引用就是当jvm内存不足的时候就会被回收(内存充足时不进行回收)。

//此实验前提将jvm内存调整为20M:-Xmx20M
public static void main(String[] args) throws InterruptedException {
    //此时new一个软对象,该对象放一个10M的byte的数组
    SoftReference<byte[]> sf = new SoftReference<>(new byte[1024*1024*10]);
    //此时是可以从sf中拿到数据的
    System.out.println(sf.get());// [B@66133adc
    //手动调用垃圾回收器回收
    System.gc();
    try{
        //睡500毫秒
        Thread.sleep(500);
    } catch (Exception e) {
        e.printStackTrace();
    }
    //此时也是可以从sf中拿到数据的
    System.out.println(sf.get());// [B@66133adc
    //这时,我们再new一个软对象,该对象放一个10M的byte的数组
    SoftReference<byte[]> sf1 = new SoftReference<>(new byte[1024*1024*10]);
    //这个时候,我我们就拿不到数据了,
    //sf就会被垃圾回收器回收了,打印的就是为空
    System.out.println(sf.get());// null
}

[B@2077d4de
[B@2077d4de
null
弱引用

弱引用就是不管内存中是否有空间,只要遇到垃圾回收器,就会被回收。ThreadLocal中使用了弱引用。

public static void main(String[] args) throws InterruptedException {
    //new 一个弱引用,然后里面存放1M的byte数组
    WeakReference<byte[]> wr = new WeakReference<>(new byte[1024 * 1024]);
    //此时在 wr 中是有数据的
    System.out.println(wr.get());
    //手动调用垃圾回收器
    System.gc();
    //这个时候在 wr 中是空的。
    System.out.println(wr.get());

}
[B@2077
虚引用

虚引用会跟一个引用队列相关联使用,它的原理就是,当一个虚引用指向的对象被回收的时候,它会把一个信息添加到跟这个虚引用相关联的这个队列中。还有就是虚引用的get方法,返回的永远是 null。

TheadLocal解读
* This class provides thread-local variables.  These variables differ from
* their normal counterparts in that each thread that accesses one (via its
* {@code get} or {@code set} method) has its own, independently initialized
* copy of the variable.  {@code ThreadLocal} instances are typically private
* static fields in classes that wish to associate state with a thread (e.g.,
* a user ID or Transaction ID).

源码解释:这个类提供了线程本地(局部)变量。这些变量不同于普通成员,是属于线程自身的变量。ThreadLocal其实就是为线程提供了一个自身可见的变量操作类,实现了多线程下的数据隔离,保证了线程的安全性。

Thread类中的两个变量:threadLocals和inheritableThreadLocals。

ThreadLocal.ThreadLocalMap threadLocals = null;
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
}
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-722wCfbb-1647766355537)(C:\Users\Administrator\Desktop\线程\new\img\ThreadLoca_set方法.png)]

其中getMap方法返回的是Thread类中的threadLocals,也就是ThreadLoacl中的ThreadLocalMap内部类。

要注意的是ThreadLocalMap里面的entry对象是弱引用(WeakReference),而弱引用的特点是当GC时,如果内存不够将进行回收。

**ThreadLocalMap的内部实现:**ThreadLocalMap内部实际上是一个Entry数组。

当前ThreadLocal的引用k被传递给WeakReference的构造函数,所以ThreadLocalMap中的key为ThreadLocal的弱引用。当一个线程调用ThreadLocal的set方法设置变量的时候,当前线程的ThreadLocalMap就会存放一个记录,这个记录的key值为ThreadLocal的弱引用,value就是通过set设置的值。如果当前线程一直存在且没有调用该ThreadLocal的remove方法(线程的ThreadLocalMap里面的ThreadLocal变量的弱引用在gc的时候就被回收,但是对应的value还是存在的这就可能造成内存泄漏(因为这个时候ThreadLocalMap会存在key为null但是value不为null的entry项),如果这个时候别的地方还有对ThreadLocal的引用,那么当前线程中的ThreadLocalMap中会存在对ThreadLocal变量的引用和value对象的引用,是不会释放的,就会造成内存泄漏。

ThreadLocal可能引发的问题

结论:

1、因为ThreadLocal只是对本线程可见,这里就涉及到一个线程转换的问题,如果线程发生改变(跨项目)原有的TheadLocal不能被传递。

2、使用ThreadLocal之后必须要进行remove回收,否则可能造成内存泄露。ThreadLocalMap中的Entry的key是弱引用,可以在gc的时候进行回收,此时ThreadLocalMap中就会存在key为null但是value不为空的情况。

ThreadLocal之inheritableThreadLocals

ThreadLocal只能对本线程可见,如果想要线程之间通信,可以借助inheritableThreadLocals(这个只能是父子类可以,也就是说只能在一个线程内部调用另外一个线程的时候可以实现数据共享)。inheritableThreadLocals:主要是重写了父类Thread中的createMap和getMap方法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值