ThreadGroup
线程组可以对线程进行批量控制。
- 每个 Thread 必然存在于一个 ThreadGroup 中,Thread 不能独立于 ThreadGroup 存在。
- 执行 main()方法的线程名字是 main。
- 如果在 new Thread 时没有显式指定,那么默认将父线程(当前执行 new Thread 的线程)线程组设置为自己的线程组。
hread testThread = new Thread(() -> {
System.out.println("testThread当前线程组名字:" +
Thread.currentThread().getThreadGroup().getName());
System.out.println("testThread线程名字:" +
Thread.currentThread().getName());
});
testThread.start();
System.out.println("执行main所在线程的线程组名字: " + Thread.currentThread().getThreadGroup().getName());
System.out.println("执行main方法线程名字:" + Thread.currentThread().getName());
执行main所在线程的线程组名字: main
testThread当前线程组名字:main
testThread线程名字:Thread-0
执行main方法线程名字:main
- ThreadGroup 是一个标准的向下引用的树状结构,父 ThreadGroup 可以引用其子 ThreadGroup 和子线程,但子 ThreadGroup 和子线程不能直接引用父 ThreadGroup,也不允许平级引用。这样设计可以防止"上级"线程被"下级"线程引用而无法被 GC 回收。
- 线程组可以起到
统一控制线程的优先级
和检查线程权限的作用。
线程优先级
- 线程优先级可以被人为指定,但 JVM只是给操作系统传达这个建议值,线程最终在操作系统中的优先级还是由操作系统决定。
- 不过通常情况下,高优先级的线程将会比低优先级的线程有更高的概率得到执行。
JMM Java内存模型
Java 内存模型(Java Memory Model,JMM)定义了 Java 程序中的变量、线程如何和主存以及工作内存进行交互的规则。它主要涉及到多线程环境下的共享变量可见性、指令重排等问题,是理解并发编程中的关键概念。
作用
Java 线程之间的通信由 JMM 控制。
Java内存模型和运行时内存区域
Java 内存模型(JMM)和 Java 运行时内存区域的区别可大着呢。
Java 内存模型(Java Memory Model,JMM)
- 定义:Java 内存模型描述了多线程程序中变量(包括实例字段、静态字段和数组元素)如何从主内存写入和读取,以及如何在主内存和工作内存(线程本地内存)之间传递。
- 主要内容:
- 可见性(Visibility): 当一个线程修改了某个变量的值,其他线程是否能立即看到这个修改。、
- 原子性(Atomicity): 一个操作是不可分割的,不会被其他线程的操作所中断。
- 有序性(Ordering): 程序执行的顺序是否与代码中的顺序一致。
- 几个关键:
- 主内存和工作内存: 所有变量都存储在主内存中,每个线程还有自己的工作内存,线程对变量的所有操作(读取和赋值)都必须在工作内存中进行。
- volatile 关键字: 保证变量的可见性和有序性,但不保证原子性。
- synchronized 关键字: 保证进入临界区的线程对变量的可见性和原子性。
- happens-before 原则: 如果一个操作 happens-before 另一个操作,那么第一个操作的结果对第二个操作是可见的,且第一个操作的执行顺序排在第二个操作之前。
Java Runtime Memory Areas Java 运行时内存区域
- 定义:JVM 在运行时将内存分为若干个不同的区域,用来存储不同类型的数据。这些区域包括:
程序计数器
(Program Counter Register):每个线程都有一个独立的程序计数器,用于记录当前线程执行的字节码指令的地址。Java 虚拟机栈
(JVM Stack):每个线程都有一个独立的虚拟机栈,用于存储局部变量表、操作数栈、动态链接和方法出口等信息。本地方法栈
(Native Method Stack):与 Java 虚拟机栈类似,不过它服务于虚拟机使用到的本地方法(如 JNI)。堆
(Heap):堆是所有线程共享的一块内存区域,几乎所有的对象实例都在这里分配。堆是垃圾收集器管理的主要区域,根据垃圾收集器的不同,可以细分为新生代(Eden、From Survivor、To Survivor)和老年代。方法区
(Method Area):方法区是所有线程共享的一块内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器(JIT)编译后的代码等数据。在 JDK 8 之后,方法区称为元空间(Metaspace)。运行时常量池
(Runtime Constant Pool):运行时常量池是方法区的一部分,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
共享堆内存带来的问题
对每一个线程来说,栈都是私有的,而堆是共有的。
也就是说,在栈中的变量(局部变量、方法定义的参数、异常处理的参数)不会在线程之间共享,也就不会有内存可见性的问题,也不受内存模型的影响。而在堆中的变量是共享的,一般称之为共享变量。
所以,内存可见性针对的是堆中的共享变量。
根据 JMM 的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主存中读取。
因此,各线程在本地内存中修改了共享变量,必须再写回到主内存,其它线程才能看到,如果还没写回到主内存,其它线程就访问了这个共享变量,就会造成可见性问题。
volatile关键字就是解决这个问题的。
对于可能被多线程并发修改访问的共享变量,加上 volatile关键字后,能确保:在本地内存修改的共享变量,立刻在主内存中生效。
用术语来说就是:保证多线程操作共享变量的可见性以及禁止指令重排序
在更底层,JMM 通过内存屏障来实现内存的可见性以及禁止重排序。为了程序员更方便地理解,设计者提出了 happens-before 的概念(下文会细讲),它更加简单易懂,从而避免了程序员为了理解内存可见性而去学习复杂的重排序规则,以及这些规则的具体实现方法。
指令重排序
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。
因为指令重排序可以提高性能。
处理器中普遍采用流水线技术,
但是流水线技术最害怕中断,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。
指令重排就是减少中断的一种技术。
- 指令重排一般分为以下三种:
- 编译器优化重排。编译器在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
- 指令并行重排。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
- 内存系统重排。由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。
程序可分为正确同步(使用 Volatile、Synchronized 等关键)、未正确同步的。
-
对于已经正确同步的程序
- 会改变程序执行结果的重排序,JMM 要求编译器和处理器都禁止这种重排序。
- 不会改变程序执行结果的重排序,JMM 对编译器和处理器不做要求,允许这种重排序。
-
对于未正确同步的程序
- JMM 只提供最小安全性:线程读取到的值,要么是之前某个线程写入的值,要么是默认值,不会无中生有。(被后发线程先改变)
并发编程的两个问题
- 并发编程的线程之间存在两个问题:
- 线程间如何通信?即:线程之间以何种机制来交换信息
- 线程间如何同步?即:线程以何种机制来控制不同线程间发生的相对顺序
- 有两种并发模型可以解决这两个问题:
- 消息传递并发模型
- 共享内存并发模型
这两种模型之间的区别如下图所示:
Java 内存模型(JMM)定义了 Java 程序中的变量、线程如何和主存以及工作内存进行交互的规则。它主要涉及到多线程环境下的共享变量可见性、指令重排等问题,是理解并发编程中的关键概念。
Java 内存模型(JMM)主要针对的是多线程环境下,如何在主内存与工作内存之间安全地执行操作。
Java 运行时内存区域描述的是在 JVM 运行时,如何将内存划分为不同的区域,并且每个区域的功能和工作机制。主要包括方法区、堆、栈、本地方法栈、程序计数器。
指令重排是为了提高 CPU 性能,但是可能会导致一些问题,比如多线程环境下的内存可见性问题。
happens-before 规则是 JMM 提供的强大的内存可见性保证,只要遵循 happens-before 规则,那么我们写的程序就能保证在 JMM 中具有强的内存可见性。
volatile
- volatile 作用
- 保证可见性:当写一个 volatile 变量时,JMM 会把该线程在本地内存中的变量强制刷新到主内存中去;
- 禁止指令重排
- 但不保证原子性:
- volatile 的实际应用
- 在单例模式中,双重检查锁定(Double-Checked Locking)使用 volatile 确保线程安全地创建单例实例。
- volatile 适用于需要在多个线程间共享并及时更新的状态变量,例如计数器、标志变量。
volatile 会禁止指令重排
重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变。
使用 volatile 关键字修饰共享变量可以禁止这种重排序。
当使用 volatile 关键字来修饰一个变量时,Java 内存模型会插入内存屏障(一个处理器指令,可以对 CPU 或编译器重排序做出约束)来确保以下两点:
- 写屏障(Write Barrier):当一个 volatile变量被写入时,写屏障确保在该屏障之前的所有变量的写入操作都提交到主内存。
- 读屏障(Read Barrier):当读取一个 volatile变量时,读屏障确保在该屏障之后的所有读操作都从主内存中读取。
“也就是说,编译器和处理器执行到 volatile 变量时,必须将其前面的所有语句都必须执行完,后面所有得语句都未执行。且前面语句的结果对 volatile 变量及其后面语句可见。”
public class VolatileExample {
private volatile boolean running = true;
public void start() {
Thread worker = new Thread(() -> {
while (running) {
// 执行一些工作
}
System.out.println("Thread stopped.");
});
worker.start();
}
public void stop() {
running = false;
}
public static void main(String[] args) throws InterruptedException {
VolatileExample example = new VolatileExample();
example.start();
Thread.sleep(1000); // 主线程等待一段时间
example.stop(); // 停止工作线程
}
}
volatile 不适用的场景
下面是变量自加的示例:
public class volatileTest {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final volatileTest test = new volatileTest();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println("inc output:" + test.inc);
}
}
测试输出:
inc output:8182
“为什么呀?二哥?” 看到这个结果,三妹疑惑地问。
“因为 inc++不是一个原子性操作(前面讲过),由读取、加、赋值 3 步组成,所以结果并不能达到 10000。”我耐心地回答。
“哦,你这样说我就理解了。”三妹点点头。
怎么解决呢?
01、采用 synchronized(下一篇会讲,戳链接直达),把 inc++ 拎出来单独加 synchronized 关键字:
public class volatileTest1 {
public int inc = 0;
public synchronized void increase() {
inc++;
}
public static void main(String[] args) {
final volatileTest1 test = new volatileTest1();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(“add synchronized, inc output:” + test.inc);
}
}
02、采用 Lock,通过重入锁 ReentrantLock 对 inc++ 加锁(后面都会细讲,戳链接直达):
public class volatileTest2 {
public int inc = 0;
Lock lock = new ReentrantLock();
public void increase() {
lock.lock();
inc++;
lock.unlock();
}
public static void main(String[] args) {
final volatileTest2 test = new volatileTest2();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(“add lock, inc output:” + test.inc);
}
}
03、采用原子类 AtomicInteger(后面也会细讲,戳链接直达)来实现:
public class volatileTest3 {
public AtomicInteger inc = new AtomicInteger();
public void increase() {
inc.getAndIncrement();
}
public static void main(String[] args) {
final volatileTest3 test = new volatileTest3();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<100;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(“add AtomicInteger, inc output:” + test.inc);
}
}
三者输出都是 1000,如下:
add synchronized, inc output:1000
add lock, inc output:1000
add AtomicInteger, inc output:1000
#volatile 实现单例模式的双重锁
下面是一个使用"双重检查锁定"(double-checked locking)实现的单例模式(Singleton Pattern)的例子。
public class Penguin {
private static volatile Penguin m_penguin = null;
// 一个成员变量 money
private int money = 10000;
// 避免通过 new 初始化对象,构造方法应为 public 或 private
private Penguin() {}
public void beating() {
System.out.println("打豆豆" + money);
}
public static Penguin getInstance() {
if (m_penguin == null) {
synchronized (Penguin.class) {
if (m_penguin == null) {
m_penguin = new Penguin();
}
}
}
return m_penguin;
}
}
在这个例子中,Penguin 类只能被实例化一次。来看代码解释:
声明了一个类型为 Penguin 的 volatile 变量 m_penguin,它是类的静态变量,用来存储 Penguin 类的唯一实例。
Penguin() 构造方法被声明为 private,这样就阻止了外部代码使用 new 来创建 Penguin 实例,保证了只能通过 getInstance() 方法获取实例。
getInstance() 方法是获取 Penguin 类唯一实例的公共静态方法。
第一次 if (null == m_penguin) 检查是否已经存在 Penguin 实例。如果不存在,才进入同步代码块。
synchronized(penguin.class) 对类的 Class 对象加锁,确保在多线程环境下,同时只能有一个线程进入同步代码块。在同步代码块中,再次执行 if (null == m_penguin) 检查实例是否已经存在,如果不存在,则创建新的实例。这就是所谓的“双重检查锁定”,一共两次。
最后返回 m_penguin,也就是 Penguin 的唯一实例。
其中,使用 volatile 关键字是为了防止 m_penguin = new Penguin() 这一步被指令重排序。因为实际上,new Penguin() 这一行代码分为三个子步骤:
步骤 1:为 Penguin 对象分配足够的内存空间,伪代码 memory = allocate()。
步骤 2:调用 Penguin 的构造方法,初始化对象的成员变量,伪代码 ctorInstanc(memory)。
步骤 3:将内存地址赋值给 m_penguin 变量,使其指向新创建的对象,伪代码 instance = memory。
如果不使用 volatile 关键字,JVM 可能会对这三个子步骤进行指令重排。
为 Penguin 对象分配内存
将对象赋值给引用 m_penguin
调用构造方法初始化成员变量
这种重排序会导致 m_penguin 引用在对象完全初始化之前就被其他线程访问到。具体来说,如果一个线程执行到步骤 2 并设置了 m_penguin 的引用,但尚未完成对象的初始化,这时另一个线程可能会看到一个“半初始化”的 Penguin 对象。
假如此时有两个线程 A 和 B,要执行 getInstance() 方法:
public static Penguin getInstance() {
if (m_penguin == null) {
synchronized (Penguin.class) {
if (m_penguin == null) {
m_penguin = new Penguin();
}
}
}
return m_penguin;
}
线程 A 执行到 if (m_penguin == null),判断为 true,进入同步块。
线程 B 执行到 if (m_penguin == null),判断为 true,进入同步块。
如果线程 A 执行 m_penguin = new Penguin() 时发生指令重排序:
线程 A 分配内存并设置引用,但尚未调用构造方法完成初始化。
线程 B 此时判断 m_penguin != null,直接返回这个“半初始化”的对象。
这样就会导致线程 B 拿到一个不完整的 Penguin 对象,可能会出现空指针异常或者其他问题。
于是,我们可以为 m_penguin 变量添加 volatile 关键字,来禁止指令重排序,确保对象的初始化完成后再将其赋值给 m_penguin。
#小结
“好了,三妹,我们来总结一下。”我舒了一口气说。
volatile 可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在 JVM 底层 volatile 是采用“内存屏障”来实现的。
观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码就能发现,加入 volatile 关键字时,会多出一个 lock 前缀指令,lock 前缀指令实际上相当于一个内存屏障(也称内存栅栏),内存屏障会提供 3 个功能:
它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
它会强制将对缓存的修改操作立即写入主存;
如果是写操作,它会导致其他 CPU 中对应的缓存行无效。
最后,我们学习了 volatile 不适用的场景,以及解决的方法,并解释了双重检查锁定实现的单例模式为何需要使用 volatile。
synchronized
synchronized 可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块(主要是对方法或者代码块中存在共享数据的操作),同时我们还应该注意到 synchronized 的另外一个重要的作用,synchronized 可保证一个线程的变化(主要是共享数据的变化)被其他线程所看到(保证可见性,完全可以替代 volatile 功能)。
synchronized 关键字最主要有以下 3 种应用方式:
同步方法,为当前对象(this)加锁,进入同步代码前要获得当前对象的锁;
同步静态方法,为当前类加锁(锁的是 Class 对象),进入同步代码前要获得当前类的锁;
同步代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。