引入多线程的重要性和挑战
可以参考另一篇文章 https://blog.csdn.net/qq_41956309/article/details/133717408
JMM(Java Memory Model,Java 内存模型)
什么是JMM
JMM(Java Memory Model,Java 内存模型)是一种规范,用于定义多线程程序中的内存访问规则和语义,确保多线程程序的正确性和可移植性。JMM 定义了线程如何与主内存和工作内存交互,以及如何确保多线程程序中的内存可见性和一致性。
引入JMM的目的
引入 Java Memory Model(JMM)的主要目的是为了解决多线程编程中的内存可见性和一致性问题,以及确保多线程程序的正确性和可移植性 它提供了规则和机制,使得多线程编程更容易管理和理解,减少了开发人员因多线程编程而引入的错误和 bug。
引入JMM的原因
在Java语言之前,C、C++等语言是直接使用物理硬件和操作系统的内存模型的,正因为这些语言直接和底层打交道,使得这些语言执行效率更高,但同时也带来了一些问题:由于不同平台上,软件和硬件都有一定差异(比如硬件厂商不同、操作系统不同),导致有可能同一个程序在一套平台上执行没问题,另一个平台上执行却得到不一样的结果,甚至报错。
Java语言试图定义一个Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,达到让Java程序在不同平台上都能达到一致的内存访问效果的目的,这就是Java内存模型的意义。
JMM的主要结构和概念
1.主内存(Main Memory): 主内存是所有线程共享的内存区域,用于存储共享数据,包括全局变量、对象实例数据和类信息。主内存是多线程程序中的主要数据存储区域。
2.工作内存(Working Memory): 每个线程都有自己的工作内存,用于存储线程私有的数据,包括局部变量、方法参数和方法调用信息。工作内存用于缓存主内存中的数据。
3.主内存与工作内存交互规则:要想读主内存的变量,首先得将主内存中的变量读取到工作内存中;要想修改主内存的变量,首先得先修改工作内存中的变量副本,然后再将变量副本写到主内存中。
4.同步操作: JMM 提供了一些同步操作,如锁、synchronized 块和 volatile 关键字,用于协调线程之间的数据访问。这些同步操作确保了线程之间的协同和互斥访问。
5.Happens-Before 关系: JMM 引入了 Happens-Before 关系,定义了事件发生的顺序和相关性。如果事件 A Happens-Before 事件 B,那么事件 B 将看到事件 A 之前的所有操作。这有助于确保多线程程序中的正确性。
i++为什么线程不安全
假设我们有一个共享变量 int i = 0;,两个线程 A 和 B 同时执行 i++ 操作。
1.线程 A 和线程 B 同时启动,各自创建自己的工作内存,其中 i 的初始值为 0
2.线程A从主内存中读取i=0 然后执行i++操作此时 i=1 并且将i的值写回主内存中
3.线程B因为是和线程A同时执行 所以在线程A将i进行++ 导致i=1并写会主内存之前就从主内存中读到了i=0 因此读到线程B自己工作内存中的 i 的值依然 = 0 并对0 进行++ 此时线程B自己工作内存中i =1 并把i=1写回主内存中 覆盖掉了原先A线程写到主存中的i=1 此时主存中的i =1 而不是 =2 所以导致了线程不安全的问题
如何解决并发导致的线程不安全问题
假设我们增加一个共享变量 lock 当线程A抢占到这个lock的时候才能继续执行,线程B没抢占的时候加入一个阻塞队列中等待只有等线程释放lock之后裁让继续执行 那么此时等线程A对i进行++之后 主内存中的i=1 然后释放lock 此时B能拿到lock在去读主内存中 i的值此时i已经=1了 那么将1 加载进自己的工作内存在进行++ 此时就能得到正确的值并且写回到主内存中
synchronized 的基本概念:
synchronized 是 Java 中用于实现线程同步的关键字。它的主要作用是确保多个线程能够安全地访问共享资源,避免竞态条件和数据不一致性的问题
特性
1.互斥性(Mutual Exclusion): synchronized 确保在同一时间只有一个线程可以获得锁并执行被 synchronized 修饰的代码块或方法。这防止多个线程同时访问共享资源,从而避免竞态条件和数据不一致性。
2.对象级别锁: synchronized 可以用于锁定对象或类。当它用于实例方法时,它锁定了对象实例;当它用于静态方法时,它锁定了类。这意味着不同的对象实例可以同时调用不同的实例方法,而不会互相阻塞。
3.重入性(Reentrancy): Java 中的 synchronized 支持重入性,即一个线程可以多次获得同一个锁而不会被阻塞。这允许线程在持有锁的情况下进入另一个 synchronized 块或方法。重入性有助于避免死锁。
4.内置锁(Intrinsic Lock): synchronized 使用内置锁,也称为监视器锁。每个 Java 对象都有一个关联的内置锁,线程可以通过 synchronized 来获取或释放这个锁。当一个线程获取锁时,其他线程会被阻塞,直到锁被释放。
5.阻塞性质: 当一个线程无法获得 synchronized 块或方法的锁时,它会被阻塞,直到锁可用。这确保了线程的排队执行,避免了竞争条件。
6.性能开销: synchronized 在某些情况下可能引入性能开销,因为只有一个线程可以执行被锁定的代码块。在高并发情况下,这可能导致性能下降。因此,在某些情况下,更高级的同步工具,如 ReentrantLock,可以提供更好的性能控制
synchronized 的作用范围:
例方法级别的 synchronized
当 synchronized 修饰实例方法时,锁的范围是该实例对象。这意味着不同实例对象之间的锁互不干扰,每个实例对象都有自己的锁,可以并发执行相同的方法,但同一个实例对象的方法只能被一个线程执行,其他线程会被阻塞。
public synchronized void instanceMethod() {
// 该方法的锁作用范围是当前对象实例
}
静态方法级别的 synchronized
当 synchronized 修饰静态方法时,锁的范围是该类的 Class 对象,因此它是全局的,对该类的所有实例对象生效。这意味着无论多少实例对象存在,只有一个线程能够同时执行该静态方法。
public static synchronized void staticMethod() {
// 该方法的锁作用范围是当前类的 Class 对象
}
对象锁
当 synchronized 修饰实例方法时,它使用的是对象锁,作用范围是该对象实例。多个线程可以同时访问不同对象实例的方法,但同一个对象实例的方法只能被一个线程执行
class Example {
public synchronized void instanceMethod() {
// 该方法的锁作用范围是当前对象实例
}
}
Example obj1 = new Example();
Example obj2 = new Example();
// 不同对象实例可以并发执行
obj1.instanceMethod();
obj2.instanceMethod();
类锁
当 synchronized 修饰静态方法时,它使用的是类锁,作用范围是整个类,对该类的所有实例对象生效。只有一个线程能够同时执行该静态方法,无论有多少实例对象存在。
class Example {
public static synchronized void staticMethod() {
// 该方法的锁作用范围是当前类的 Class 对象
}
}
Example obj1 = new Example();
Example obj2 = new Example();
// 不论有多少实例对象,同一个类的静态方法只能被一个线程执行
Example.staticMethod();
synchronized案例:
演示如何使用 synchronized 实现多线程访问共享资源的同步。在这个例子中,我们有一个银行账户对象,多个线程同时进行存款和取款操作,需要确保线程安全。
class BankAccount {
private int balance = 1000;
public synchronized void deposit(int amount) {
balance += amount;
}
public synchronized void withdraw(int amount) {
if (balance >= amount) {
balance -= amount;
} else {
System.out.println("Insufficient funds");
}
}
public synchronized int getBalance() {
return balance;
}
}
public class BankExample {
public static void main(String[] args) {
BankAccount account = new BankAccount();
// 创建多个存款线程
Thread depositThread1 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
account.deposit(10);
}
});
Thread depositThread2 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
account.deposit(10);
}
});
// 创建多个取款线程
Thread withdrawThread1 = new Thread(() -> {
for (int i = 0; i < 50; i++) {
account.withdraw(20);
}
});
Thread withdrawThread2 = new Thread(() -> {
for (int i = 0; i < 50; i++) {
account.withdraw(20);
}
});
// 启动线程
depositThread1.start();
depositThread2.start();
withdrawThread1.start();
withdrawThread2.start();
// 等待所有线程执行完毕
try {
depositThread1.join();
depositThread2.join();
withdrawThread1.join();
withdrawThread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 打印账户余额
System.out.println("Final account balance: " + account.getBalance());
}
}
在这个示例中,BankAccount 类的存款和取款方法都使用 synchronized 修饰,以确保同一时间只有一个线程可以执行这些方法。这样,多个线程可以安全地访问共享的账户对象,避免了竞态条件和数据不一致性。
synchronized 的原理:
Java对象结构
重量级锁存在的性能问题
在Linux系统架构中可以分为用户空间和内核,我们的程序都运行在用户空间,进入用户运行状态就是所谓的用户态。在用户态可能会涉及到某些操作如I/O调用,就会进入内核中运行,此时进程就被称为内核运行态,简称内核态。
内核: 本质上可以理解为一种软件,控制计算机的硬件资源,并提供上层应用程序运行的环境。
用户空间: 上层应用程序活动的空间。应用程序的执行必须依托于内核提供的资源,包括CPU资源、存储资源、I/O资源等。
系统调用: 为了使上层应用能够访问到这些资源,内核必须为上层应用提供访问的接口:即系统调用。
使用monitor是重量级锁的加锁方式。在objectMonitor.cpp中会涉及到Atomic::cmpxchg_ptr,Atomic::inc_ptr等内核函数,
执行同步代码块,没有竞争到锁的对象会park()被挂起,竞争到锁的线程会unpark()唤醒。这个时候就会存在操作系统用户态和内核态的转换,这种切换会消耗大量的系统资源。试想,如果程序中存在大量的锁竞争,那么会引起程序频繁的在用户态和内核态进行切换,严重影响到程序的性能。这也是为什么说synchronized效率低的原因
synchronized锁优化
JDK1.6中引入偏向锁和轻量级锁对synchronized进行优化。此时的synchronized一共存在四个状态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁着锁竞争激烈程度,锁的状态会出现一个升级的过程。即可以从偏向锁升级到轻量级锁,再升级到重量级锁。锁升级的过程是单向不可逆的,即一旦升级为重量级锁就不会再出现降级的情况。
优化后锁的状态
偏向锁
什么是偏向锁
偏向锁(Biased Locking)是Java中用于提高单线程访问同步块的性能的一种锁机制。它的核心思想是,当一个线程第一次访问一个同步块时,虚拟机会将锁对象标记为偏向锁,并记录获取锁的线程ID。之后,如果同一个线程再次访问该同步块,它可以直接获取锁,而无需竞争,从而提高了性能。
为什么要引入偏向锁
大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。
偏向锁的作用
减少同一线程获取锁的代价。
加锁过程
1.首次加锁:当第一个线程尝试获取该对象的锁时,虚拟机检测到该对象没有被任何线程锁定,于是它将该对象的状态设置为偏向锁状态,并将偏向锁标志位设置为1。
2.线程ID记录:虚拟机记录获取锁的线程的ID,并将其存储在对象头中的偏向线程ID字段。此时,对象已经被偏向于第一个线程。
3.偏向锁校验: 当同一个线程再次尝试获取该对象的锁时,虚拟机会检查记录在对象头中的线程ID,与当前线程的ID进行比较。如果两者相同,说明当前线程已经获取了偏向锁,可以直接进入临界区执行,无需进一步竞争。
4.偏向锁升级: 如果当前线程的ID与记录在对象头中的线程ID不匹配,表示其他线程曾经获取过这个锁,偏向锁不再生效,会被撤销。此时,虚拟机会尝试使用CAS(Compare and Swap)操作来竞争锁。
5.获取锁: 如果CAS操作成功,当前线程获得了锁,进入临界区执行。如果CAS操作失败,虚拟机会尝试使用轻量级锁或重量级锁来竞争锁,具体取决于竞争情况。
什么是偏向锁撤销
偏向锁的撤销是为了应对多线程竞争的情况,以保证多线程环境下的锁操作能够正确执行。一旦偏向锁被撤销,锁对象会升级为轻量级锁或重量级锁,这些锁提供了更复杂的锁协议,以确保多线程环境下的正确性和公平性。
偏向锁撤销流程
竞争检测: 当有一个线程尝试获取一个对象的偏向锁时,虚拟机会进行竞争检测。它会检查对象头中的偏向锁标志位和线程ID字段,以确认是否有其他线程曾经获取过该锁。
判断偏向锁状态: 如果虚拟机发现偏向锁标志位为1,而且线程ID字段不是当前线程的ID,那么说明有其他线程曾经获取过该锁。
偏向锁撤销: 当偏向锁被撤销时,虚拟机会将对象头中的偏向锁标志位设置为0,表示不再使用偏向锁。线程ID字段也会被清空。
锁升级: 偏向锁被撤销后,虚拟机会将对象的锁状态升级为轻量级锁或重量级锁,具体升级方式取决于当前的竞争情况。这通常涉及到CAS(Compare and Swap)操作,用于确保多线程环境下的正确性和公平性。
轻量级锁
加锁过程
轻量级锁优化性能的依据是对于大部分的锁,在整个同步生命周期内都不存在竞争。 当升级为轻量级锁之后,MarkWord的结构也会随之变为轻量级锁结构。JVM会利用CAS尝试把对象原本的Mark Word 更新为Lock Record的指针,成功就说明加锁成功,改变锁标志位为00,然后执行相关同步操作。
轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁就会失效,进而膨胀为重量级锁。
解锁过程
当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cpu。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。
自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。
自旋锁是基于在大多数情况下,线程持有锁的时间都不会太长。如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),不断的尝试获取锁。空循环一般不会执行太多次,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入同步代码。如果还不能获得锁,那就会将线程在操作系统层面挂起,即进入到重量级锁。
重量级锁
当线程的自旋次数过长依旧没获取到锁,为避免CPU无端耗费,锁由轻量级锁升级为重量级锁。获取锁的同时会阻塞其他正在竞争该锁的线程,依赖对象内部的监视器(monitor)实现,monitor又依赖操作系统底层,需要从用户态切换到内核态,成本非常高。
成本高的原因
当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cpu。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。
偏向锁、轻量级锁、重量级锁的对比
synchronized锁升级过程
1.当线程A尝试获取该对象的锁时,虚拟机检测到该对象没有被任何线程锁定,于是它将该对象的状态设置为偏向锁状态,并将偏向锁标志位设置为1。并且在Mark Word中记录线程A的ID 此时,对象已经被偏向于线程A。
假设当线程A再次尝试获取锁 虚拟机会检查对象的状态。由于对象已经被偏向于当前线程,偏向锁标志位和线程ID会与当前线程匹配。因此,虚拟机可以直接让线程获取锁,无需竞争直接执行同步代码
2.当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步代码。如果抢锁失败。
3.代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步代码。如果保存失败,表示抢锁失败,竞争太激烈。
-
轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步代码,如果失败则继续执行步骤5;
-
自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。
synchronized 的局限性:
1.粒度粗: synchronized 只能锁定代码块或方法,这意味着只能对整个方法或代码块进行同步。如果某个方法中只有一小部分需要同步,使用synchronized可能导致过多的线程阻塞。
2.性能开销: synchronized 在某些情况下可能引入性能开销,因为只有一个线程可以执行被锁定的代码块,其他线程必须等待。这可能导致性能下降,尤其在高并发情况下。
3.无法中断: 一旦线程获得synchronized锁,其他线程无法中断它,只能等待锁被释放。这可能导致线程在等待锁时无法响应中断信号。
4.无法设置超时: synchronized 也无法设置获取锁的超时时间,这使得在某些情况下可能导致线程无限期等待。
5.只支持互斥锁: synchronized 只提供了一种互斥锁,这意味着只有一个线程可以获取锁,其他线程必须等待。它不支持更复杂的同步模式,如读写锁或信号量。
6.局部性差: synchronized 不提供足够的工具来优化缓存局部性,这可能会导致内存访问效率降低。
与其他同步机制的对比
synchronized vs. ReentrantLock
synchronized 是内置锁,而 ReentrantLock 是Java提供的一个可重入锁。
ReentrantLock提供了更多的灵活性,如超时等待、可中断锁、公平性等。
synchronized 更简单易用,但ReentrantLock提供更多高级功能。