Java内存模型与线程

为什么要并发:计算机的运算能力与速度远大于它的存储和通信子系统的速度,不希望计算机处于一个空闲状态等待I/O、网络通信、数据库访问上。衡量标准就是TPS(每秒事物的处理数)

Java虚拟机的内存模型

主要为了方便的从内存中存取实例字段、静态字段和数组对象的元素等,定义了内存模型。但是最关键的是不包括局部变量和方法参数,因为这两个是线程私有的。

在JVM中内存主要分为主内存和工作内存,线程的工作内存保存了这个线程使用的变量的主内存副本,线程对变量的操作不能在主内存中进行

pkcHhGt.png

一般虚拟机会使工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存

缓存一致性考虑

在不同的工作内存要同时操作主内存或者操作相同的数据的情况下,如何保证内存间一致交互呢?主要是有不同操作。这些操作基本上都是在JVM下保证原子的、不可再分的。具体操作如下:

  • lock:把主内存的一个变量标记为一条线程独占的状态。
  • unlock:把主内存的一个处于锁定状态的变量释放出来。
  • read:把主内存的变量值从主内存传输到线程的工作内存中。
  • load:把前面从read中拿到的主内存的变量放入工作内存的变量副本中,此操作在工作内存中进行。
  • use:作用于工作内存中的变量,把变量值传递给执行引擎。
  • assign(赋值):作用于工作内存中的变量,把执行引擎接受的值赋值给变量。
  • store:作用于工作内存中的变量,把工作内存中的一个变量的值传到主内存中。
  • write:作用于主内存中的变量,从store得到的变量值放入主内存中。

简化的一些操作

  1. volatile

最轻量级的同步机制,主要有两个作用:一是保证此变量对所有线程的可见性,当一个具有volatile关键字的变量被修改的时候,其他线程立即可以得知。如果是普通变量则会先要写回主内存,所以并不是立刻得知的。但这条性质并不代表使用了volatile的变量在线程下是线程安全的,因为Java中对于运算操作符并不是原子的。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class VolatileTest {
public static volatile int race = 0;
public static void increase(){
race++;
}
private static final int THREADS_COUNT = 20;
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++){
threads[i] = new Thread(new Runnable() {
@override
public void run() {
for (int i = 0; i < 10000; i ++) {
increase();
}
}
});
threads[i].start();
}
while (Thread.activeCount() > 1)
Thread.yield();
System.out.println(race);
}
}

结果是每次都小于200000,问题就在race++中。所以volatile只保证了可见性,没有保证原子性。如果要保证原子性则需要synchronized。二是禁止指令重排序优化,指令重排序虽然可以提高处理器的执行效率,但也可能导致程序行为出现问题,特别是在多线程环境下:

  • 数据竞争:当多个线程并发访问共享变量时,如果处理器对指令进行了重排序,可能导致程序读取到不正确的数据,从而产生数据竞争的问题。
  • 可见性问题:如果一个线程对共享变量进行了修改,其他线程可能无法立即看到这个修改,因为处理器可能会延迟刷新缓存,或者对指令进行重排序。

举一个单例模式的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Singleton {
private static volatile Singleton instance;
public static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if(instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

在这段代码的字节码表示中:

1
2
3
4
5
0x01a3de0f: mov $0x3375cdb0, %esi;
0x01a3de0f: mov %eax, 0x150(%esi);
0x01a3de0f: shr $0x9, %esi;
0x01a3de0f: movb $0x0, %0x1104800(%esi);
0x01a3de0f: lock addl $0x0, (%esp);

在用了volatile关键字以后,多了一行lock代码,也称为内存屏障,就使得指令重排时不能把后面的指令重排序到内存屏障之前的位置。

2.synchronized

Synchronized 是 Java 中用于实现线程安全的关键字,它可以应用于方法或代码块,用来保证在同一时刻最多只有一个线程可以执行被 synchronized 保护的代码段。这里我将分别展示 synchronized 方法和 synchronized 代码块的使用示例。

  1. synchronized 方法

当一个方法被 synchronized 修饰时,它一次只允许一个线程访问该方法的代码块,其他线程必须等待当前线程执行完毕释放锁后才能执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class Counter {
private int count = 0;

// synchronized 方法:保证对 count 变量的操作是线程安全的
public synchronized void increment() {
count++;
}

public synchronized int getCount() {
return count;
}
}

public class Main {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();

// 创建多个线程并发增加计数器的值
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});

Thread thread2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});

thread1.start();
thread2.start();

// 等待两个线程执行完毕
thread1.join();
thread2.join();

// 输出计数器的值
System.out.println("Final Count: " + counter.getCount()); // 应该输出 2000
}
}

解释

  • Counter 类中的 increment()getCount() 方法都使用了 synchronized 关键字,确保对 count 变量的读写操作是线程安全的。
  • increment() 方法递增 count 变量,由于是 synchronized 方法,同一时刻只能有一个线程执行此方法。
  • Main 类创建了两个线程 thread1thread2,分别执行对 Counter 实例的 increment() 方法来增加计数器的值。
  • join() 方法用来等待 thread1thread2 执行完毕,然后输出最终的计数器值。
  1. synchronized 代码块

synchronized 代码块允许指定哪个对象或类的实例来作为锁,只有获取了该锁的线程才能执行被保护的代码块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class Counter {
private int count = 0;

// 非 synchronized 方法
public void increment() {
synchronized (this) { // 使用当前对象 this 作为锁
count++;
}
}

public int getCount() {
synchronized (this) {
return count;
}
}
}

public class Main {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();

// 创建多个线程并发增加计数器的值
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});

Thread thread2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});

thread1.start();
thread2.start();

// 等待两个线程执行完毕
thread1.join();
thread2.join();

// 输出计数器的值
System.out.println("Final Count: " + counter.getCount()); // 应该输出 2000
}
}

解释

  • Counter 类中的 increment()getCount() 方法使用了 synchronized 代码块,锁对象是 this,即当前实例对象。
  • increment() 方法中,使用 synchronized (this) 包围对 count 变量的修改,确保只有一个线程可以同时执行这段代码。
  • Main 类的使用方式与上一个示例类似,创建多个线程来并发访问 Counter 实例,执行 increment() 方法。

总结

通过使用 synchronized 关键字,可以有效地控制多线程环境下的共享资源访问,保证线程安全性。需要注意的是,虽然 synchronized 能够简单有效地实现同步,但在高并发场景下可能会导致性能问题,此时可以考虑使用并发工具类或者 java.util.concurrent 包中的锁机制来进行更细粒度的控制。

在 Java 中,wait()notify() 是用于线程间通信的关键方法,它们通常与 synchronized 关键字一起使用,用于实现线程的等待和唤醒机制。下面我将详细解释它们的使用方法和注意事项。

wait() 方法

wait() 方法使当前线程进入等待状态,并释放它持有的锁(如果有的话),直到其他线程调用相同对象上的 notify()notifyAll() 方法来唤醒它,或者等待超时。

使用方法:

1
2
3
4
5
6
7
8
9
10
synchronized (obj) {
while (condition) {
try {
obj.wait(); // 线程进入等待状态,并释放锁
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 等待结束后的操作
}
  • 在 synchronized 块中调用 wait() 方法,线程释放锁并进入等待状态。
  • 等待状态下的线程可以被其他线程通过 notify()notifyAll() 方法唤醒。
  • 等待期间可以通过调用 interrupt() 方法来中断线程的等待状态,会抛出 InterruptedException 异常。

notify() 方法

notify() 方法用于唤醒因调用相同对象上的 wait() 方法而进入等待状态的一个线程。如果有多个线程在等待,只能唤醒其中一个线程,具体唤醒哪个由 JVM 决定。

使用方法:

1
2
3
4
synchronized (obj) {
// 修改共享变量的状态
obj.notify(); // 唤醒一个等待中的线程
}
  • 在 synchronized 块中调用 notify() 方法,唤醒因调用 obj.wait() 进入等待状态的一个线程。
  • 唤醒的线程仍需重新竞争锁,才能继续执行 synchronized 块后面的代码。

notifyAll() 方法

notifyAll() 方法用于唤醒因调用相同对象上的 wait() 方法而进入等待状态的所有线程。如果有多个线程在等待,会唤醒所有等待的线程。

使用方法:

1
2
3
4
synchronized (obj) {
// 修改共享变量的状态
obj.notifyAll(); // 唤醒所有等待中的线程
}
  • 在 synchronized 块中调用 notifyAll() 方法,唤醒因调用 obj.wait() 进入等待状态的所有线程。
  • 每个被唤醒的线程仍需重新竞争锁,才能继续执行 synchronized 块后面的代码。

注意事项:

  1. 必须在 synchronized 块中调用wait(), notify(), notifyAll() 方法必须在持有对象的监视器(锁)时调用,否则会抛出 IllegalMonitorStateException 异常。
  2. 避免过早或过晚的调用:调用 wait() 方法时,通常是在一个循环中检查条件的状态并等待某些条件满足后再进一步执行,以避免虚假唤醒和竞态条件。
  3. wait() 方法需要放在循环中:等待条件的时候,应当使用 while 循环检查条件,而不是 if 语句,以防止因虚假唤醒而导致程序逻辑错误。
  4. 避免死锁:使用 wait()notify() 时要小心死锁问题,确保线程之间的通信和锁的竞争不会导致死锁的发生。
  5. 线程安全性:考虑到多线程环境下的竞态条件和线程安全问题,确保对共享资源的访问是安全的和可预测的。

综上所述,wait()notify() 是 Java 多线程编程中非常重要的方法,用于实现线程间的协作和通信,能够有效地处理线程之间的等待和唤醒操作。

线程

线程在Java中也是比进程更轻量级的调度执行单位,线程的引用可以把一个进程的资源分配和执行调度封开,各个线程既可以共享进程资源,又可以进行独立的调度。每个已经调用过start()方法且还未结束的java.lang.Thread类的实例就代表着一个线程。

实现类型

  1. 内核线程的实现:

顾名思义,一个内核(KLT)就实现和管理一个进程,所以多核处理器达到并发的效果。

程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程。

内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。

pk2K0Xt.png

但是:系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。

  1. 用户线程的实现

用户线程(User Thread,UT)是建立在用户空间的线程库,系统内核时无法感知用户线程的存在和实现,用户线程完全是在用户态中完成的,甚至在绝大多数时候都不需要切换到内核态,所以操作实现非常快捷和低消耗。这种进程与用户线程之间1:N的关系称为一对多的线程模型。

pk2KhXq.png

但在Java中已经几乎舍弃了这种操作,因为所有的操作几乎都在用户态实现,所以在如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难。像Golong等新型语言慢慢支持了这种模型,因为为了实现更高的并发度。

  1. 混合实现

线程除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式,被称为N:M实现。在这种混合实现下,既存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,这大大降低了整个进程被完全阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,是N:M的关系。

pk2Kxnx.png

Java线程调度

主要有两种方式:协同式和抢占式的线程调度

协同式是指线程自己执行完工作以后通知系统然后下一条线程再去执行,而抢占式是指每个线程将由系统来分配执行时间。Java使用的线程调度方式是抢占式调度。Java语言一共设置了10个级别的线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY)。在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。