juc并发编程

本文详细介绍了并发和并行的区别,以及Java中自旋锁、轻量级锁、偏向锁的概念和工作原理。同时,讨论了JMM内存模型如何确保多线程环境下的数据一致性,包括volatile关键字的作用以及happens-before原则。
摘要由CSDN通过智能技术生成

1.并发和并行的区别:

        并发实际就是同一时间只能处理同一个任务,时间片轮转,只要我们每次处理分配的时间足够短,在宏观看来就是是多个任务在同时进行。

        并行是突破了同一时间只能处理同一任务的限制,我们同一时间可以做多个任务。比如进行排序操作,就可以用到并行操作,等待所有子任务完成最终将结果汇总即可。包括MapReduce也是这种。

2.各种锁介绍

2.自旋锁:

        在没获得锁的线程中不会将等待状态的线程挂起,而是会不断的循环的方式,不间断的检测是否可以获取锁。由于单个线程的占用时间非常短暂,所以说循环次数不会太多,很快就可以获取锁,这就是自旋锁。当然仅仅在时间短的时候自旋锁的表现是比较好的,如果时间长,那么将会自动切换到重量级锁机制。自旋锁的默认等待时间为10次。超过则切换。

  1. 如果平均负载小于CPUs则一直自旋
  2. 如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞
  3. 如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞
  4. 如果CPU处于节电模式则停止自旋
  5. 自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差)
  6. 自旋时会适当放弃线程优先级之间的差异

        自旋锁在jdk1.6之后发生了改进,不再采用固定时长的锁等待机制,而是采用了自适应的自旋次数,自选次数取决于上一次自旋的情况。如果在同一个锁对象上,上一次自旋等待刚刚获得成功,并且持有锁的线程正在运行,那么这次自旋也是有可能成功的,所以会允许自旋更多次。当然,如果自旋多次都失败了,那么久可能不会再采取自旋策略。

3.轻量锁:

        轻量锁的目的就是在无竞争的情况下,减少重量锁产生的消耗(实际上并不是代替了重量锁,类似于赌同一时间只有一个线程在占用资源)。他不像重量锁那样,需要向操作系统申请互斥量,她的运作机制如下:

        在即将开始执行的代码块中,会先检查对象的mark word,查看对象是否被其他线程占用,如果没有其他线程占用,那么就会在当前线程中所处的栈帧中建立一个名为锁的记录的空间(lock record),用于复制并存储对象目前的Mark word信息,接着虚拟机将使用CAS操作将对象mark word 更新为轻量级锁的状态(具体为数据结构变为指向lock record的指针,指向的是当前栈帧)

        CAS:一种无锁算法,他不会为对象加锁,具体的方法为他会放任多个线程一起去竞争修改数据,但是在修改数据之前,会判断当前数据的值是否为预期的数值,如果是就进行正常的替换,如果不是就替换失败。

        CAS举例:如果两个线程都想修改变量i a线程来的时候查看发现值为默认的10,现在另一个线程想要将其修改为20,如果都是使用的cas算法,那么并不会加锁访问i,而是直接尝试修改i的值,如果i的值是10则直接修改,不是则说明有其他的线程正在操作,不能修改。

        

        如果CAS操作失败了的话,那么说明可能这时有线程已经进入这个同步代码块了,这时虚拟机会再次检查对象的Mark Word,是否指向当前线程的栈帧,如果是,说明不是其他线程,而是当前线程已经有了这个对象的锁,直接放心大胆进同步代码块即可。如果不是,那确实是被其他线程占用了。

        这时,轻量级锁一开始的想法就是错的(这时有对象在竞争资源,已经赌输了),所以说只能将锁膨胀为重量级锁,按照重量级锁的操作执行(注意锁的膨胀是不可逆的)

        image-20220302210830272

 

        所以,轻量级锁 -> 失败 -> 自适应自旋锁 -> 失败 -> 重量级锁

        解锁过程同样采用CAS算法,如果对象的MarkWord仍然指向线程的锁记录,那么就用CAS操作把对象的MarkWord和复制到栈帧中的Displaced Mark Word进行交换。如果替换失败,说明其他线程尝试过获取该锁,在释放锁的同时,需要唤醒被挂起的线程。

4.偏向锁

        偏向锁是一种更加轻量级的锁,干脆将整个的同步都消除掉,不再进行CAS操作了,偏向锁实际上就是专门为单个线程而生的,当某个线程第一次获得锁的时候,如果接下来其他线程来抢,那么偏向锁会根据当前的状态,决定是否回复到未锁定或者膨胀为轻量级锁。

        所以,最终的锁等级为:未锁定 < 偏向锁 < 轻量级锁 < 重量级锁

        值得注意的是,如果对象通过调用hashCode()方法计算过对象的一致性哈希值,那么它是不支持偏向锁的,会直接进入到轻量级锁状态,因为Hash是需要被保存的,而偏向锁的Mark Word数据结构,无法保存Hash值;如果对象已经是偏向锁状态,再去调用hashCode()方法,那么会直接将锁升级为重量级锁,并将哈希值存放在monitor(有预留位置保存)中。
image-20220302214647735

3.JMM内存管理

        为了缓存一致性问题,需要各个处理器在访问缓存时都遵循一些共同的协议。在读写时要根据协议来进行操作,这类协议有MSI,MESI.

        image-20220303114228749

        JMM内存管理规定:

  1.  所有的变量全部存储在主存上(指的是会出现竞争的变量,包括成员变量,静态变量,而局部变量这种属于线程私有的变量不包括在其中)
  2. 每条线程中都存着自己自己的工作内存,类比cpu 的高速缓存,线程对变量的所有操作,必须在自己的工作内存中,不能直接操作主内存中的变量。
  3. 不同线程之间内存是隔离的。如果需要在线程之间传递内容只能通过主内存去完成,无法直接访问对方的工作内存。

        也就是说,在每一条线程如果想操作主存中的数据,需要将主存中的数据复制到线程的工作内存中,然后对工作内存中的数据进行操作,操作完成后,也需要从工作副本中将结果拷贝到主存中,具体的操作就是save和load操作。         

这个内存模型,结合之前JVM所讲的内容,具体是怎么实现的呢?

  • 主内存:对应堆中存放对象的实例的部分。
  • 工作内存:对应线程的虚拟机栈的部分区域,虚拟机可能会对这部分内存进行优化,将其放在CPU的寄存器或是高速缓存中。比如在访问数组时,由于数组是一段连续的内存空间,所以可以将一部分连续空间放入到CPU高速缓存中,那么之后如果我们顺序读取这个数组,那么大概率会直接缓存命中。

前面我们提到,在CPU中可能会遇到缓存不一致的问题,而Java中,也会遇到,比如下面这种情况:

public class Main {
    private static int i = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            for (int j = 0; j < 100000; j++) i++;
            System.out.println("线程1结束");
        }).start();
        new Thread(() -> {
            for (int j = 0; j < 100000; j++) i++;
            System.out.println("线程2结束");
        }).start();
        //等上面两个线程结束
        Thread.sleep(1000);
        System.out.println(i);
    }
}

      可以看到这里是两个线程同时对变量i各自进行100000次自增操作,但是实际得到的结果并不是我们所期望的那样。

        那么为什么会这样呢?在之前学习了JVM之后,相信各位应该已经知道,自增操作实际上并不是由一条指令完成的(注意一定不要理解为一行代码就是一个指令完成的):

image-20220303143131899

         包括变量i的获取、修改、保存,都是被拆分为一个一个的操作完成的,那么这个时候就有可能出现在修改完保存之前,另一条线程也保存了,但是当前线程是毫不知情的。

image-20220303144344450

 4.重排序

在编译或执行时,为了优化程序的执行效率,编译器或处理器常常会对指令进行重排序,有以下情况:

  1. 编译器重排序:Java编译器通过对Java代码语义的理解,根据优化规则对代码指令进行重排序。
  2. 机器指令级别的重排序:现代处理器很高级,能够自主判断和变更机器指令的执行顺序。

指令重排序能够在不改变结果(单线程)的情况下,优化程序的运行效率,比如:

public static void main(String[] args) {
    int a = 10;
    int b = 20;
    System.out.println(a + b);
}

我们其实可以交换第一行和第二行:

public static void main(String[] args) {
    int b = 10;
    int a = 20;
    System.out.println(a + b);
}

虽然单线程下指令重排确实可以起到一定程度的优化作用,但是在多线程下,似乎会导致一些问题:

public class Main {
    private static int a = 0;
    private static int b = 0;
    public static void main(String[] args) {
        new Thread(() -> {
            if(b == 1) {
                if(a == 0) {
                    System.out.println("A");
                }else {
                    System.out.println("B");
                }   
            }
        }).start();
        new Thread(() -> {
            a = 1;
            b = 1;
        }).start();
    }
}

        上面这段代码,在正常情况下,按照我们的正常思维,是不可能输出A的,因为只要b等于1,那么a肯定也是1才对,因为a是在b之前完成的赋值。但是,如果进行了重排序,那么就有可能,a和b的赋值发生交换,b先被赋值为1,而恰巧这个时候,线程1开始判定b是不是1了,这时a还没来得及被赋值为1,可能线程1就已经走到打印那里去了,所以,是有可能输出A的。

5.volatile关键字(可见性,禁止重排,不保证原子性)

首先讲三个性质:

  • 原子性:其实之前讲过很多次了,就是要做什么事情要么做完,要么就不做,不存在做一半的情况。
  • 可见性:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
  • 有序性:即程序执行的顺序按照代码的先后顺序执行。

我们之前说了,如果多线程访问同一个变量,那么这个变量会被线程拷贝到自己的工作内存中进行操作,而不是直接对主内存中的变量本体进行操作,下面这个操作看起来是一个有限循环,但是是无限的:

public class Main {
    private static int a = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (a == 0);
            System.out.println("线程结束!");
        }).start();

        Thread.sleep(1000);
        System.out.println("正在修改a的值...");
        a = 1;   //很明显,按照我们的逻辑来说,a的值被修改那么另一个线程将不再循环
    }
}

        实际上这就是我们之前说的,虽然我们主线程中修改了a的值,但是另一个线程并不知道a的值发生了改变,所以循环中依然是使用旧值在进行判断,因此,普通变量是不具有可见性的。

要解决这种问题,我们第一个想到的肯定是加锁,同一时间只能有一个线程使用,这样总行了吧,确实,这样的话肯定是可以解决问题的:

public class Main {
    private static int a = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (a == 0) {
                synchronized (Main.class){}
            }
            System.out.println("线程结束!");
        }).start();

        Thread.sleep(1000);
        System.out.println("正在修改a的值...");
        synchronized (Main.class){
            a = 1;
        }
    }
}

        但是,除了硬加一把锁的方案,我们也可以使用volatile关键字来解决,此关键字的第一个作用,就是保证变量的可见性。当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去,并且这个写会操作会导致其他线程中的volatile变量缓存无效,这样,另一个线程修改了这个变时,当前线程会立即得知,并将工作内存中的变量更新为最新的版本。

public class Main {
    //添加volatile关键字
    private static volatile int a = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (a == 0);
            System.out.println("线程结束!");
        }).start();

        Thread.sleep(1000);
        System.out.println("正在修改a的值...");
        a = 1;
    }
}

        不对啊,volatile不是能在改变变量的时候其他线程可见吗,那为什么还是不能保证原子性呢?还是那句话,自增操作是被瓜分为了多个步骤完成的,虽然保证了可见性,但是只要手速够快,依然会出现两个线程同时写同一个值的问题(比如线程1刚刚将a的值更新为100,这时线程2可能也已经执行到更新a的值这条指令了,已经刹不住车了,所以依然会将a的值再更新为一次100)

        那要是真的遇到这种情况,那么我们不可能都去写个锁吧?后面,我们会介绍原子类来专门解决这种问题。

        最后一个功能就是volatile会禁止指令重排,也就是说,如果我们操作的是一个volatile变量,它将不会出现重排序的情况,也就解决了我们最上面的问题。那么它是怎么解决的重排序问题呢?若用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序

6.happens-before原则

        JMM提出了happens-before(先行发生)原则,定义一些禁止编译优化的场景,来向各位程序员做一些保证,只要我们是按照原则进行编程,那么就能够保持并发编程的正确性。具体如下:

  1. 程序次序原则,在同一个线程中,按照程序的顺序,前面的操作会对后续的任何操作happens-before。在同一个线程内,代码是有顺序执行的,其实就是可能会发生指令重拍,但是保证对代码的执行结果一定是和按照顺序执行的结果相同的。
  2. 监视器锁规则:对于一个锁的解锁操作,happens-before后续对于这个锁的加锁操作。就是说无论是单线程还是多线程,对于一个锁来说,一个县城对这个锁解锁之后,另一个县城获取了这个锁都能看到前一个锁的操作结果。
  3. volatile变量原则,如果一个线程先去写一个volatile变量,紧接着另一个线程去读这个变量,那么这个写操作的结果一定对这个读的变量的线程可见。
  4. 线程启动规则: 线程a去启动线程b,那么线程b中可以看到线程a启动b之前的操作。在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。
  5. 线程加入规则:如果线程A执行操作join()线程B并成功返回,那么线程B中的任意操作happens-before线程Ajoin()操作成功返回。
  6. 传递性规则:如果A happens-before B,B happens-before C,那么A happens-before C

那么我们来从happens-before原则的角度,来解释一下下面的程序结果:

public class Main {
    private static int a = 0;
  	private static int b = 0;
    public static void main(String[] args) {
        a = 10;
        b = a + 1;
        new Thread(() -> {
          if(b > 10) System.out.println(a); 
        }).start();
    }
}

首先我们定义以上出现的操作:

  • **A:**将变量a的值修改为10
  • **B:**将变量b的值修改为a + 1
  • **C:**主线程启动了一个新的线程,并在新的线程中获取b,进行判断,如果为true那么就打印a

  首先我们来分析,由于是同一个线程,并且B是一个赋值操作且读取了A,那么按照程序次序规则,A happens-before B,接着在B之后,马上执行了C,按照线程启动规则,在新的线程启动之前,当前线程之前的所有操作对新的线程是可见的,所以 B happens-before C,最后根据传递性规则,由于A happens-before B,B happens-before C,所以A happens-before C,因此在新的线程中会输出a修改后的结果10
 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值