跳至主要內容

多线程与并发

chanchaw大约 9 分钟languagejava

线程上下文

一个进程通常是一个应用程序,一个进程中会有N多个线程,这些线程共享一个虚拟内存(一个进程一个虚拟内存),在一个进程中的不同线程间做上下文切换时,虚拟内存的资源保持不变,切换的是:线程私有数据、寄存器数据。切换的时长大概在几十纳秒到几微妙之间。如果被锁住的代码逻辑执行时长小于该上下文切换的时长那么应该选择使用自旋锁,相反则使用互斥锁(1秒=1000毫秒,1毫秒=1000微妙,1微妙=1000纳秒)。

引用分类

强引用

正常的 new 出来的对象都是强引用,当设置对象为 null 后,由于内存空间缺少了引用,会被 gc 清理掉。

public static void main(String[] args) {
    Object o = new Object();
    o = null;
    System.gc();// 没有引用的内存空间会被gc清理掉
    System.out.println(o);
}

软引用

软引用的对象在 java 虚拟机内存不够使用时会自动被 gc 回收,不同于强引用,后者必须设置为 null 后 gc 才能对该内存进行回收。前者不需要设置 null ,虚拟机检测到内存不够使用就会清理掉该内存给强引用对象使用。 本模式常用于缓存环境下

    public static void main(String[] args) {
        // 执行本案例时要设置 vm 最大内存空间为20M
        SoftReference<byte[]> sr = new SoftReference<>(new byte[1024*1024*10]);
        System.gc();// 软引用在内存空间足够时不会被 gc
        System.out.println(sr.get());

        // 再次申请超过剩余空间(此时剩余可用内存空间为小于10M)
        // 软引用对象会被清理掉,将空间给强引用对象使用
        byte[] b = new byte[1024*1024*12];
        System.out.println(sr.get());// 此时sr是null
    }

弱引用

ThreadLocal 中使用了弱引用,gc 每次清理内存时都会清理掉弱引用的内存区域 - 如果该区域只有弱引用,没有其他强引用了。ThreacLocal 中有变量 ThreadLocalMap 保存键值对,key = ThreadLocal 对象本身,value = ThreadLocal 包裹的实际数据对象。看下面代码

ThreadLocal<String> th = new ThreadLocal();
th.set("chanchaw");

ThreadLocal 中的属性 ThreadLocalMap 的 key = th value = "chanchaw" 其中 key 对于 th 的引用是弱引用,当 th = null 之后又有 gc 进行回收,则会回收掉 key 的内存空间。 但是 value 的内存空间不会被回收,因为 value 是强引用,所以使用 ThreacLocal 时要注意使用 th.remove() 清理内存,否则容易造成内存泄露。

弱引用
弱引用

虚引用

上面3个种引用都是针对虚拟机可管理到的堆内存,虚引用指向的是虚拟机管理内存区域之外的内存区域,例如网卡的缓存数据。为达到零拷贝的效果声明变量通过虚引用关联到堆外内存,gc 回收内存空间时检测到该类型的引用在清理堆内存的变量占用的内存的同时还要清理其关联到堆外内存。

ThreadLocal

ThreadLocal 中有名称为 ThreadLocalMap 的属性,ThreadLocal 通过 set 方法设置 value 时其实是向属性 ThreadLocalMap 中添加 Entry。其中 key = ThreadLocal 实例对象(这里是弱引用),value = set 方法设置的 value。 由于 key 采用了弱引用,当 ThreadLocal 对象 tl 被设置为 null 后,gc 会回收 tl 对象的内存空间。但是 value 采用的是强引用,仍然不会被回收,此处在多次操作后就产生了内存泄露。所以使用 ThreadLocal 记得使用 remove 方法清理 map 对象。

锁与对象结构

概述

本文使用 oracle 的 hotsopt 的 jvm 实现作为分析对象,其他诸如openJDK 不考虑。

锁分类

互斥锁与自旋锁

这两种锁是最底层的锁,有一些高级锁是基于他们实现的。互斥锁和自旋锁的不同体现在加锁失败后的处理方式

互斥锁

互斥锁加锁失败后线程会释放 cpu 给其他线程,此时本线程的后续代码被阻塞,该阻塞是由操作系统内核将线程设置为 “睡眠” 状态来实现,当线程被唤醒后才会再次尝试加锁,这个过程中线程从用户态切换为内核态,内核负责切换线程,这个过程会出现性能开销,具体的成本是:两次线程上下文切换

  • 当线程加锁失败时,内核会把线程从 “运行” 状态设置为 “睡眠” 状态,然后把 cpu 切换给其他线程运行
  • 当锁被释放后,之前 “睡眠” 的线程会变为 “就绪” 状态,然后内核在合适的时间把 cpu 切换给该线程运行

自旋锁

线程加锁失败则进入 “忙等待” 此时该线程不会放弃 CPU(这也是不会主动产生上下文切换的原因),在多核心 CPU 中不会主动产生上下文切换,如果是单核心 CPU 则需要【抢占式调度器】- 通过时钟中断一个线程执行其他线程。另外需要注意的是如果被锁住的代码执行时间过长则该自旋线程占用 CPU 时间也会很长,所以自旋锁一般用于 “被锁住的代码逻辑” 执行时间短的情况下。可参照 线程上下文

CAS

CAS = compare nad swap 或者等于 compare and exchange,意思是比较和交换。该方法在不上锁的情况下保证多线程环境下线程安全。执行的步骤

  1. 首先从源变量 source 读取数据到线程A的 coped 中,然后进行运算。
  2. 线程A运算完毕后准备将 coped 写入 source,要先比较当前 source 的值是否等于运算前的值,如果相等则表示没有其他线程操作过,则将 coped 覆盖到 source
  3. 如果此时 source 中的值不同,则重新回到步骤1再运算再回写。
  4. 这里还有 ABA 问题 - 即在线程101在运算的过程中,有线程102将 source 的值从A修改为B又修改为A(假设线程101最初读取到的是A)。此种情况可以为 source 设置版本号来区分,每个线程在回写到 source 时都将版本号累加1,依次解决 ABA 问题。
锁与对象结构06
锁与对象结构06

CPU 本身提供 CAS 函数,即在汇编中存在 CAS 指令,该指令是原子的(CAS不需要加锁解锁,指示做原值比较并交换,不同于自旋锁,所以不会主动产生上下文切换)

读写锁

由 “读锁” 和 “写锁” 两部分构成,如果只是读取共享资源则用 “读锁” 加锁,如果修改共享资源则 “写锁” 加锁,所以它适用于能明确区分读操作和写操作的场景

  • 如果 “写锁” 没有被持有,则多个线程可同时持有 “读锁” 同时读取共享资源,即 “读锁” 是共享锁
  • 相反的,有线程持有了 “写锁” 则会阻塞所有读写操作,即写锁是独占锁(互斥锁和自旋锁都是独占锁)

由上面的特点可知 “读写锁” 适合于读多写少的场景。该锁还分为 “读优先” 和 “写优先”:

  • 读优先:线程A先持有 “读锁”,后来者B线程在获取 “写锁” 时会被阻塞,但是后续的读线程 C 仍然可以获取 “读锁”,即尽可能多的可读,当所有 “读锁” 都被释放后,写线程可获取 “写锁”,此后阻塞所有读写操作
  • 写优先:线程A先持有 “读锁”,此时后来者B线程在获取 “写锁” 时会被阻塞,同时再后来的读线程 C 也无法获取 “读锁”,当线程 A 释放 “读锁” 后,线程B可成功获取 “写锁”

读优先会有写线程饥饿的情况,如果有太多的读线程并且后续一直有新的读线程出现,那么写线程会一直被阻塞出现 “饥饿” 现象 相反的,写优先也会有读线程饥饿的情况 - 一直出现新的写线程

公平读写锁

公平读写锁比较简单的一种方式是:用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁即可,这样读线程仍然可以并发,也不会出现「饥饿」的现象。

悲观锁

悲观锁(Pessimistic Lock)足够严谨,总是假设最坏的情况,避免出现问题,每次读写之前都会上锁,即阻塞其他线程的读+写。synchronizedReentrantLock 等独占锁就是悲观锁,这两个锁的使用如下

public void performSynchronisedTask() {
    synchronized (this) {
        // 需要同步的操作
    }
}

private Lock lock = new ReentrantLock();
lock.lock();
try {
   // 需要同步的操作
} finally {
    lock.unlock();
}

高并发场景下激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致频繁的系统上下文切换,增加系统性能开销。并且悲观锁还可能造成死锁问题,影响代码正常运行。

乐观锁

乐观锁(Optimistic Lock)

32、64对象头

下面是32位系统

锁与对象结构01
锁与对象结构01

下面是64位系统的 markword 结构

锁与对象结构02
锁与对象结构02

32位系统时 hashCode 是25 bit,64位系统时是31位,HashMap 等类型会用到该 hashCode.在对象调用 hashCode 方法后,markword 中相应位置上才有值。

锁升级

看上图锁升级的路线是:无锁 -> 偏向锁 -> 轻量级锁(CAS自旋锁)-> 重量级锁 偏向锁:当资源没有被其他线程使用时,记录当前线程指针,下次该线程访问该资源时可直接使用 轻量级锁:有任何其他线程来竞争时偏向锁则升级为轻量级锁。在JDK1.6之前自旋10次后升级为重量级锁 1.6版本之后 jvm 自适应该次数,不需要手动调整该自旋次数。线程栈中保存有 Lock Record 锁即指向该数据的指针。 重量级锁:指向互斥量的指针

锁消除

锁与对象结构03
锁与对象结构03

锁粗化

锁与对象结构04
锁与对象结构04

new Object() 内存占用空间

锁与对象结构05
锁与对象结构05