Java进阶之并发编程(一)synchronized关键字
并发编程基础篇
一、基本概念
- QPS(Queries Per Second):每秒查询率,指的是一个系统或服务在一秒内能够处理的查询次数。例如,如果一个网站每秒钟能够处理100个请求,那么它的QPS为100。
- TPS(Transactions Per Second):每秒事务数,指的是一个系统或服务在一秒内能够完成的事务数。一个事务通常指的是一组相关的操作,例如在数据库中执行一次读写操作。TPS是一个系统的性能指标之一,可以用来衡量系统的稳定性和响应能力。
- CPU(Central Processing Unit,中央处理器):CPU是计算机的核心部件,它负责执行计算机指令。在并发编程中,CPU的主要限制是它的计算能力,它能够处理的任务数量受到CPU核心数、频率和指令集等因素的影响。为了充分利用CPU的性能,可以采用多线程、异步编程等方式来并发执行任务。
- 内存(Memory):内存是计算机中用于存储数据和程序的临时存储器,它的容量和速度对并发编程也有很大的影响。如果程序需要频繁地读写内存,那么内存的带宽和延迟就会成为瓶颈。为了减少内存的使用,可以采用一些优化策略,如对象池、缓存等方式。
- 磁盘(Disk):磁盘是计算机用于存储数据的永久性存储器,它的容量和速度也会对并发编程造成影响。磁盘的读写速度相对于内存来说较慢,因此频繁地进行磁盘读写会成为程序的瓶颈。为了优化磁盘的使用,可以采用一些策略,如异步I/O、缓存、压缩等方式。
- 网卡(Network Interface Card):网卡是计算机用于连接网络的接口,它的带宽和延迟对网络通信的性能有很大的影响。在并发编程中,网络通信是一种常见的场景,因此优化网络通信的性能非常重要。可以采用一些策略,如连接池、异步I/O、压缩等方式来优化网络通信的性能。
- I/O(Input/Output)是计算机系统中,用于实现计算机与外部设备交互的操作。在并发编程中,I/O操作的限制主要包括磁盘、网络和数据库等方面的性能瓶颈。磁盘的读写速度相对于内存来说较慢,因此频繁进行磁盘读写会成为程序的瓶颈。网络通信的带宽和延迟也会影响I/O操作的性能,需要采用一些策略来优化,如使用缓存、压缩、连接池等方式。在使用数据库时,频繁进行数据库的读写操作也会成为系统的瓶颈,需要采用一些策略来优化,如使用数据库连接池、使用索引等。
此外,在并发编程之中,进程和线程的概念是必须熟练掌握的
进程是指一个程序的运行实例,它拥有自己的地址空间、文件描述符、环境变量、堆栈等资源,是操作系统进行资源分配和调度的基本单位。
线程是进程内的一个执行单元,一个进程可以包含多个线程,线程共享进程的地址空间和资源,但是每个线程都有自己的堆栈和寄存器,可以独立执行。线程的并发性相对于进程更高,可以更好地利用计算机的多核处理器,提高程序的性能。
区别:
- 进程是操作系统资源分配的基本单位,线程是进程的执行单元,进程可以包含多个线程。
- 进程拥有自己的地址空间、文件描述符、环境变量等资源,而线程共享进程的地址空间和资源。
- 进程之间相互独立,线程之间共享进程的资源。
- 进程切换需要保存当前进程的状态和上下文,开销较大,线程切换开销较小,切换速度更快。
- 进程通信需要使用进程间通信机制,如管道、消息队列、共享内存等,线程之间可以直接共享数据。
总的来说,进程和线程都是用于实现多任务的机制,但是它们的性质和使用方式不同,应该根据具体情况选择适合的机制来实现多任务。
二、线程相关
1.线程生命周期
线程的生命周期可以被分为五个不同的阶段:创建、就绪、运行、阻塞和终止。
- 创建阶段:线程被创建,分配了必要的系统资源,但还未开始运行。
- 就绪阶段:线程已经准备好运行,但还未被CPU调度执行。
- 运行阶段:CPU选择了一个就绪状态的线程,将其放入运行状态,并执行线程的run()方法。
- 阻塞阶段:线程进入了一个阻塞状态,例如等待某个事件发生或者等待输入输出操作完成。
- 终止阶段:线程执行完了run()方法或者抛出了一个未被捕获的异常,或者被强制中断,线程将进入终止状态。
需要注意的是,在java代码当中,线程具有六个状态,和线程生命周期并不完全匹配
NEW 新建,RUNNABLE 就绪,BLOCKED 阻塞,WAITING 等待,TIMED_WAITING 时间等待,TERMINATED 终止
不同状态之间的转换可以由以下事件触发:
- 创建状态转换为就绪状态:当线程被创建并分配了系统资源之后,它就进入了就绪状态。这个状态转换是自动发生的。
- 就绪状态转换为运行状态:当线程被操作系统的调度器选择并分配了CPU资源后,线程进入运行状态。
- 运行状态转换为阻塞状态:当线程在执行过程中遇到了阻塞事件(如等待输入/输出操作完成、等待某个锁等待等),线程会被挂起,进入阻塞状态。
- 阻塞状态转换为就绪状态:当线程被阻塞的原因消除后(如输入/输出操作完成或某个锁被释放),线程就会转换为就绪状态,等待操作系统调度器重新分配CPU资源。
- 运行状态转换为终止状态:当线程执行完了run()方法或抛出了未被捕获的异常或被强制中断时,线程就会进入终止状态。
2.实现线程的方法
1.继承Thread类
2.实现Runnable接口
3.实现Callable接口
4.使用线程池ThreadPoolExecutor
3.线程中断
线程可以使用stop()来中断,但是这种方法会不论当前线程状态直接将线程中断掉,容易造成未知影响,已经逐渐废弃
更普遍的,可以使用interrupt()方法来中断线程,相当于给正在运行的线程发送了一个信号,通知线程可以中断了,可以用isInterrupted()方法来判断是否启用了线程中断,如果处于中断命令状态,用户可以执行相应操作
需要注意的是,如果线程内run使用类似如下语句
1 | while(!Thread.currentThread.isInterrupted()){ |
这种情况下在线程外调用interrupt()方法是可以将线程正常中断掉的,而这种状态下的中断其实和使用volatile声明一个共享变量来做线程通信的原理是相似的
interrupt()方法并不会直接中断一个正在运行状态的线程,而是将中断标志位设置为true,在线程内可以通过对中断标志位的判断,来决定何时中断当前线程,此外,interrupt()方法对不同状态的线程影响不同:
- 如果线程处于运行状态,那么中断标志位将被设置为
true,但线程并不会立即停止执行,而是可以通过对中断标志位的检查来决定是否停止执行。 - 如果线程处于阻塞状态,那么调用
interrupt()方法会立即抛出InterruptedException异常,这样可以提前结束线程的阻塞状态并抛出异常,从而使线程退出阻塞状态继续执行。 - 如果线程处于等待状态,例如调用了
Object.wait()方法或Thread.join()方法等待其他线程的通知或执行完成,那么调用interrupt()方法也会立即抛出InterruptedException异常,从而使线程退出等待状态继续执行。 - 如果线程处于新建状态或者已经终止,那么调用
interrupt()方法不会有任何效果,中断标志位会被设置为true,但线程并不会中断执行。
需要注意的是,抛出
InterruptedException异常之后,运行线程会将中断标志位重新置为false,即退出了中断状态,为什么这样设计呢?这种设计主要是为了线程安全性考虑,当某个线程中断标志位设置为
true之后,如果其他线程来获取这个线程状态,会获得true结果,但是这个线程其实并不是在中断标志位被设置为true后就立即进入中断状态了,它可能还会有一些自己的处理,因此需要将中断标志位重置。同时,这也是将中断交给用户来处理的一个操作,使得用户可以更加灵活的处理中断情况,而如果用户想真的中断这个线程时,可以在run内部调用Thread.currentThread().interrupt()来实现线程真正的中断。
如下所示,就是一个调用interrupt()后能抛出InterruptedException异常的run内写法
1 | while(!Thread.currentThread.isInterrupted()){ |
4.cpu飙升问题排查
当CPU飙升时,可能是因为某个进程或线程在消耗大量的CPU资源,导致CPU使用率过高。要解决这个问题,可以通过以下步骤进行排查:
- 查看系统负载:可以通过命令
top或htop来查看系统的负载情况,包括CPU使用率、内存使用率、进程数量等信息。如果CPU使用率很高,可以查看哪些进程或线程占用了大量的CPU资源。 - 查看进程信息:可以通过命令
ps或pidstat来查看进程的详细信息,包括进程ID、CPU使用率、内存使用率、线程数量等信息。可以根据进程ID定位到具体的进程,并查看其线程的详细信息。(top命令看进程信息,找到占用cpu过高的进程) - 分析线程堆栈信息:可以通过工具如jstack、jvisualvm等来获取线程堆栈信息,从而了解线程的执行情况。通过分析线程堆栈信息,可以确定哪些线程占用了大量的CPU资源,并查找问题所在。(jstack查看线程信息,找到占用cpu过高的线程)
- 分析代码:根据线程堆栈信息,可以定位到具体的代码位置,进一步分析代码的执行情况。如果是某个代码块或方法导致CPU使用率过高,可以考虑对其进行优化或改进。(根据dump文件,使用jmap分析是否有死锁,内存泄漏等问题,定位到对应代码块)
- 调整系统参数:如果以上步骤都无法解决问题,可以考虑调整系统参数,例如增加CPU数量、增加内存容量、调整线程池大小等,以提高系统性能。
需要注意的是,CPU飙升问题可能有多种原因,可能是代码问题,也可能是系统配置问题。在排查问题时,需要综合考虑各种因素,并根据具体情况采取相应的措施。
注意,死锁不一定会导致CPU飙升
首先分析会导致CPU飙升的死锁,这种情况就是多个进程存在死锁,然后不断重试去尝试获取资源,从而导致占用大量CPU资源,导致CPU使用率飙升
那么不会使CPU飙升的死锁是什么样的呢?考虑以下场景
- 进程A获取了锁1,正在等待锁2
- 进程B获取了锁2,正在等待锁1
在这种情况下,A和B进程都进入了阻塞状态,这种状态是不会对CPU资源进行占用的,但是这种死锁不释放也会对资源进行占用,需要及时发现并处理。
三、并发编程之同步锁
1.synchronized关键字
synchronized关键字只能作用于代码块或者方法,并不能直接作用于变量,但是可以声明一个对象放在sychronized锁住代码块的小括号里,作为共享对象进行并发控制。
1 | class MyClass { |
此时引出了另一个问题,sychronized关键字都可以怎么使用?不同方法锁住的都是什么?
- 锁在普通方法上:这种情况下,sychronized锁住的就是当前实例对象本身,也就是this
1 | public synchronized void increment() { |
- 锁在静态方法上:这种情况下,锁对象通常是类对象,也就是说,锁住的是当前类对象的锁
1 | public static synchronized void increment() { |
- 锁在代码块上:这种情况下,锁对象可以是任何的Java对象,通常是使用一个专门声明的私有final对象,防止其他对象意外使用该对象导致竞争发生
1 | private final Object lock = new Object(); |
此外,锁的也可以是ClassName.class形式的对象,表示对这个类加了锁
1 | synchronized (ClassName.class) { |
什么叫对类进行加锁呢?
在JVM当中,每个类在其中都有一个对应的Class对象,这个Class对象是全局唯一的,因此当使用synchronized关键字对类进行加锁时,类所有实例对象的静态变量和静态方法也都是被锁住的,但是对于这个类的实例来说,非静态方法与变量是可以被其他线程正常访问的;所以类锁其实只会锁住一个类的静态方法与静态变量;
类锁控制的资源范围更大,锁的粒度也更大,可以保证多线程条件下类的静态资源的并发安全性,经典的用例便是单例模式的双重校验锁中对单例类的锁
1 | public class Singleton { |
这里使用了一个 volatile 关键字来保证在多线程环境下,instance 变量的可见性和有序性,为什么要使用它呢?
如果没有使用 volatile 关键字,可能会出现以下问题:
- 线程 A 进入双重校验锁,执行第一个步骤,此时实例还未被创建,然后线程 B 也进入双重校验锁。
- 线程 B 获取到锁,执行第一个步骤,此时实例还未被创建,然后线程 A 获取到锁,执行第二个步骤,创建实例。
- 线程 B 继续执行第二个步骤,创建另一个实例,并返回。
因为指令重排序的原因,线程 B 可能会在实例还未被创建的时候获取到锁,并返回一个未完成初始化的实例。如果其他线程使用这个实例,可能会导致程序出错。
双重校验锁实现的单例并不完美,具体问题与优化可以参考Java进阶之设计模式中的单例模式介绍
2.抢占锁的本质是什么
抢占锁是指多个线程竞争同一把锁时,其中一个线程成功获得了锁并持有锁的情况下,其他线程将被阻塞,无法进入临界区进行操作,只有等到锁被释放后才能继续竞争锁并进入临界区。
抢占锁的本质是通过锁来保证多个线程对共享资源的访问是互斥的,从而保证了线程安全。当一个线程获取锁时,它会进入临界区,执行相关操作,直到完成后才会释放锁。其他线程在没有获得锁之前,无法进入临界区,从而避免了多个线程同时对共享资源进行修改而导致的数据不一致问题。
3.MarkWord对象头
在Java对象头中,用于表示锁状态的部分称为mark word(标记字段)。mark word通常包含了三种信息:
- 对象的hashCode值
- 对象的分代年龄
- 锁状态
其中,锁状态一般使用2bit位来表示,但是在无锁或者偏向锁时,会用3bit位来表示锁状态
- 0 01表示无锁状态
- 1 01表示是偏向锁状态
- 00表示是轻量级锁状态
- 10表示是重量级锁状态
- 11则是用到的GC标志
mark word的指针并不是一直不变的,在不同的锁状态下指向不同的对象,用于实现不同需求
- 在偏向锁状态下,mark word的指针指向线程ID,用于记录哪个线程拥有该对象。
- 在轻量级锁状态下,mark word的指针指向锁记录的地址,锁记录用于存储锁定对象的线程ID和锁的状态信息。
- 在重量级锁状态下,mark word的指针指向重量级锁的互斥量。
下面分别介绍这几种锁
在jdk6之后才对sychronized进行了细化区分,在5和之前都是用的重量级锁
1.偏向锁
偏向锁的核心思想是,如果一个线程获得了对象的锁,那么在之后的执行中,该线程就可以直接进入临界区,而不用再次获取锁了。偏向锁可以消除大量的同步操作,因为大部分情况下,锁总是由同一个线程多次获得的。如果加入了偏向锁,当这个线程再次进入临界区时,就不用再去尝试获取锁,因为锁已经被偏向该线程了。
注意,并不是说一个线程拿到某一对象置为偏向锁后,这个对象的偏向锁就只能是该线程了,偏向锁存在一个偏向锁撤销的可能,偏向锁撤销可能会升级成轻量级锁,也可能重新变成无锁状态,由以下状态情况触发回到无锁状态
- 使用-XX:BiasedLockingStartupDelay参数设置了偏向锁持有时间,到达时间后偏向锁会被撤销
- 在并发标记过程中,遇到了安全点,会暂停所有线程,并且把对象头中的偏向锁撤销
其他一些情况不再细谈,撤销偏向锁其实是一个比较耗费资源的操作,所以有时候jvm会直接把对象设置为“不可偏向”状态,此时再有线程过来尝试获取锁直接升级为轻量级锁
2.轻量级锁
当一个线程尝试获取一个有偏向锁的对象时,如果这个对象的偏向锁标识符与线程的标识符不同,说明有另一个线程已经竞争这个锁了,那么就会将偏向锁升级为轻量级锁,并使用CAS操作来竞争锁。所以,一般情况下,偏向锁升级为轻量级锁是在第二个线程尝试获取锁时发生的。
轻量级锁适合使用在有多个线程在不同时刻轮流获取锁的情况,如果竞争较为激烈,重量级锁的性能表现会更好
轻量级锁采用CAS机制来尝试获取锁,如果获取锁失败则会自旋尝试再次获取锁,默认会在10次获取失败后升级成重量级锁,但是这个参数可用通过JVM参数控制
3.重量级锁
synchronized 的重量级锁是指当竞争激烈时,锁会升级为重量级锁(也叫监视器锁)。在重量级锁的实现中,会使用操作系统的互斥量来实现锁,因此在争用同步资源时会发生内核态与用户态之间的切换,这种切换的代价比较高,因此重量级锁在性能方面比较差,适合用于保护竞争激烈的共享资源。
那么操作系统的互斥量是指的什么呢?重量级锁又是如何实现的呢?
简单来说,Java虚拟机通过对对象的监视器(monitor)进行操作,来达到对对象的加锁和解锁目的
monitorenter 和 monitorexit 是 Java 虚拟机的两条指令,当 JVM 执行 monitorenter 指令时,会先判断对象的 monitor 是否处于无锁状态,如果是,就将 monitor 设置为当前线程持有,并将锁的计数器加1。如果 monitor 已经被当前线程持有,就直接将计数器加1即可,这样可以重入锁。如果 monitor 已经被其他线程持有,则当前线程就会进入阻塞状态,直到 monitor 变为可用。
当 JVM 执行 monitorexit 指令时,会先判断当前线程是否持有 monitor,如果是,就将 monitor 的计数器减1。如果计数器减为0,说明该线程已经释放了 monitor,可以将 monitor 设置为无锁状态,并唤醒等待在 monitor 上的其他线程。
本质上就是JVM通过对
monitor的状态进行操作,来进行加锁和解锁,那么更进一步了解,monitor又是如何实现的呢?
monitor是一种同步原语,用于实现互斥锁等同步操作。monitor可以看作是一种抽象的概念,实际上是通过操作系统的互斥量(mutex)来实现的。mutex enter和mutex exit就是操作系统中用于实现互斥量的原语。
四、CAS机制
1.简介
CAS(Compare And Swap)是一种基于原子性操作的并发编程技术,主要用于多线程环境下的共享变量的操作,可以保证线程安全。
在CAS操作中,主要包含三个参数:内存地址V、旧的预期值A和新的值B。操作步骤如下:
- 读取V的当前值,记为A。
- 判断A是否等于预期值,如果相等,则执行步骤3,否则不执行。
- 将V的值设置为新值B,如果操作成功,则返回true,否则返回false。
CAS操作通过比较内存地址V中的旧值A与预期值是否相等来判断是否执行更新操作,如果相等,则将新值B写入到内存地址V中,如果不相等,则不执行更新操作。在更新操作期间,如果其他线程修改了内存地址V中的值,那么CAS操作会失败,需要重新尝试执行CAS操作。
在java中使用unsafe.compareAndSwapXXX方法来实现cas操作,传递四个值,两个是用来对内存地址定位,相当于内存地址V
2.ABA问题
CAS操作存在ABA问题,即如果一个值由A变为B,又变回A,那么CAS操作就可能认为这个值没有变化,从而可能产生问题。为了解决ABA问题,Java提供了AtomicStampedReference类和AtomicMarkableReference类。
AtomicStampedReference使用一个整数类型的stamp(可以理解为版本号)来记录对象被修改的次数。在执行CAS操作时,除了比较对象是否相同,还需要比较版本号是否相同,从而确保不会出现ABA问题。
AtomicMarkableReference则是使用一个boolean类型的标记位来记录对象是否被修改过。在执行CAS操作时,除了比较对象是否相同,还需要比较标记位是否相同,从而确保不会出现ABA问题。
3.CAS如何实现并发安全
在单CPU情况下,CAS利用来硬件的原子性指令,将比较与交换同时完成,因此可以保证执行的原子性,从而保证了并发安全
在多CPU的情况下,单个CPU的原子性指令并不能保证夸CPU实现原子性,因此在多CPU的情况下,底层实际上是加了一个LOCK锁,可以称为缓存锁或总线锁,因此本质还是使用锁来保证的原子性
关于总线锁,后面会再展开篇幅进行介绍










