JVM学习笔记6——多线程

Posted by Ann on January 2, 2022

Java内存模型

Java内存模型是JVM设计的一套旨在多并发的场景下,也能具有平台无关性的访问存储规则。

主内存 & 工作内存

  • 主内存: 所有变量(静态字段、实例字段、数组对象元素等)都必须存在主内存中。
  • 工作内存:线程独立的空间,保存了可能被该线程用到的变量在主内存的方法拷贝

对应于具体的操作系统,主内存对应硬件内存,JVM会让工作内存优先享有寄存器&告诉缓存

主内存与工作内存的交互

两者内存之间如何交互,JVM定义了八种操作,每种操作都是原子的,保证线程安全。

  • lock(锁定),unlock(解锁):作用于主内存中变量

  • read(读取),write(写入):作用于主内存中变量

  • load(载入),use(使用),assign(赋值,将工作内存值赋给主内存),store(存储):作用于工作内存中变量

上诉八种操作没有强制要求必须连续执行,但对于两两的操作必须是顺序执行,即:

  • read & load; store & write必须成对出现

  • 不允许线程抛弃assign操作,即工作内存值改变必须赋给主内存,也不允许没有assign操作就同步内存。

  • 新变量必须在主内存中”诞生“,工作内存只存拷贝地址

  • 一个变量同一时刻只允许一个线程lock(锁)

volatile关键字

  • volatile关键字保证可见性 & 禁止指令重排,但不保证原子性
    • 可见性的实现:对变量操作完成后插入一个空操作,强制要求工作内存向主内存读。
    • 禁止指令重排的实现:通过插入若干内存屏障指令,防止JVM指令重排优化。
    • 关于原子性的限制:对于变量的操作结果依赖当前值的情况,或需要与其他变量共同参与不变约束的情况下并不安全。

上述可见,用volatile修饰的变量读的性能和普通变量差不多,但写性能会略慢(整体比sychronized还是更快点的)

  • 经典面试点:单例双重锁模式,为什么需要用volatile修饰单例对象?
Class SingleTon {
    /** 单例 */
    private static volatile SingleTon instance;

    public static SingleTon getInstance() {
        if (instance == null) {
            synchronized(SingleTon.class) {
                if (instance == null) {
                    instance = new SingleTon();
                }
            }
        }
        return instance;
    }
}
  • 禁止指令重排: 防止先修改instance值,再创建的情况,保证第一层的instance判断一定可靠。

  • 可见性: 单个线程中对于对象的操作完成后,直接更新主内存,保证锁住对象后,从主内存中拿到的对象状态一定可靠。

long、double特殊规则

对于64位的long、double,JVM允许划分为两个32位的变量分别进行操作,因此,不用volatile修饰的long、double,是不安全的。(可能会得到一个既非原值也非新值的”中间值“)

Java与线程

线程

线程的大部分实现都是native的,无法使用平台无关的手段实现。实现线程主要有三种方式:

  • 使用内核线程实现(由内核完成线程切换,需要内核中的线程支持,代价较高)
  • 使用用户线程实现(在用户空间(非内核中)完成切换,无内核的支持,手动处理逻辑复杂)
  • 使用用户线程+轻量级线程混合实现(较优权衡)

生命周期

thread

🚗🚕🚙

线程调度

线程调度——为线程分配使用权的过程,有两种方式。

协同式线程调度

没有时间限制,当前线程执行完,主动通知下一个待执行的线程。实现简单,易发生阻塞(某个线程死循环)

抢占式线程调度

线程的切换不由线程本身决定,由系统进行分配。分配中,线程有优先级概念。但这个优先级并不是很靠谱

线程安全性级别

  • 不可变(final) 如String对象、枚举类型、Number部分子类、BigInteger等大数据类型等。
  • 绝对线程安全(任何时候不对代码做任何操作也能保证线程安全)
  • 相对线程安全 (对象单独操作是安全的,无需做任何操作,需要考虑并发操作,如插入删除等) 如:Vector、HashTable等。
  • 线程兼容 (线程不安全)
  • 线程对立 (无论是否做同步措施,都无法在多线程中使用)如:Thread的suspend和resume方法,在不同的线程调用会造成死锁。(这种设计也不该出现!)

线程安全实现方法

线程互斥&线程同步

  • 线程同步指:多线程场景下,共享数据同一时刻只应被一个线程使用。
  • 线程互斥——实现线程同步的一种方法,如临界区、互斥量、信号量都是互斥的实现方式。

sychronized VS reentrantLock

  • 一个是Java关键字,由JVM保证实现,变量存在方法头中
  • 一个是Java的工具类包,reentrantLock在sychronized的基础上支持公平锁、等待可中断、可绑定多个条件

需要了解几种不同的锁,以及阻塞同步&非阻塞同步(CAS)

性质分类

公平锁VS非公平锁
  • 公平锁是指多个线程按照申请锁的顺序来获取锁。
  • 非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获得锁。

有可能会造成优先级反转或者饥饿现象。

对于Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁,非公平锁的优点在于吞吐量比公平锁大。

对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。

乐观锁/悲观锁
  • 悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。

  • 乐观锁则认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法(compare and swap),典型的例子就是原子类,通过CAS自旋实现原子操作的更新,缺点是1.CPU开销大 2.不能保证原子性 3.ABA的场景下会造成脏读。

悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。悲观锁在Java中的使用,就是利用各种锁。

独享锁/共享锁(互斥锁/读写锁)
  • 独享锁是指该锁一次只能被一个线程所持有。
  • 共享锁是指该锁可被多个线程所持有。

对于Java ReentrantLock而言,其是独享锁。但是对于Lock的另一个实现类ReentrantReadWriteLock,其读锁是共享锁,其写锁是独享锁。

读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。

独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享

设计方案分类

自旋锁/自适应锁

如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。 优点是:自旋等待本身可以避免线程切换的开销。缺点是:自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的 自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

  • 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长时间,比如100个循环。
  • 如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源
偏向锁/轻量级锁/重量级锁

偏向锁 > 轻量级锁 > 重量级锁

这三种锁是指锁的状态。并且是针对Synchronized。

这三种锁的状态是通过对象监视器在对象头中的字段来表明的。

  • 偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
  • 轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
  • 重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
分段锁

分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。我们以ConcurrentHashMap来说一下分段锁的含义以及设计思想,ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。 当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。

但是,在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。

synchronize VS lock

  • Synchronized,它是一个:非公平,悲观,独享,互斥,可重入的重量级锁
  • ReentrantLock,它是一个:默认非公平但可实现公平的,悲观,独享,互斥,可重入,重量级锁。
  • ReentrantReadWriteLocK,它是一个,默认非公平但可实现公平的,悲观,写独享,读共享,读写,可重入,重量级锁。

Synchronize VS volatile

1.volatile是线程同步的轻量级实现,所以volatile的性能要比synchronize好;volatile只能用于修饰变量,synchronize可以用于修饰方法、代码块。随着jdk技术的发展,synchronize在执行效率上会得到较大提升,所以synchronize在项目过程中还是较为常见的;

2.多线程访问volatile不会发生阻塞;而synchronize会发生阻塞;

3.volatile能保证变量在私有内存和主内存间的同步,但不能保证变量的原子性;synchronize可以保证变量原子性;

4.volatile是变量在多线程之间的可见性;synchronize是多线程之间访问资源的同步性; 对于volatile修饰的变量,可以解决变量读时可见性问题,无法保证原子性。对于多线程访问同一个实例变量还是需要加锁同步。

Synchronize 锁对象VS锁方法

synchronize底层原理: https://www.cnblogs.com/aspirant/p/11470858.html