【无标题】

JUC

线程和进程的区别

  • 进程:进程可以理解为程序的基本运行单位,进程是动态的。一个程序是从一个进程从创建,运行到消亡的过程

  • 线程:线程是一个比进程更小的执行单位,是cpu调度和分派的基本单位。一个进程在其执行的过程中可以产生多个线程。

  • 二者区别: 我从JVM角度分析这个事情。首先一个进程可能拥有多个线程,**多个线程共享一个JVM进程的堆和方法区或者说元空间资源。但是 每个 线程拥有自己的程序计数器,虚拟机栈和本地方法栈(HOTSPOT虚拟机中二者合二为一)所以二者的区别最大的不同点在于每个进程之间都是独立的,每个线程不一定,因为同一进程中的线程可能会相互影响(比如线程安全问题)。 **

    • 深挖(谨防傻逼面试官): 为什么堆和方法区是线程共享的,而程序计数器,虚拟机栈和本地方法栈式线程私有的呢?
      • 程序计数器为什么是私有的?
        • 这哥们有两个作用
          • 字节码解释器通过改变pc计数器来读取指令,实现流程控制(了解)
          • 多线程的情况下,pc计数器用于记录当前线程执行的位置,进行上下文切换的时候恢复该线程上次的运行状态
        • 所以:程序计数器 私有 主要是为了 线程上下文切换后能够恢复到正确的执行位置
      • 虚拟机栈为什么是私有的?
        • 虚拟机栈里面放的东西是栈帧,里面有局部变量表,操作数栈,常量池引用等信息。(本地方法栈同理,不过他存的是native方法)
        • 所以为了保证本线程的局部变量不被其他的线程访问到,虚拟机栈和本地方法栈是线程私有的
      • 堆和方法区:堆里面主要放的是新创建的对象。方法区主要存放被加载的类信息,常亮,静态变量等数据。这些东西在多线程的情况下并不影响程序的正常执行,比如A线程 new 一个对象, B线程new一个对象,他俩不会使用同一个地址。不会出现问题。
  • 总结

    • 线程是进程划分成最小的运行单位
    • 二者的区别最大的不同点在于每个进程之间都是独立的,每个线程不一定,因为同一进程中的线程可能会相互影响(比如线程安全问题)。
    • 线程执行的开销比较小,进程相反

并发与并行,串行与并行

  • 并行:多个事件在同一时刻发生
  • 并发:并发是任务数多余cpu核数。在一个时间段有多个线程在执行任务。对于一个cpu而言,把cpu的运行时间分割成几部分交给几个线程去执行,一个线程在执行的时候其他线程处于挂起状态,这种状态叫并发。
  • 串行:串行指在同一时间只能有一个线程执行任务,这个线程执行完成当前这个才能执行下一个线程的任务。

同步,异步

  • 同步:同步就是顺序执行,执行完一个执行下一个,需要等待,协调运行。发出一个调用后没有得到结果就不能返回,要一直等待。
  • 异步:异步让调用方法的主线程不需要同步等待另一线程工作的完成,让主线程能够干其他的事情。
  • 区别:一句话,同步不能开启新的线程,异步可以。

多线程

  • 为什么使用多线程?

    • 使用多线程能够提升系统的并发能力和性能

    • 多核cpu意味着多个线程能够同时运行,减少了线程上下文切换的开销

      • 上下文切换: 线程在执行的过程中会有自己的运行条件和状态。 出现一些特殊情况,比如说让出cpu的资源,时间片用完,请求io,阻塞线程等。这些情况都会发生线程切换。 切换就需要保存线程的上下文,留着下次使用。加载下一个要占用cpu的线程的上下文。
  • 使用多线程可能会出现线程安全问题。例如死锁

死锁:多个线程被阻塞,他们中的一个或者全部都在等待某个资源的释放。因为线程被阻塞,所以线程不可能正常终止

举例如下

public class Demo {
    private static Object resource1 = new Object();//资源 1
    private static Object resource2 = new Object();//资源 2
    public static void main(String[] args) throws Exception{
        new Thread() {
            () -> {
                synchronized(resource1) {
                    Thread.sleep(1000);
                    System.out.println("aaaaa");
                    synchronized(resource2) {
                        System.out.println("bbbbb");
                    }
                }
            
         new Thread() {
            () -> {
                synchronized(resource2) {
                    Thread.sleep(1000);
                    System.out.println("aaaaa");
                    synchronized(resource1) {
                        System.out.println("bbbbb");
                    }
                }
            }
        }
    }
}

死锁产生的四个条件:

  1. 互斥条件:被争夺的资源一个时刻只能有一个线程占用
  2. 请求与保持条件:一个线程请求资源被阻塞,对已经获得的资源保持不放
  3. 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行抢夺,只能自己释放
  4. 循环等待:线程之间形成循环的资源等待关系。一个接一个。(恶心点理解,人体蜈蚣,这边刚出来那边进去了)

定位死锁的方法:JAVA自带的 jps , 输入jps -l 获得我们想要检测的方法的端口号,然后 jstack 查看该方法。获取信息。 会打印一个到具体包下哪个类下的具体信息。

sleep() 和 wait () 的区别?

共同点:调用这两个方法都会让线程进入阻塞状态。

  1. sleep是Thread类里面的一个静态方法,wait是Object的方法,每个类里都有这个方法。
  2. 醒来的时机不同:
    1. 如果wait方法在没有传参数的时候,他是需要通过notify来唤醒他。wait和wait(long)都能通过notify方法唤醒。
    2. sleep再时间到后自己就醒了。
  3. 锁特性不同
    1. wait方法的调用必须获取wait对象的锁,锁的就是调用wait方法的对象,sleep没有这个限制,sleep做的事是让当前线程暂停,和锁没关系
    2. wait方法执行后会释放对象的锁,允许其他线程获取该对象的锁。举个例子就是synchronized代码块里面wait被唤醒了,这个同步代码块的锁就被释放了。sleep睡醒了之后也不会释放这个锁。
Thread类的run方法能够被直接调用嘛?

能被调用。但是如果直接被调用他只是作为一个普通的方法被执行。而不是以多线程的状态。 通常 我们 new 一个新线程,然后 调用start方法, (我有粗略的看过底层的c语言版本的代码,他会有一个操作系统级别的操作,为当前操作分配一个线程。)然后会执行线程的相应准备工作,使线程进入就绪状态。然后会自动调用run方法。这套操作自动调用的run方法的代码逻辑就是多线程情况下跑的。

JMM

JMM是一个抽象的规范,主要围绕的是多线程的原子性,可见性,有序性(JMM三大特性)。

抽象了线程与主内存之间的关系,线程与线程之间共享的变量必须存储在主内存中

什么是主内存?什么是本地内存?

主内存:所有县城创建的实例对象都放在主内存中,不管该实例是成员变量还是方法中的局部变量

本地内存:每个线程都有一个私有的本地内存来存储共享变量的副本,并且每个线程只能访问自己的本地内存,无法访问其他线程的本地内存。本地内存是JMM抽象出来的一个概念,存储的是 主存中的共享变量的副本

线程与线程之间不能更改对方的本地变量,线程之间进行通信要通过主内存。 JMM为共享变量提供了可见性的保障

happens-before原则

定义:

  • 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作课件,并且第一个操作的执行顺序在第二个操作之前(从程序员的视角出发)

  • 两个操作存在happens-before关系,并不意味着JAVA平台必须按照这个指定的顺序来执行。在结果一致的情况下,JMM有些情况会支持指令重排序

  • 在这里插入图片描述

原则表达的意义其实并不是一个操作发生在另外一个操作的前面,虽然这从程序员的角度上来说也并无大碍。更准确地来说,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里

happens-before 的常见规则有哪些?

主要是五条重点

  • 程序顺序规则:一个线程内,书写在前面的操作happens-before 于书写后面的操作
  • 解锁规则:解锁happens-before 于加锁
  • volatile变量规则:对于一个volatile变量的写操作,happend-before 与后面对这个voliatile变量的读操作。说爆了对于 vo 变量的写操作的结果对于发生在其后面的任何操作都是可见的。
  • 传递规则:如果 A 发生在 B前面, B发生在C前面, 那么A发生在C前面
  • 线程启动规则:Thread对象的start()方法发生在此线程的每一个行动之前

如果有操作不满足happends-before规则,那么两个操作就没有顺序的保障,JVM可以对这两个操作进行重排序(指令重排)。

JMM围绕的并发编程的三大特性
  • 原子性:按事务那么理解,要么都执行,要么都不执行,借助synchronized,Lock接口的方法,或者原子工具类(cas思想)实现
  • 可见性:如果一个线程对共享变量进行了修改,那么其他的线程都是立即看到修改后的最新值。
  • 有序性:有时候可能会出现指令重排序的问题,代码的执行顺序未必是编写代码的时候的顺序,指令重排序在多线程的情况下不一定能保证语意一致,在JAVA中,用volatile禁止指令重排序

volatile关键字

作用:能够保证变量的可见性(可见性见JMM规范)

特点:

  • 能够保证可见性,如果将变量声明为volatile修饰的,那么每次使用它的时候都回去主存读取它

    需要保证见性的起因就是因为种种原因导致当前线程看不到其他线程对共享资源的修改。 有可能是编译器优化。JVM里面有一个JIT 叫即时编译器。这个哥们的作用就是优化代码。将热点代码放进codeCache里面,后续再执行这个代码,就会直接去缓存里面读机器码。不会再编译了。(热点代码就是在一定时间内对方法或者代码调用次数超过一个阈值的时候,会被认定为热点代码。)这种读缓存的行为产生的问题就是,当前线程读数据的时候就不会去物理内存读数据了,去缓存读,这样就会导致其他线程的更改让当前线程不可见,就会产生线程安全问题。解决这个问题的方法就是加volatile关键字,加这个关键字的变量就会使这个变量的读取不通过codecache,也就是说这多个线程读的是一份数据。就不会产生可见性问题了。
    在这里插入图片描述

  • 不保证原子性

  • 禁止指令重排

    • 指令重排:这是一种 编译器处理器 在执行程序的时候的一种优化技术。目的是通过重新排序指令的执行顺序,提高程序的性能效率。 在JAVA中,多线程情况下可能会因为指令重排,导致JVM的一些指令出现顺序不一导致结果出现问题。 存在数据依赖禁止重排序 。重排序后的语句不能更改原有的串行语义。也就是,单线程中的执行结果要保持一致。

    • 解决指令重排:如果我们将变量声明为volatile,那么在对这个变量进行读写操作的时候,会插入内存屏障,防止指令重排。

      • 引入JMM定义的八种同步操作

        锁定(lock): 作用于主内存中的变量,将他标记为一个线程独享变量。

        解锁(unlock): 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。

        read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。

        load(载入):把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。

        use(使用):把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。

        assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

        store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。

        write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。

      • 内存屏障:白话:这点之前所有的读写操作都执行后才可以开始执行此点之后的操作。 避免代码重排。这玩意说白了就只一种JVM指令,JMM的重排规则会要求JAVA编译器生成JVM指令时插入内存屏障,通过内存屏障实现volatile的特性。

        内存屏障之前所有的 写操作 都要写回主内存

        内存屏障之后的 读操作 都能获得内存屏障之前的所有写操作的最新结果

        写屏障(store Memeory Barrier) : 告诉cpu在 写屏障之前将所有存储在缓存的数据同步到主存中。看到store屏障,必须把该指令之前所有的写入操作执行完才行。写操作之后加写屏障,将工作内存或缓存中的数据刷到主存里

        读屏障(Load Memeory Barrier): cpu在load之后的读操作,都在load后执行。也就是说,load屏障指令之后能保证后面读数据指令一定能够读到最新的数据。在读操作加入读屏障,让工作内存或缓存中的数据失效,回主存中获取最新的数据

        内存屏障的意义:是happens-before的落地实现。

        内存屏障的影响:

        1. 内存可见性:内存屏障之前的写操作对其他线程可见。
        2. 防止指令重排
        3. 缓存一致性:内存之前的写操作会立即刷新到主存中。避免数据不一致.
      • loadstore:确保在读取一个volatile变量之后,后面所有的写操作还没有开始

      • loadload : 确保在读取一个 volatile变量前,前面所有的读操作都已经完成

      • storestore:确保写入一个volatile变量前,前面所有写操作都已经开始

      • storeload:确保写入一个volatile变量后,后面所有的读操作还没开始

      • 当一个线程执行一个读取volatile变量的操作的时候,java会插入loadload和loadstore屏障。loadload 防止读操作 和前面的任何读操作被重排序。loadStore屏障会防止读取操作和后续的写入操作被重排序。

      • 当一个线程对volatile变量有一个写操作的时候,会插入storestore,storeLoad屏障。作用如下。

      • 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

应用:

经典实现懒汉式双检锁

public class Singleton4 implements Serializable {
    private Singleton4() {
        System.out.println("private Singleton4()");
    }

    private static volatile Singleton4 INSTANCE = null; // 可见性,有序性

    public static Singleton4 getInstance() {
        if (INSTANCE == null) {
            synchronized (Singleton4.class) {
                if (INSTANCE == null) {
                    INSTANCE = new Singleton4();
                }
            }
        }
        return INSTANCE;
    }

    public static void otherMethod() {
        System.out.println("otherMethod()");
    }
}

如果只有第一层
INSTANCE == null判断,那么可能会出现下面的问题:A线程占有资源B进程挂起,但是if判断B线程是可以进去的,A释放资源之后B又创建新对象了,为了避免这种问题,我们在同步代码快里面再加一个判断。双检机制解决了重复创建对象的问题。
但是这并不是完全安全的,在整个对象创建的过程,其实是分三步的,创建对象,调用构造方法,给静态变量赋值。创建对象毋庸置疑是第一步执行的,但是后面两步,它实际上先后执行的顺序是不确定的。JVM可能 先putstatic,在执行构造方法init对象。如果先putstatic,那么当前单例对象就不是空了,其他线程在这个时候如果来获取单例对象,就可能获取到未被构造方法初始化的对象。为了避免这种问题,在单例对象上加volatile关键字。保证单例对象的有序性。加关键字之后,执行构造方法这一步骤就会加上内存屏障,putstatic只能在构造方法init对象之后才能执行。避免了指令重排序。解决了上述的问题。
注:上文提到的putstatic 和 init方法均是JVM执行的字节码文件的指令

乐观锁,悲观锁

这哥俩实际上是一种思想。乐观和悲观的思想

  • 悲观锁 : 悲观锁总是假设最坏的情况。每次获取资源的时候都会上锁,其他线程想拿到资源的时候都会上锁。也就是说共享的资源只有一个线程能使用。 例如synchronized 和 reentrantLock。
  • 乐观锁:总是假设最好的情况。 只是在提交修改的时候验证对应的资源。 版本号或CAS实现。
    • CAS :CAS 实际上是一种并发算法。用于实现多线程的环境下的一种 无锁操作。通过比较共享变量当前值和期望值是否相等决定是否更新该共享变量的值。 他是一条 原子操作。
      • 操作过程: 线程获取共享变量的当前值,然后检查当前值是否等于期望值。如果相等,则将更新的值写入共享变量的内存位置。如果不相等,就说明共享变量已经被其他线程修改了,CAS操作失败
      • 优点:不使用锁,就能保证某些操作的线程安全。
      • 缺点
        • 如果使用循环等待的话,比如getAndAddInt,这种方法。可能会出现时间开销大的情况
        • 只能保障一个共享变量的原子操作。
        • ABA问题:一个线程从主存中加载A到工作内存中,由于一些问题,产生一些时间延迟,在这个时间内,别的线程从A到B,从B到A,数据被篡改过。但是从初始线程的时间看数据是干净的。这就是ABA问题。
          • ABA问题解决办法:在JAVA中使用AtomicStampedReference类。加版本号。检查变量是否被更改过。compareAndSet方法。检查当前应用是否等于预期的引用。当前 标志是否等于预期的标志。相等说明数据正确。

JAVA中的常见锁

公平锁和非公平锁

  • 公平锁:公平锁指按多个线程按照申请所的顺序来获取锁,类似队列,先进先出。
  • 非公平锁:多个线程获取锁的顺序并不是按照申请锁的顺序。有可能是后申请的线程比先申请的线程更优先获取锁。
  • 对于JAVA中,reentrantLock这种锁是可以通过构造方法来决定是否非公平。 它的底层原理是使用AQS。一种CLS 改进的双向链表为原理实现的同步器,后续详细写。

可重入锁

ReentrantLock 和 synchronized 都是一种可重入锁,对锁的获取次数会进行技术,每次成功获取锁之后计数器加一,解锁计数器减一,只有将计数器为0的时候,其他线程才能获取到锁。也叫递归锁,指的是线程可以再次获取某个对象的锁。不可重入锁就会造成死锁

读写锁

读写锁就是,允许多个线程同时读取共享资源,但是在写操作的会后,只有一个线程能够进行写入。 这样做的好处就是 读多写少的情况下 提高并发性能。允许多个线程能够读到主存中的共享数据,在写操作的时候保证数据的

互斥锁

互斥锁用于对共享资源的访问,保证同一时刻只能有一个线程访问共享变量。从而防止多个线程同时对共享资源进行修改而导致的数据不一致或者其他的并发问题。

自旋锁

自旋锁通过线程在忙等待的方式,不断地检查锁是否可用。不进入阻塞状态。适用于锁竞争时间短暂,也就是不让他一直循环检查是否可用。 减少线程上下文切换带来的开销。

public class SnipLock {
    private AtomicReference<Class<SnipLock>> locked = new AtomicReference<>();

    public void lock() {
        while (!locked.compareAndSet(null, SnipLock.class)) {

        }
    }

    public void unlock() {
        locked.compareAndSet(SnipLock.class, null);
    }

乐观锁悲观锁

前文提到的

synchronized关键字

synchronized 关键字 和 lock 的区别

  • 层次 : 前者是 JVM层面的锁,后者是jdk封装的接口类
  • 使用:synchronized 在使用上直接代码块,或者加在方法上,释放也不需要手动释放。而后者需要用户手动unlock
  • 前者获取锁等待的过程不可中断,后者可以tryLock设置超时时间,中断
  • 前者非公平锁,后者默认非公平,构造方法设置成公平锁可以
  • 前者是随机唤醒,后者可以通过condition唤醒

使用

  • 加在普通方法上,锁的是当前调用方法的这个对象
  • 加在静态方法,锁的是class对象
  • 对于方法快,锁的是synchronized里面的对象

synchronized 加锁的 时候 ,通过 monitor的获取与释放来实现的。 monitorenter monitorexit 两个操作来做的

synchronized 锁升级机制

在jdk 1.6 之后 ,synchronized 关键字被升级优化。有了锁升级机制

  • 临界区 : 临界区是一个被多个线程共享的代码片段或数据区域,这个区域只允许一个线程进行访问,保证线程之间对共享资源的互斥访问。
  • 偏向锁:出现在锁竞争很少的场景。如果当前只有一个线程。锁只被一个线程持有,其他线程不会竞争这个锁,因此可以让持有锁的线程进入偏向模式。标记为偏向锁。当其他线程需要访问该锁是,会检查锁的偏向标记。如果发现已经偏向了其他线程,则尝试撤销偏向锁,并尝试使用轻量级锁或重量级锁。
  • 轻量级锁:轻量级锁是JAVA虚拟机的一种锁优化的机制,我认为他不是一种锁,他实际上是一种锁的优化手段。在线程竞争少的情况下,避免传统的重量级锁的性能开销。使用CAS乐观加锁,在没有竞争的情况下减少上下文切换。
  • 重量级锁就是会设计线程阻塞和唤醒,进入内核态进行所操作。只有获取锁的线程能够获取临界区的资源,其他线程会被阻塞。

假设有一个被synchronized修饰的方法

  1. 无锁状态:初始时没有线程对它进行竞争,处于无锁状态。

  2. 偏向锁:现在有一个线程过来调用这个方法,这个线程会将这个对象标记为偏向锁,偏向锁id是这个线程A的id。后续线程A在访问这个方法的时候直接进入临界区拿资源。如果这时候来一个新线程B,尝试执行方法,因为这个方法是被上锁的,此时是偏向锁,b发现这个对象被A标记成偏向锁了,线程B的线程id与偏向锁上面的线程id不匹配。这是会尝试升级为轻量级锁

  3. 轻量级锁:接着上面的操作。B使用CAS来获取锁,CAS成功了,代表B能够获取轻量级锁进入临界区,此时AB可以交替访问临界区。此时他并不是一种真正的上锁,竞争的是能够进入临界区的资格。

  4. 重量级锁:当更多线程出现来获取锁,肯定会出现大量获取不到的情况,这些线程不会立刻进入内核态去阻塞线程,采用一种自适应自旋机制,给能够获取到锁的线程一个释放的机会。

    1. 锁膨胀 : 如果线程自旋到达一定次数,或者说自旋不成功获取不到这个锁了,JVM检测到了同一把锁上发生了竞争。可能执行锁膨胀操作。轻量级锁升级为重量级锁,也就是互斥锁。锁被替换成一个内核级别的互斥量,此时会发生阻塞现象。

    总结 : 在重量级锁之前,其实都不算真正的加锁,属于线程本身为了不进入内核态停留在用户态而做的一些努力,属于线程层面的操作,比如一些自旋操作,都是为了减少性能开销,到了重量级锁 属于JVM层面和操作系统的操作,去 进行一些阻塞操作,线程进入内核态,锁也成为了monitor,以至于发生上下文切换等线程。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值