java多线程基础
在操作系统之上,可以同时运行很多个进程,并且每个进程之间相互隔离互不干扰。CPU会通过时间片轮转算法,为每一个进程分配时间片,并在时间片使用结束后切换下一个进程继续执行,通过这种方式来实现宏观上的多个程序同时运行。
由于每个进程都有一个自己的内存空间,进程之间的通信就变得非常麻烦(比如要共享某些数据)而且执行不同进程会产生上下文切换,非常耗时
后来,线程的概念被提出,一个进程可以有多个线程,线程是程序执行中一个单一的顺序控制流程,现在线程才是程序执行流的最小单元,各个线程之间共享程序的内存空间(也就是所在进程的内存空间),上下文切换速度也高于进程。
很多框架都在使用多线程,比如Tomcat服务器,所有用户的请求都是通过不同的线程来进行处理的,这样网站才可以同时响应多个用户的请求。
虽然多线程能够解决很多问题,但是,如何才能正确地使用多线程,如何才能将多线程的资源合理使用,这都是我们需要关心的问题。
在Jdk1.5的时候,新增了java.util.concurrent(JUC)包,其中包括大量用于多线程编程的工具类,目的是为了更好的支持高并发任务,让开发者进行多线程编程时减少竞争条件和死锁的问题!通过使用这些工具类,我们的程序会更加合理地使用多线程。
基本概念
线程:
- 程序执行的最小单元,是进程中的一个独立执行流
- 一个进程可以包含多个线程,这些线程共享进程的资源(如内存、文件等)
多线程(Multithreading):在一个进程中同时运行多个线程,每个线程执行不同的任务
并发(Concurrency):多个任务在同一时间段内交替执行,看起来像是同时执行
并行(Parallelism):多个任务在同一时刻同时执行,通常需要多核CPU的支持
创建线程的方式
- 继承
Thread
类并重写run()
方法
class MyThread extends Thread { |
- 实现
Runnable
接口并将其实例传递给Thread
对象
class MyRunnable implements Runnable { |
- 实现
Callable
接口,可以返回线程的执行结果,并通过Future
获取结果
import java.util.concurrent.Callable; |
常用方法
start()
:启动线程,使其进入就绪状态run()
:线程执行的主体方法sleep(long millis)
:让线程休眠指定的毫秒数join()
:等待线程执行完毕interrupt()
:中断线程isAlive()
:判断线程是否处于活动状态
锁机制
使用synchronized
关键字来实现锁,能够很好地解决线程之间争抢资源的情况。synchronized
底层如何实现的?
使用synchronized
,一定是和某个对象相关联的,比如要对某一段代码加锁,就需要提供一个对象来作为锁本身:
public static void main(String[] args) { |
这段程序变成字节码之后,会使用monitorenter
和monitorexit
分别对应加锁和释放锁,在执行monitorenter
之前需要尝试获取锁,每个对象都有一个monitor
监视器与之对应,而这里正是去获取对象监视器的所有权,一旦monitor
所有权被某个线程持有,那么其他线程将无法获得(管程模型的一种实现)。
在代码执行完成之后,可以观察到字节码中一共有两个monitorexit
,那么为什么这里会有两个呢?
第一个,在释放锁之后,会马上进入到一个goto指令,跳转到方法的返回指令,正常情况下只会执行第一个monitorexit
释放锁,在释放锁之后就接着同步代码块后面的内容继续向下执行了。
第二个,其实是用来处理异常的,如果程序运行发生异常,那么就会执行第二个monitorexit
,并且会继续向下通过athrow
指令抛出异常
在 JVM中,锁的开销很大,为了提高效率,Java 采用了一种逐步升级的锁优化机制,即:
- 无锁状态(Normal Object)
- 偏向锁(Biased Locking)
- 轻量级锁(Lightweight Locking)
- 重量级锁(Heavyweight Locking)
升级原则:
- 当锁竞争激烈时,锁会逐步升级(偏向锁 → 轻量级锁 → 重量级锁)
- 锁不会降级(为了避免频繁升级/降级带来的性能开销)
重量级锁
在JDK6之前,synchronized
一直被称为重量级锁,monitor
依赖于底层操作系统的Lock实现,Java的线程是映射到操作系统的原生线程上,切换成本较高。而在JDK6之后,锁的实现得到了改进。
在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,每个等待锁的线程都会被封装成ObjectWaiter对象
ObjectWaiter首先会进入Entry Set,当线程获取到对象的monitor
后进入The Owner区域并把monitor
中的owner
变量设置为当前线程,同时monitor
中的计数器count ++
,若线程调用wait()
方法,将释放当前持有的monitor
,owner
变量恢复为null
,count --
,同时该线程进入WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor
并复位变量的值,以便其他线程进入获取对象的monitor
.
在JDK1.4.2时,引入了自旋锁(JDK6之后默认开启),它不会将处于等待状态的线程挂起,而是通过循环的方式,不断检测是否能够获取锁,由于单个线程占用锁的时间非常短,所以说循环次数不会太多,可能很快就能够拿到锁并运行,这就是自旋锁。当然,仅仅是在等待时间非常短的情况下,自旋锁的表现会很好,但是如果等待时间太长,由于循环是需要处理器继续运算的,所以这样只会浪费处理器资源,因此自旋锁的等待时间是有限制的,默认情况下为10次,如果失败,那么会进而采用重量级锁机制。
在JDK1.6之后,自旋锁得到了一次优化,自旋的次数限制不再是固定的,而是自适应变化的,比如在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行,那么这次自旋也是有可能成功的,所以会允许自旋更多次。当然,如果某个锁经常都自旋失败,那么有可能会不再采用自旋策略,而是直接使用重量级锁。
特点
- 基于操作系统的互斥锁(Mutex)实现,涉及线程阻塞和内核态切换
- 当一个线程获取锁,其他线程必须等待,会导致线程进入阻塞(Blocking)状态
- 加锁和解锁的开销较高,因为涉及线程切换和操作系统调度
适用场景
- 线程竞争严重(多个线程频繁竞争同一个锁)
- 需要避免CPU自旋浪费资源(比如轻量级锁在竞争过高时,会导致 CPU 资源被自旋锁消耗)
- 适用于IO密集型任务(比如数据库操作、文件读写等场景)
轻量级锁
从JDK1.6开始,为了减少获得锁和释放锁带来的性能消耗,就引入了轻量级锁
轻量级锁的目标是,如果一个线程想获取锁,它不会立刻进入阻塞状态,而是尝试使用 CAS 进行自旋获取锁,减少线程上下文切换的开销。它不像是重量级锁那样,需要向操作系统申请互斥量。
过程
- 锁对象的Mark Word结构
- Java中每个对象的对象头(Header)中都有一个Mark Word,其中存储了锁的信息
- 默认情况下(无锁状态),Mark Word存储的是对象的hashCode、GC信息等
- 当线程尝试获取锁时:
- JVM会尝试使用CAS将对象头的Mark Word设置为线程ID
- 如果CAS成功,则表示线程获取到锁,进入轻量级锁状态
- 如果CAS失败,则表示有其他线程竞争该锁,会进入自旋状态继续尝试
- 自旋锁
- 线程会通过短时间循环尝试获取锁(自旋),如果成功,则进入同步代码块
- 如果自旋失败(即有多个线程竞争锁),轻量级锁会升级为重量级锁
- 释放锁
- 线程退出同步代码块时,JVM会使用CAS操作,将对象的Mark Word还原为无锁状态
- 如果在执行过程中没有发生锁升级(没有其他线程竞争),那么整个加锁解锁过程仅使用CAS,没有线程阻塞或上下文切换
CAS操作
CAS(Compare-And-Swap)是一种无锁编程技术,用于实现多线程环境下的原子操作,用于比较并交换内存中的值
CAS采用乐观锁机制,假设操作不会发生冲突,只有在冲突时才会重试
CAS操作包含3个参数:
- 预期值(expected value,E):期望变量的原始值
- 目标变量(memory location,V):需要修改的内存地址
- 新值(new value,N):要更新的值
执行逻辑:
如果V == E
(变量的当前值等于期望值),则将V
更新为N
否则,说明有其他线程修改了V
,不执行更新,继续重试(通常是自旋)
比如有两个线程都需要修改变量i
的值,默认为10,现在一个线程要将其修改为20,另一个要修改为30,如果他们都使用CAS算法,那么并不会加锁访问i
,而是直接尝试修改i
的值,但是在修改时,需要确认i
是不是10,如果是,表示其他线程还没对其进行修改,如果不是,那么说明其他线程已经将其修改,此时不能完成修改任务,修改失败
Mark Word
每个Java对象在内存中都有一个对象头,对象头包括以下两部分:
- Mark Word:存储对象的运行时数据,如锁状态、哈希码、GC分代年龄等
- Klass Pointer:指向对象的类元数据(Class Metadata)的指针
在 64 位 JVM 中,对象头的结构如下:
- Mark Word:64 位(8 字节)
- Klass Pointer:64 位(8 字节)
存储内容
Mark Word 的存储内容根据对象的状态动态变化,主要包括以下几类信息:
- 锁状态:无锁、偏向锁、轻量级锁、重量级锁等
- 哈希码:对象的默认哈希码(
hashCode()
方法返回的值) - GC 分代年龄:对象在垃圾回收过程中的年龄(用于分代回收)
- 其他标志位:用于标记对象的状态,如是否被锁定、是否偏向锁等
偏向锁
偏向锁旨在减少无竞争情况下的同步开销。如果一个锁对象(ObjectMonitor
)在整个生命周期中只被一个线程访问,那这个线程可以“偏向”该锁,无需进行CAS操作,避免锁的竞争。只有当其他线程尝试竞争锁时,才会撤销偏向锁,升级为轻量级锁甚至重量级锁。
过程
加锁过程:
- 线程进入同步代码块,尝试获取对象的锁
- 如果该对象是无锁状态(即Mark Word里线程ID为空)
- 直接将当前线程ID记录到对象的Mark Word中
- 偏向该线程,以后该线程进入同步块时,无需CAS操作
- 如果Mark Word里的
线程ID==当前线程ID
,说明这个线程已经获得偏向锁,直接进入同步块
解锁过程:
- 线程退出同步块时,不会释放锁,仍然偏向该线程,避免下次加锁时CAS操作
- 只有当其他线程尝试获取锁(其他线程访问锁对象时,发现Mark Word中的线程ID不属于自己)时,才会撤销偏向锁(升级为轻量级锁)
三种锁的对比
锁类型 | 适用场景 | 加锁方式 | 开销 | 优点 | 缺点 |
---|---|---|---|---|---|
偏向锁 | 无竞争 | 记录线程 ID | 最小 | 无需 CAS,效率最高 | 竞争时需要STW(暂停线程) |
轻量级锁 | 少量竞争 | CAS + 自旋 | 较低 | 避免线程阻塞 | 竞争高时 CPU 消耗大 |
重量级锁 | 高竞争 | 内核阻塞 | 最高 | 线程安全性强 | 线程切换开销大 |
锁消除和锁粗化
锁消除和锁粗化都是在运行时的一些优化方案,锁消除是比如我们某段代码虽然加了锁,但是在运行时根本不可能出现各个线程之间资源争夺的情况,这种情况下,完全不需要任何加锁机制,所以锁会被消除。锁粗化则是我们代码中频繁地出现互斥同步操作,比如在一个循环内部加锁,这样明显是非常消耗性能的,所以虚拟机一旦检测到这种操作,会将整个同步范围进行扩展。
JMM内存模型(JMM)
这里提到的内存模型和JVM中介绍的内存模型不在同一个层次,JVM中的内存模型是虚拟机规范对整个内存区域的规划,而Java内存模型,是在JVM内存模型之上的抽象模型,具体实现依然是基于JVM内存模型实现的。
主要解决的问题:
- 原子性:一个操作是不可分割的整体
- 可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
- 有序性:代码的执行顺序与编写顺序一致
JMM(Java Memory Model)内存模型规定:
- 所有的变量全部存储在主内存(指的是会出现竞争的变量,包括成员变量、静态变量等,而局部变量这种属于线程私有,不包括在内)
- 每条线程有着自己的工作内存(可以类比CPU的高速缓存)线程对变量的所有操作,必须在工作内存中进行,不能直接操作主内存中的数据
- 不同线程之间的工作内存相互隔离,如果需要在线程之间传递内容,只能通过主内存完成,无法直接访问对方的工作内存
也就是说,每一条线程如果要操作主内存中的数据,那么得先拷贝到自己的工作内存中,并对工作内存中数据的副本进行操作,操作完成之后,也需要从工作副本中将结果拷贝回主内存中,具体的操作就是Save
(保存)和Load
(加载)操作。
内存模型具体实现:
- 主内存:对应堆中存放对象的实例的部分
- 工作内存:对应线程的虚拟机栈的部分区域,虚拟机可能会对这部分内存进行优化,将其放在CPU的寄存器或是高速缓存中。比如在访问数组时,由于数组是一段连续的内存空间,所以可以将一部分连续空间放入到CPU高速缓存中,那么之后如果我们顺序读取这个数组,那么大概率会直接缓存命中
volatile关键字
volatile
关键字可以解决可见性问题。
如果多线程访问同一个变量,那么这个变量会被线程拷贝到自己的工作内存中进行操作,而不是直接对主内存中的变量本体进行操作,下面这个操作看起来是一个有限循环:
public class Main { |
要解决这种问题,使用加锁的方法同一时间只能有一个线程使用,这样的话是可以解决问题的:
public class Main { |
但是,除了硬加一把锁的方案,也可以使用volatile
关键字来解决,此关键字的第一个作用,就是保证变量的可见性。当写一个volatile
变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去,并且这个写会操作会导致其他线程中的volatile
变量缓存无效,这样,另一个线程修改了这个变时,当前线程会立即得知,并将工作内存中的变量更新为最新的版本。
public class Main { |
当a发生改变时,循环立即结束。
虽然说volatile
能够保证可见性,但是不能保证原子性,保证原子性可以使用原子类来解决
volatile
会禁止指令重排,即volatile变量不会出现重排序的情况
若用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障
来禁止特定类型的处理器重排序
内存屏障(Memory Barrier)是一个CPU指令,它的作用有两个:
- 保证特定操作的顺序
- 保证某些变量的内存可见性(volatile的内存可见性,其实就是依靠这个实现的)
由于编译器和处理器都能执行指令重排的优化,如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序
happens-before原则
JMM提出了happens-before
(先行发生)原则,定义一些禁止编译优化的场景,只要是按照原则进行编程,那么就能够保持并发编程的正确性
程序次序规则:同一个线程中,按照程序的顺序,前面的操作happens-before后续的任何操作
- 同一个线程内,代码的执行结果是有序的。其实就是,可能会发生指令重排,但是保证代码的执行结果一定是和按照顺序执行得到的一致,程序前面对某一个变量的修改一定对后续操作可见的,不可能会出现前面才把a修改为1,接着读a居然是修改前的结果,这也是程序运行最基本的要求
监视器锁规则:对一个锁的解锁操作,happens-before后续对这个锁的加锁操作
- 就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果。比如前一个线程将变量
x
的值修改为了12
并解锁,之后另一个线程拿到了这把锁,对之前线程的操作是可见的,可以得到x
是前一个线程修改后的结果12
(所以synchronized是有happens-before规则的)
- 就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果。比如前一个线程将变量
volatile变量规则:对一个volatile变量的写操作happens-before后续对这个变量的读操作
- 就是如果一个线程先去写一个
volatile
变量,紧接着另一个线程去读这个变量,那么这个写操作的结果一定对读的这个变量的线程可见
- 就是如果一个线程先去写一个
线程启动规则:主线程A启动线程B,线程B中可以看到主线程启动B之前的操作
- 在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见
线程加入规则:如果线程A执行操作
join()
线程B并成功返回,那么线程B中的任意操作happens-before线程Ajoin()
操作成功返回传递性规则:如果A happens-before B,B happens-before C,那么A happens-before C