多线程与高并发day04

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档


前言

个人学习笔记,仅供参考,欢迎指正!
原子性相对复杂,且各种面试题层出不穷,需要认真学习!


一、原子性

1.线程的原子性

从一个简单的小程序谈起:

public class Day04 {
    private static long n = 0L;

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[100];
        CountDownLatch latch = new CountDownLatch(threads.length);

        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(()->{
                for (int j = 0; j < 10000; j++) {
                    //synchronized (Day04.class){
                        n++;
                    //}
                }
                latch.countDown();
            });
        }
        for (Thread thread : threads) {
            thread.start();;
        }
        latch.await();
        System.out.println(n);
    }
}

1.多线程访问同一份数据,会产生竞争(race condition=>指多个线程访问共享数据的时候产生竞争)
2.数据的不一致(unconsistency),并发之下产生的不期望出现的结果。
如何保障数据一致性呢?—>线程同步(线程执行的顺序安排好)
具体:保证操作的原子性(Atomicity)
原子性概念:原子操作是不可分割的,不能并发执行,在执行完毕前不会被任何其它任务或事件中断。要做一定做完,要么就没有执行。

什么样的语句需要原子性:
在这里插入图片描述
使用jclasslib查看n++翻译成汇编指令后的构成:
在这里插入图片描述

第一句:把static值拿过来
第二句:放入栈空间
第三句:把值加好
第四句:放回去

这些指令还会翻译为本地汇编,所以即便只有一条代码也不一定能确定是原子操作。所以需要一种机制去保障原子性的操作。上锁即可。

上锁的本质:上锁的本质是把并发编程序列化。如下图:
在这里插入图片描述

在这里插入图片描述

一些基本概念:
monitor(管程)—>锁
critical section —>临界区
如果临界区执行时间比较长则称锁的粒度比较粗,反之则称锁的粒度比较细。

我们一般说的锁,其实是锁定了某个对象,只有持有这把锁的时候才能执行这些代码。

2.悲观锁与乐观锁

1.悲观锁:悲观的认为这个操作会被别的线程打断(悲观锁),synchorized(上一个小程序)
2.乐观锁:乐观地认为正操作不会被别的线程打断(乐观锁、自旋锁、无锁) cas操作。

在这里插入图片描述

解决ABA问题加Version即可,加时间戳、时间或者用bool的方式。

在比较完,再去更新值的时候如果被打断呢?也是一个问题,所以CAS必须保证他的原子性操作,接下来学习CAS的一些底层原理。来看下面这段代码:

public class Day04 {
    AtomicInteger count = new AtomicInteger(0);
    void m(){
        for (int i = 0; i < 10000; i++) {
            count.incrementAndGet();
        }
    }

    public static void main(String[] args) {
        Day04 day04 = new Day04();

        List<Thread> threads = new ArrayList<>();
        for (int i = 0; i < 100; i++) {
            threads.add(new Thread(day04::m,"thread-"+i));
        }

        threads.forEach((o)->{o.start();});
        threads.forEach((o)->{
            try {
                o.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
         System.out.println(day04.count);
    }
}

AtomicInteger类是可以进行一些原子操作的,而经过对incrementAndGet()方法的追踪发现他并没有使用synchronize或ReentrantLock锁等,它使用的是compareAndSwapInt方法,也就是说它的底层采用的是CAS的方法,继续追踪之后如下使用了本地方法:
在这里插入图片描述

它调用的是c++的unsafe.cpp中的方法:
在这里插入图片描述

在这里插入图片描述

最终实现是:
多核会加lock指令

lock cmpxchg

也就是说,在底层的实现上其实还是有一把锁,宏观上是乐观锁,微观上是悲观锁。

3.悲观锁和乐观锁谁的效率更高呢?

不同的场景:
	临界区执行的时间长,等的人很多,->重量级
	时间短、等的人少->自旋锁
	也可以做压测,根据情况选择。
	实战中:也可以选择,synchronize,因为synchronize经过不断优化,是可以完成锁升级的过程的。

4.synchronize保障可见性:
在这里插入图片描述

在解锁后,会把所有的内存状态和本地缓存做一个刷新,然后下一个线程再能继续。在底层,会有一条lock语句,有一个内存屏障的作用,本身要做一个内存的同步,因此synchronize可以保障可见性。

3.synchronize所升级深入详解

  1. 用户态与内核态
    作为操作系统,许多操作是操作系统可以执行,但其他普通程序不可以执行的,必须通过操作系统申请。所以为了保障操作系统的健壮性,现在很多操作系统会把指令分成级别,有些指令用户进程可以直接访问,有些指令作为用户空间的进程得通过操作系统来调用。例如:想访问网卡的内容,直接内存的内容,访问显卡的内容,都得通过操作系统来。从逻辑上讲:是把整个内存空间或者说内存的执行过程或者程序的执行过程,分成了两种状态。一种叫:内核态,一种叫用户态。内核态可以访问所有的指令,而用户态只能访问用户能访问的指令。例如:intel的cpu支持四种级别的分布,从ring0~ring3级,Linux内核工作在0级,可以访问所有的指令,Linux用户空间的程序工作在3级,有些指令不能直接访问。
    JDK早期,synchronize叫做重量级锁,因为申请锁资源必须通过kernel,系统调用。所以原来synchronize是重量级锁。向操作系统申请,通过操作系统老大通过一个从用户态到内核态的一个调用,著名的0X80。
    现在做了一些优化,就是上锁的时候在某些状态下是不需要向操作系统申请,只需要在用户空间就可以解决问题。现在要讲的主要就是这个升级过程。

  2. markword实现表:64bit

在这里插入图片描述

如何区分,锁的状态呢?优先看最低的两位,如果是00则为轻量级锁,也称为自旋锁;如果为10则是重量级锁,如果是11代表这个对象正在被回收。如果是01则有可能有两种状态,就需要看偏向锁位,如果是001则是无锁,也就是刚new出来的状态,如果是101则是偏向锁状态。

偏向锁和轻量级锁叫用户空间锁,不需要与操作系统申请。

偏向锁:当一个线程访问加了同步锁的代码块时,会在对象头中存储当前线程的 ID,后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁。如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了。
偏向锁是不需要加锁竞争机制的。

偏向锁的获取撤销:
首先获取锁 对象的 Markword,判断是否处于可偏向状态。(biased_lock=1、且 ThreadId 为空)
	如果是可偏向状态,则通过 CAS 操作,把当前线程的 ID 写入到 MarkWord
		a) 如果 cas 成功,那么 markword 就会变成这样。 表示已经获得了锁对象的偏向锁,接着执行同步代码块
		b) 如果 cas 失败,说明有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并且把它持有的锁升级为轻量级锁(这个操作需要等到全局安全点,也就是没有线程在执行字节码)才能执行
	如果是已偏向状态,需要检查 markword 中存储的ThreadID 是否等于当前线程的 ThreadID
		a) 如果相等,不需要再次获得锁,可直接执行同步代码块
		b) 如果不相等,说明当前锁偏向于其他线程,需要撤销偏向锁并升级到轻量级锁


偏向锁的撤销并不是把对象恢复到无锁可偏向状态(因为偏向锁并不存在锁释放的概念),而是在获取偏向锁的过程中,发现 cas 失败也就是存在线程竞争时,直接把被偏向的锁对象升级到被加了轻量级锁的状态。对原持有偏向锁的线程进行撤销时,原获得偏向锁的线程有两种情况:
   	  a) 原获得偏向锁的线程如果已经退出了临界区,也就是同步代码块执行完了,那么这个时候会把对象头设置成无 锁状态并且争抢锁的线程可以基于 CAS 重新偏向当前线程
      b) 如果原获得偏向锁的线程的同步代码块还没执行完,处于临界区之内,这个时候会把原获得偏向锁的线程升级为轻量级锁后继续执行同步代码块
      在我们的应用开发中,绝大部分情况下一定会存在 2 个以上的线程竞争,那么如果开启偏向锁,反而会提升获取锁的资源消耗。所以可以通过 jvm 参数UseBiasedLocking 来设置开启或关闭偏向锁

	摘自:本段摘自:原文链接:https://blog.csdn.net/m0_37540696/article/details/113247587
  1. 轻量级锁(自旋锁):竞争过程:

在这里插入图片描述

升级为自旋锁后,线程会在线程栈生成锁记录LR(Lock Record),以自旋方式竞争锁,竞争成功后会指向竞争成功的线程的LR的指针,即指向谁的LR则是谁获得锁。当竞争成功后,其他线程以自旋的方式继续竞争,直到前一个释放后一个成功如此往复循环。

重量级锁:则需要向操作系统申请锁。markword中记录着object Monitor,是JVM空间写的一个C++对象。它内部去访问时,是需要通过操作系统,拿到操作系统对应的那把锁,才能够继续持有才能锁定再干活。

  1. 锁升级过程
    只要有一个抢就会从偏向锁升级为轻量级锁。

在这里插入图片描述
如果想深入了解这个过程,则需要看hotspot的部分源码:interpreterRuntime.cpp

InterpreterRuntime::monitorenter方法
在这里插入图片描述

如果打开了偏向锁(UseBiasedLocking)就是进入fast_enter,否则slow_enter。如果偏向锁不成功依然进入slow_enter。
在这里插入图片描述

也就是会进入自旋,升级为轻量锁;如果自旋不成功,就会进入inflate,也就是锁膨胀,就会进入最终的重量级锁。
在这里插入图片描述在这里插入图片描述

下面这段代码经过jclasslib可以显示出JVM级的汇编:

public class Day04 {
    public static void main(String[] args) {
        Object o = new Object();
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
        synchronized (o){
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }
}

在这里插入图片描述

也就是说在源码层级,当我们使用了synchronize编译为class文件后,实际上是monitorenter,当synchronize大括号开始时monitorenter锁开始了,当monitorexit时即是synchronize大括号结束锁释放,第二个monitorexit,是产生任何异常时monitorexit,所以synchronize是自动上锁自动释放锁,当代码执行完或发生异常时自动释放。

  1. 锁重入
    重入锁,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。synchronized是我们熟知的一个重入锁;synchronized关键字隐式的支持重进入,比如一个synchronized修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获得该锁。
    synchronize是可重入锁。
    我们可以用代码来演示一下:
public class Day04 {
    private synchronized void method1() {
        System.out.println("invoke 1");
        method2();
    }
    private synchronized void method2() {
        System.out.println("invoke 2");
    }
    
    public static void main(String[] args) {
        Day04 demo = new Day04();
        new Thread(new Runnable() {
            @Override
            public void run() {
                demo.method1();
            }
        }).start();
    }
}

重入次数需要记录,因为解锁几次必须要对应。不同锁的实现是不一样的。

偏向锁记录在线程栈中,每重入一次加LR+1
在这里插入图片描述

当记录转为线程指针后,hashcode记录在了线程栈中。存在了LR中,LR中有一个指针,指向一个数据结构,这个数据结构记录着前面状态的用来做备份的markword我们成为displaced Markword。如果锁重入,就再生成一个LR,但是指针就是空值。当LR弹出时即解锁,全部弹出就会释放锁。轻量级锁也是类似方式。

重量级锁信息会记录在ObjectMonitor上的一个字段上?

  1. 轻量级锁什么时候升级为重量级锁。
    竞争加剧:有线程超过十次自旋(可以通过参数控制:-XX:PreBlockSpin),或自旋线程数超过CPU核数一半,1.6之后加入自适应自旋Adapative Self Spining,JVM自己控制。
    升级重量级锁:->向操作系统申请资源,linux mutex,CPU从3级-0级系统调用,线程挂起,进入等待队列,等待操作系统的调度,然后映射回用户空间。

为什么有自旋锁还需要重量级锁?
自旋是消耗CPU资源的,如果锁的时间长,或者自旋线程多,CPU会被大量消耗。重量级锁有自己的等待队列,不需要消耗CPU资源。

在这里插入图片描述
当申请锁的时候,会把线程放到锁上面的一些队列里面。处于自旋拿不到锁的线程都会被扔到WaitSet队列中,通过操作系统的进程调度,拿出来的线程才有资格持有锁,所以当竞争激烈时,重量级锁更合适。

偏向锁是否一定比自旋锁效率更高?
不一定,在明确知道会有多线程竞争的情况下,偏向锁肯定会涉及锁撤销,直接使用自旋锁。JVM启动过程,会有很多线程竞争(明确知道)所以默认情况启动时不打开偏向锁,过一段时间再打开。
默认情况偏向锁有时延,默认是4秒,以为虚拟机自己有一些默认启动的线程,里面有很多syn代码,这些sync代码启动时就知道肯定会有竞争,如果使用偏向锁,就会造成偏向锁不断的进行撤销和锁升级的操作,效率较低。
-XX:BiasedLockingStartupDelay=0
验证:

public class Day04 {
    public static void main(String[] args) throws InterruptedException {
        Thread.sleep(5000);
        Object o = new Object();
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
    }
}

输出结果:
在这里插入图片描述

当睡了五秒以后,markword中的记录变为了偏向锁。但他并没有记录偏向锁的线程指针,这种情况这把锁叫匿名偏向。

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值