java 并发

参考链接:https://javaguide.cn/java/concurrent/java-concurrent-questions-02.html

参考书籍:深入理解java虚拟机第三版

Java 对象头

  1. Mark Word
  2. 指向类的指针
  3. 数组长度(只有数组对象才有)

32位大小均为4个字节

img

在这里插入图片描述

java对象采取8字节对齐

高端面试必备:一个Java对象占用多大内存 - rickiyang - 博客园 (cnblogs.com)

synchronized

主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

历程

在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

使用方法

synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁;

synchronized 关键字加到实例方法上是给对象实例上锁;

尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。

synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

synchronized 修饰的方法ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。

锁分类

级别从低到高依次是:

  1. 无锁状态
  2. 偏向锁状态
  3. 轻量级锁状态
  4. 重量级锁状态

锁可以升级,但不能降级。即:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁是单向的。

锁的升级

参考 深入理解java虚拟机琐优化部分

自旋锁与自适应自旋

在JDK 6中对自旋锁的优化,引入了自适应的自旋。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。

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

锁消除

逃逸分析(Escape Analysis)

简单来讲就是,Java Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存的一项技术。

当编译器确定一个对象没有发生逃逸时,它便会移除该对象的同步锁。

函数里多个同步方法,且所有引用没法逃逸出函数的使用范围,就可以去除同步方法。

额外

标量替换

首先要明白标量和聚合量,基础类型和对象的引用可以理解为标量,它们不能被进一步分解。而能被进一步分解的量就是聚合量,比如:对象。

对象是聚合量,它又可以被进一步分解成标量,将其成员变量分解为分散的变量,这就叫做标量替换。

这样,如果一个对象没有发生逃逸,那压根就不用创建它,只会在栈或者寄存器上创建它用到的成员标量,节省了内存空间,也提升了应用程序性能。

标量替换在 JDK1.8 中也是默认开启的,但是同样也要建立在已开启逃逸分析的基础之上。

栈内存分配

栈内存分配很好理解,在上文中提过,就是将原本分配在堆内存上的对象转而分配在栈内存上,这样就可以减少堆内存的占用,从而减少 GC 的频次。

锁粗化

如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部

轻量级锁

加锁过程,会先在栈帧开辟一个锁记录的空间,记录原来markword中的值,

image-20230510171955856

虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针

image-20230510172336003

解锁过程也同样是通过CAS操作来进行的,如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。

偏向锁

Java6及以上版本对synchronized的优化 - 蜗牛大师 - 博客园 (cnblogs.com)

当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作等)。

一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”),撤销后标志位恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态,后续的同步操作就按照上面介绍的轻量级锁那样去
执行。偏向锁、轻量级锁的状态转化及对象Mark Word的关系如图所示。

image-20230511105334431

当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求[1]时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。(这里说的计算请求应来自于对Object::hashCode()或者System::identityHashCode(Object)方法的
调用,如果重写了对象的hashCode()方法,计算哈希码时并不会产生这里所说的请求)

Java内存模型

image-20230511200439548

这里所讲的主内存、工作内存与第2章所讲的Java内存区域中的Java堆、栈、方法区等并不是同一个层次的对内存的划分,这两者基本上是没有任何关系的。如果两者一定要勉强对应起来,那么从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分[4],而工作内存则对应于虚拟机栈中的部分区域。

Unsafe

Unsafe 是位于 sun.misc 包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升 Java 运行效率、增强 Java 语言底层资源操作能力方面起到了很大的作用

主要功能分类

  1. 内存操作

  2. 内存屏障

    1
    2
    3
    4
    5
    6
    //内存屏障,禁止load操作重排序。屏障前的load操作不能被重排序到屏障后,屏障后的load操作不能被重排序到屏障前
    public native void loadFence();
    //内存屏障,禁止store操作重排序。屏障前的store操作不能被重排序到屏障后,屏障后的store操作不能被重排序到屏障前
    public native void storeFence();
    //内存屏障,禁止load、store操作重排序
    public native void fullFence();
  3. 对象操作

  4. 数据操作

  5. CAS 操作

  6. 线程调度

  7. Class 操作

  8. 系统信息


著作权归所有 原文链接:https://javaguide.cn/java/basis/unsafe.html

为啥要使用堆外内存。通常因为:

  • 在进程间可以共享,减少虚拟机间的复制
  • 对垃圾回收停顿的改善:如果应用某些长期存活并大量存在的对象,经常会出发YGC或者FullGC,可以考虑把这些对象放到堆外。过大的堆会影响Java应用的性能。如果使用堆外内存的话,堆外内存是直接受操作系统管理( 而不是虚拟机 )。这样做的结果就是能保持一个较小的堆内内存,以减少垃圾收集对应用的影响。
  • 在某些场景下可以提升程序I/O操纵的性能。少去了将数据从堆内内存拷贝到堆外内存的步骤。

进程和线程

进程

进程状态

  1. 新建
  2. 就绪
  3. 运行
  4. 阻塞
  5. exit

如果有挂起的话,还会有阻塞挂起->就绪挂起(挂起状态主要是因为阻塞状态是会占用内存,操作系统会把阻塞状态的物理内存空间换到硬盘当中)

PCB

  1. 进程标识符
  2. 用户标识
  3. 进程状态和优先级
  4. 内存空间和打开的文件以及IO信息
  5. cpu信息 各个寄存器的值,用于进程切换的时候保护现场

Java 线程状态变迁图

  • 线程初始状态:NEW
  • 线程运行状态:RUNNABLE
  • 线程阻塞状态:BLOCKED
  • 线程等待状态:WAITING
  • 超时等待状态:TIMED_WAITING
  • 线程终止状态:TERMINATED

Java 线程状态变迁图

线程与进程的比较

线程与进程的比较如下:

  • 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位;
  • 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;
  • 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;
  • 线程能减少并发执行的时间和空间开销;

对于,线程相比进程能减少开销,体现在:

  • 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;
  • 线程的终止时间比进程快,因为线程释放的资源相比进程少很多;
  • 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;
  • 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了;

Thread

thread.sleep(0)

让出cpu

volatile 关键字

可见性

每次都要强制从主存中重新读取,如同直接在主内存中读写访问

JMM(Java 内存模型)强制在主存中进行读取

指令重排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Singleton {

private volatile static Singleton uniqueInstance;

private Singleton() {
}

public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}

uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

具体实现

·lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。

·unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

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

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

·use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。

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

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

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

后面最新的JSR-133文档中,已经放弃了采用这8种操作去定义Java内存模型的访问协议,缩减为4种read、write、lock和unlock

如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read和load操作,如果要把变量从工作内存同步回主内存,就要按顺序执行store和write操作。注意,Java内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行,中间可以插入其他操作。

volatile 作用是在 use之前必须是load、load之前必须read 保证了每次使用都是最新值,assign和store也一样,保证了每次赋值都会写回到主内存

内存屏障

底层字节码是用了 lock addrl

1
2
3
public native void loadFence();
public native void storeFence();
public native void fullFence();

happens-before

解锁 加锁

volatile

一个线程中的代码

传递原则

线程启动先于之后的所有

ReentrantLock

ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。不过,ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。

ReentranLock Synchonrized
底层 依赖javaApi Monitor模式
灵活性 超时,尝试获取锁,公平非公平 非公平锁
释放锁 显示调用unlock() 自动退出
条件队列 多个条件队列 一个条件队列
是否可重入 可重入 可重入

AQS

img

CLH队列

img

state变量表示临界资源获取锁的状态

公平锁和非公平锁

公平锁和非公平锁代码中的最大区别只是公平锁需要判断自己是否还有前驱节点正在排队,如果有就不能获得锁

主要在与是否需要判断当前独占的线程,不判断那就是共享锁

图片

ThreadLocal

ThreadLocal 有什么用?

每一个线程都有自己的专属本地变量,避免了线程安全问题。

ThreadLocal 原理

ThreadLocal 可以理解为只是ThreadLocalMap的封装,传递了变量值。其实可以看成一个map,ThreadLocalMap的 key 就是 ThreadLocal对象,value 就是 ThreadLocal 对象调用set方法设置的值。

ThreadLocal 内存泄露问题是怎么导致的?

ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。

这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()get()remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后最好手动调用remove()方法

线程池

线程池参数

coreSize maxSize(阻塞队列满才会用到) 阻塞队列

keepAliveTime(大于coreSize的时候,超过这个时间的线程会被关闭)、拒绝策略、线程工厂

拒绝策略

  1. 抛出异常
  2. 在execute线程中执行
  3. 不做处理
  4. 丢弃最早未处理的任务

为什么不使用内置线程

主要还是队列长度没限制

**FixedThreadPoolSingleThreadExecutor**:使用的是无界的 LinkedBlockingQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。

**CachedThreadPool**:使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。

ScheduledThreadPoolSingleThreadScheduledExecutor : 使用的无界的延迟阻塞队列DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。

阻塞队列

LinkedBlockingQueue(无界队列):由于队列永远不会被放满,因此FixedThreadPool最多只能创建核心线程数的线程。

SynchronousQueue(同步队列): 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务

DelayedWorkQueue(延迟阻塞队列):DelayedWorkQueue 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 Integer.MAX_VALUE,所以最多只能创建核心线程数的线程。

图片

作者

leon Yan

发布于

2023-04-18

更新于

2023-09-08

许可协议


评论