一、沉默王二-并发编程
1、BlockingQueue
BlockingQueue 是 Java 中的一个接口,它代表了一个线程安全的队列,不仅可以由多个线程并发访问,还添加了等待/通知机制,以便在队列为空时阻塞获取元素的线程,直到队列变得可用,或者在队列满时阻塞插入元素的线程,直到队列变得可用。
最常见的"生产者-消费者"问题中,队列通常被视作线程间的数据容器,生产者将“生产”出来的数据放入数据容器,消费者从“数据容器”中获取数据,这样,生产者线程和消费者线程就解耦了,各自只需要专注自己的业务即可。
阻塞队列(BlockingQueue)被广泛用于“生产者-消费者”问题中,其原因是 BlockingQueue 提供了可阻塞的插入和移除方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。
1.1 基本操作
由于 BlockingQueue 继承了 Queue 接口,因此,BlockingQueue 也具有 Queue 接口的基本操作,如下所示:
1.1.1 插入元素
boolean add(E e):将元素添加到队列尾部,如果队列满了,则抛出异常 IllegalStateException。boolean offer(E e):将元素添加到队列尾部,如果队列满了,则返回 false。
1.1.2 删除元素
boolean remove(Object o):从队列中删除元素,成功返回true,失败返回falseE poll():检索并删除此队列的头部,如果此队列为空,则返回null。
1.1.3 查找元素
E element():检索但不删除此队列的头部,如果队列为空时则抛出 NoSuchElementException 异常;peek():检索但不删除此队列的头部,如果此队列为空,则返回 null.
除了从 Queue 接口 继承到一些方法,BlockingQueue 自身还定义了一些其他的方法,比如说插入操作:
void put(E e):将元素添加到队列尾部,如果队列满了,则线程将阻塞直到有空间。offer(E e, long timeout, TimeUnit unit):将指定的元素插入此队列中,如果队列满了,则等待指定的时间,直到队列可用。
比如说删除操作:
take():检索并删除此队列的头部,如有必要,则等待直到队列可用;poll(long timeout, TimeUnit unit):检索并删除此队列的头部,如果需要元素变得可用,则等待指定的等待时间。
1.2 ArrayBlockingQueue
- 有界:ArrayBlockingQueue 的大小是在构造时就确定了,并且在之后不能更改。这个界限提供了流量控制,有助于资源的合理使用。
- FIFO:队列操作符合先进先出的原则。
- 当队列容量满时,尝试将元素放入队列将导致阻塞;尝试从一个空的队列取出元素也会阻塞。
需要注意的是,ArrayBlockingQueue 并不能保证绝对的公平,所谓公平是指严格按照线程等待的绝对时间顺序,即最先等待的线程能够最先访问到 ArrayBlockingQueue。
这是因为还有其他系统级别的因素,如线程调度,可能会影响到实际的执行顺序。如果需要公平的 ArrayBlockingQueue,可在声明的时候设置公平标志为 true。
ArrayBlockingQueue 的字段如下:
- items: 这是一个用于存储队列元素的数组。队列的大小在构造时定义,并且在生命周期内不会改变。
- takeIndex: 这个索引用于下一个 take、poll、peek 或 remove 操作。它指向当前可被消费的元素位置。
- putIndex: 这个索引用于下一个 put、offer 或 add 操作。它指向新元素将被插入的位置。
- count: 这是队列中当前元素的数量。当达到数组大小时,进一步的 put 操作将被阻塞。
- lock: 这是用于保护队列访问的 ReentrantLock 对象。所有的访问和修改队列的操作都需要通过这个锁来同步。
- notEmpty: 这个条件 Condition 用于等待 take 操作。当队列为空时,尝试从队列中取元素的线程将等待这个条件。
- notFull: 这个条件 Condition 用于等待 put 操作。当队列已满时,尝试向队列中添加元素的线程将等待这个条件。
构造方法如下:
public ArrayBlockingQueue(int capacity, boolean fair) {
if (capacity <= 0)
throw new IllegalArgumentException();
this.items = new Object[capacity];
lock = new ReentrantLock(fair);
notEmpty = lock.newCondition();
notFull = lock.newCondition();
}
1.2.1 put 方法详解
put(E e)方法源码如下:
public void put(E e) throws InterruptedException {
// 确保传入的元素不为null
checkNotNull(e);
final ReentrantLock lock = this.lock;
// 请求锁,如果线程被中断则抛出异常
lock.lockInterruptibly();
try {
// 循环检查队列是否已满,如果满了则在notFull条件上等待
while (count == items.length) {
notFull.await();
}
// 队列未满,将元素加入队列
enqueue(e);
} finally {
// 在try块后释放锁,确保锁最终被释放
lock.unlock();
}
}
该方法的逻辑很简单,当队列已满时(count == items.length)将线程移入到 notFull 等待队列中,如果满足插入数据的条件,直接调用 enqueue(e)插入元素。
enqueue 方法的逻辑同样很简单,先插入数据(items[putIndex] = x),然后通知被阻塞的消费者线程:当前队列中有数据可供消费(notEmpty.signal())了。
1.2.2 take 方法详解
take 方法的源码如下:
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
//如果队列为空,没有数据,将消费者线程移入等待队列中
while (count == 0)
notEmpty.await();
//获取数据
return dequeue();
} finally {
lock.unlock();
}
}
- 如果当前队列为空的话,则将获取数据的消费者线程移入到等待队列中;
- 如果队列不为空则获取数据,即完成出队操作
dequeue。
dequeue 方法主要做了两件事情:
- 获取队列中的数据(
(E) items[takeIndex]); - 通知可能正在等待插入元素的生产者线程队列现在有可用空间,通过调用notFull 条件变量的 signal 方法实现。
从以上分析可以看出,put 和 take 方法主要通过 Condition 的通知机制来完成阻塞式的数据生产和消费。
1.3 LinkedBlockingQueue
LinkedBlockingQueue 是一个基于链表的线程安全的阻塞队列:
- 可以在队列头部和尾部进行高效的插入和删除操作。
- 当队列为空时,取操作会被阻塞,直到队列中有新的元素可用。当队列已满时,插入操作会被阻塞,直到队列有可用空间。
- 可以在构造时指定最大容量。如果不指定,默认为 Integer.MAX_VALUE,这意味着队列的大小受限于可用内存。
LinkedBlockingQueue 的字段如下:
- count: 一个 AtomicInteger,表示队列中当前元素的数量。通过原子操作保证其线程安全。
- head: 队列的头部节点。由于这是一个 FIFO 队列,所以元素总是从头部移除。头部节点的 item 字段始终为 null,它作为一个虚拟节点,用于帮助管理队列。
- last:队列的尾部节点。新元素总是插入到尾部。
- takeLock 和 putLock: 这是 LinkedBlockingQueue 中的两把 ReentrantLock 锁。takeLock 用于控制取操作,putLock 用于控制放入操作。这样的设计使得放入和取出操作能够在一定程度上并行执行,从而提高队列的吞吐量。
- notEmpty 和 notFull: 这是两个 Condition 变量,分别与 takeLock 和 putLock 相关联。当队列为空时,尝试从队列中取出元素的线程将会在 notEmpty 上等待。当新元素被放入队列时,这些等待的线程将会被唤醒。同样地,当队列已满时,尝试向队列中放入元素的线程将会在 notFull 上等待,等待队列有可用空间时被唤醒。
1.3.1 put 方法详解
put 方法源码如下:
/**
* 将指定元素插入队列。
* 如果队列已满,则阻塞当前线程,直到队列有空闲空间。
* @param e 要插入的元素
* @throws InterruptedException 如果当前线程在等待时被中断
*/
public void put(E e) throws InterruptedException {
// 如果元素为空,则抛出空指针异常
if (e == null) throw new NullPointerException();
// 初始化计数器为-1,表示操作尚未成功
int c = -1;
// 创建一个新节点,包含要插入的元素
Node<E> node = new Node<E>(e);
// 获取用于插入操作的锁
final ReentrantLock putLock = this.putLock;
// 获取队列计数器
final AtomicInteger count = this.count;
// 获取锁,可中断
putLock.lockInterruptibly();
try {
// 如果队列已满,则阻塞当前线程
while (count.get() == capacity) {
notFull.await();
}
// 将新节点插入队列
enqueue(node);
// 更新队列计数器,并获取更新前的值
c = count.getAndIncrement();
// 如果队列还有空闲空间,则唤醒等待的生产者线程
if (c + 1 < capacity)
notFull.signal();
} finally {
// 释放锁
putLock.unlock();
}
// 如果队列之前为空,现在插入了一个元素,则唤醒消费者线程
if (c == 0)
signalNotEmpty();
}
put 方法的逻辑基本上和 ArrayBlockingQueue 的一样。
01)参数检查:如果传入的元素为 null,则抛出 NullPointerException。LinkedBlockingQueue 不允许插入 null 元素。
02)局部变量初始化:
int c = -1;用于存储操作前的队列元素数量,预设为 -1 表示失败,除非稍后设置。Node<E> node = new Node<E>(e);创建一个新的节点包含要插入的元素 e。final ReentrantLock putLock = this.putLock;和final AtomicInteger count = this.count;获取队列的锁和计数器对象。
03)获取锁:putLock.lockInterruptibly(); 尝试获取用于插入操作的锁,如果线程被中断,则抛出 InterruptedException。
04)等待队列非满:如果队列已满(count.get() == capacity),当前线程将被阻塞,并等待 notFull 条件被满足。一旦有空间可用,线程将被唤醒继续执行。
05)入队操作:调用 enqueue(node); 将新节点插入队列的尾部。
06)更新计数:通过 c = count.getAndIncrement(); 获取并递增队列的元素计数。
07)检查并可能的唤醒其他生产者线程:如果队列没有满(c + 1 < capacity),使用 notFull.signal(); 唤醒可能正在等待插入空间的其他生产者线程。
08)释放锁:finally 块确保锁在操作完成后被释放。
09)可能的唤醒消费者线程:如果插入操作将队列从空变为非空(c == 0),则调用 signalNotEmpty(); 唤醒可能正在等待非空队列的消费者线程。
1.3.2 take 方法详解
take 方法的源码如下:
/**
* 从队列中获取元素。
* 如果队列为空,则阻塞当前线程,直到队列中有元素可取。
*
* @return 队列中的元素
* @throws InterruptedException 如果当前线程在等待时被中断
*/
public E take() throws InterruptedException {
E x; // 定义一个泛型变量,用于存储队列中的元素
int c = -1; // 定义一个变量,用于存储队列的计数
final AtomicInteger count = this.count; // 获取队列的计数器
final ReentrantLock takeLock = this.takeLock; // 获取取元素的锁
// 获取取元素操作的锁,并且响应中断
takeLock.lockInterruptibly();
try {
// 如果队列为空,则阻塞当前线程,直到队列非空
while (count.get() == 0) {
notEmpty.await();
}
// 从队列中移除队头元素,并赋值给x
x = dequeue();
// 获取队列当前的计数,并在计数器中减一
c = count.getAndDecrement();
// 如果队列中还有其他元素,则唤醒等待的消费者线程
if (c > 1) {
notEmpty.signal();
}
} finally {
// 释放取元素操作的锁
takeLock.unlock();
}
// 如果队列在取元素之前已满,则唤醒等待的生产者线程
if (c == capacity) {
signalNotFull();
}
// 返回队列中的元素
return x;
}
01)局部变量初始化:
E x;用于存储被取出的元素。int c = -1;用于存储操作前的队列元素数量,预设为 -1 表示失败,除非稍后设置。final AtomicInteger count = this.count;和final ReentrantLock takeLock = this.takeLock;获取队列的计数器和锁对象。
02)获取锁:takeLock.lockInterruptibly(); 尝试获取用于取出操作的锁,如果线程被中断,则抛出 InterruptedException。
03)等待队列非空:如果队列为空(count.get() == 0),当前线程将被阻塞,并等待 notEmpty 条件被满足。一旦队列非空,线程将被唤醒继续执行。
04)出队操作:调用 x = dequeue(); 从队列的头部移除元素,并将其赋值给 x。
05)更新计数:通过 c = count.getAndDecrement(); 获取并递减队列的元素计数。
06)检查并可能的唤醒其他消费者线程:如果队列仍有其他元素(c > 1),使用 notEmpty.signal(); 唤醒可能正在等待非空队列的其他消费者线程。
07)释放锁:finally 块确保锁在操作完成后被释放。
08)可能的唤醒生产者线程:如果取出操作将队列从满变为未满(c == capacity),则调用 signalNotFull(); 唤醒可能正在等待插入空间的生产者线程。
09)返回取出的元素:最后返回被取出的元素 x。
1.4 ArrayBlockingQueue 与 LinkedBlockingQueue 的比较
相同点:ArrayBlockingQueue 和 LinkedBlockingQueue 都是通过 Condition 通知机制来实现可阻塞的插入和删除。
不同点:
- ArrayBlockingQueue 基于数组实现,而 LinkedBlockingQueue 基于链表实现;
- ArrayBlockingQueue 使用一个单独的 ReentrantLock 来控制对队列的访问,而 LinkedBlockingQueue 使用两个锁(putLock 和 takeLock),一个用于放入操作,另一个用于取出操作。这可以提供更细粒度的控制,并可能减少线程之间的竞争。
1.5 PriorityBlockingQueue
PriorityBlockingQueue 是一个具有优先级排序特性的无界阻塞队列。元素在队列中的排序遵循自然排序或者通过提供的比较器进行定制排序。你可以通过实现 Comparable 接口来定义自然排序。
当需要根据优先级来执行任务时,PriorityBlockingQueue 会非常有用。
1.6 SynchronousQueue
SynchronousQueue 是一个非常特殊的阻塞队列,它不存储任何元素。每一个插入操作必须等待另一个线程的移除操作,反之亦然。因此,SynchronousQueue 的内部实际上是空的,但它允许一个线程向另一个线程逐个传输元素。
SynchronousQueue 允许线程直接将元素交付给另一个线程。因此,如果一个线程尝试插入一个元素,并且有另一个线程尝试移除一个元素,则插入和移除操作将同时成功。
如果想让一个线程将确切的信息直接发送给另一个线程的情况下,可以使用 SynchronousQueue。
1.7 LinkedTransferQueue
LinkedTransferQueue 是一个基于链表结构的无界传输队列,实现了 TransferQueue 接口,它提供了一种强大的线程间交流机制。它的功能与其他阻塞队列类似,但还包括“转移”语义:允许一个元素直接从生产者传输给消费者,如果消费者已经在等待。如果没有等待的消费者,元素将入队。
常用方法有两个:
transfer(E e),将元素转移到等待的消费者,如果不存在等待的消费者,则元素会入队并阻塞直到该元素被消费。tryTransfer(E e),尝试立即转移元素,如果有消费者正在等待,则传输成功;否则,返回 false。
如果想要更紧密地控制生产者和消费者之间的交互,可以使用 LinkedTransferQueue。
1.8 inkedBlockingDeque
LinkedBlockingDeque 是一个基于链表结构的双端阻塞队列。它同时支持从队列头部插入和移除元素,也支持从队列尾部插入和移除元素。因此,LinkedBlockingDeque 可以作为 FIFO 队列或 LIFO 队列来使用。
常用方法有:
addFirst(E e),addLast(E e): 在队列的开头/结尾添加元素。takeFirst(),takeLast(): 从队列的开头/结尾移除和返回元素,如果队列为空,则等待。putFirst(E e),putLast(E e): 在队列的开头/结尾插入元素,如果队列已满,则等待。pollFirst(long timeout, TimeUnit unit),pollLast(long timeout, TimeUnit unit): 在队列的开头/结尾移除和返回元素,如果队列为空,则等待指定的超时时间。
1.9 DelayQueue
DelayQueue 是一个无界阻塞队列,用于存放实现了 Delayed 接口的元素,这些元素只能在其到期时才能从队列中取走。这使得 DelayQueue 成为实现时间基于优先级的调度服务的理想选择。
2、CopyOnWriteArrayList
ArrayList 是一个线程不安全的容器,如果在多线程环境下使用,需要手动加锁,或者使用 Collections.synchronizedList() 方法将其转换为线程安全的容器。
否则,将会出现 ConcurrentModificationException 异常。
于是,Doug Lea 大师为我们提供了一个并发版本的 ArrayList——CopyOnWriteArrayList。
CopyOnWriteArrayList 是线程安全的,可以在多线程环境下使用。CopyOnWriteArrayList 遵循写时复制的原则,每当对列表进行修改(例如添加、删除或更改元素)时,都会创建列表的一个新副本,这个新副本会替换旧的列表,而对旧列表的所有读取操作仍然可以继续。
由于在修改时创建了新的副本,所以读取操作不需要锁定。这使得在多读取者和少写入者的情况下读取操作非常高效。当然,由于每次写操作都会创建一个新的数组副本,所以会增加存储和时间的开销。如果写操作非常频繁,性能会受到影响。
2.1 什么是 CopyOnWrite
大家应该还记得读写锁 ReentrantReadWriteLock 吧?读写锁是通过读写分离的思想来实现的,即读写锁将读写操作分别加锁,从而实现读写操作的并发执行。
但是,读写锁也存在一些问题,比如说在写锁执行后,读线程会被阻塞,直到写锁被释放后读线程才有机会获取到锁从而读到最新的数据,站在读线程的角度来看,读线程在任何时候都能获取到最新的数据,满足数据实时性。
而 CopyOnWriteArrayList 是通过 Copy-On-Write(COW),即写时复制的思想来通过延时更新的策略实现数据的最终一致性,并且能够保证读线程间不阻塞。当然,这要牺牲数据的实时性。
通俗的讲,CopyOnWrite 就是当我们往一个容器添加元素的时候,不直接往容器中添加,而是先复制出一个新的容器,然后在新的容器里添加元素,添加完之后,再将原容器的引用指向新的容器。多个线程在读的时候,不需要加锁,因为当前容器不会添加任何元素。
我们在介绍并发容器的时候,也曾提到过,相信大家都还有印象。
2.2 CopyOnWriteArrayList原理
OK,接下来我们来看一下 CopyOnWriteArrayList 的源码。顾名思义,实际上 CopyOnWriteArrayList 内部维护的就是一个数组:
/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;
该数组被 volatile 修饰,能够保证数据的内存可见性。
2.2.1 get 方法
get 方法的源码如下:
/**
* 根据指定的索引获取元素。
* @param index 要获取元素的索引
* @return 指定索引处的元素
*/
public E get(int index) {
return get(getArray(), index);
}
/**
* 获取数组。该方法非私有,以便CopyOnWriteArraySet类也能访问。
* @return 当前数组的副本
*/
final Object[] getArray() {
return array;
}
/**
* 根据指定的数组和索引获取元素。
* @param a 包含元素的数组
* @param index 要获取元素的索引
* @return 指定索引处的元素
*/
private E get(Object[] a, int index) {
return (E) a[index];
}
get 方法的实现非常简单,几乎就是一个“单线程”,没有添加任何的线程安全控制,没有加锁也没有 CAS 操作,原因就是所有的读线程只会读取容器中的数据,并不会进行修改。
2.2.2 add 方法
add 方法的源码如下:
/**
* 向数组中添加一个元素。
* @param e 要添加的元素
* @return 添加成功返回true,否则返回false
*/
public boolean add(E e) {
// 使用重入锁,确保同一时刻只有一个线程可以写入
final ReentrantLock lock = this.lock;
lock.lock();
try {
// 获取当前数组的引用
Object[] elements = getArray();
int len = elements.length;
// 创建一个新的数组,大小为原数组长度加1
// 并将原数组的数据复制到新数组中
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 在新数组的末尾添加新元素
newElements[len] = e;
// 更新数组引用,使其指向新的数组
setArray(newElements);
return true;
} finally {
// 释放锁,以便其他线程可以获取锁
lock.unlock();
}
}
add 方法的逻辑也比较容易理解,需要注意这么几点:
01、采用 ReentrantLock保证同一时刻只有一个写线程正在进行数组的复制;
02、通过调用 getArray() 方法获取旧的数组。
final Object[] getArray() {
return array;
}
03、然后创建一个新的数组,把旧的数组复制过来,然后在新的数组中添加数据,再将新的数组赋值给旧的数组引用。
final void setArray(Object[] a) {
array = a;
}
根据 volatile 的 happens-before 规则,所以这个更改对所有线程是立即可见的。
04、最后,在 finally 块中释放锁,以便其他线程可以访问和修改列表。
2.3 CopyOnWriteArrayList 的使用
CopyOnWriteArrayList 的使用非常简单,和 ArrayList 的使用几乎一样,只是在创建对象的时候需要使用 CopyOnWriteArrayList 的构造方法,如下所示:
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("element1");
list.add("element2");
for (String element : list) {
System.out.println(element);
}
2.4 CopyOnWriteArrayList 的缺点
CopyOnWrite 容器有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性问题。所以在开发的时候需要特别注意。
- 内存占用问题:因为 CopyOnWrite 的写时复制机制,在进行写操作的时候,内存里会同时有两个对象,旧的对象和新写入的对象,分析 add 方法的时候大家都看到了。
如果这些对象占用的内存比较大,比如说 200M 左右,那么再写入 100M 数据进去,内存就会占用 600M,那么这时候就会造成频繁的 minor GC 和 major GC。
- 数据一致性问题:CopyOnWrite 容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用 CopyOnWrite 容器,最好通过 ReentrantReadWriteLock自定义一个的列表。
我们来比较一下 CopyOnWrite 和读写锁。
相同点:
- 两者都是通过读写分离的思想来实现的;
- 读线程间是互不阻塞的
不同点:
为了实现数据实时性,在写锁被获取后,读线程会阻塞;或者当读锁被获取后,写线程会阻塞,从而解决“脏读”的问题。而 CopyOnWrite 对数据的更新是写时复制的,因此读线程是延时感知的,不会存在阻塞的情况。
假设 COW 的变化如下图所示:
数组中已有数据 1,2,3,现在写线程想往数组中添加数据 4,我们在第 5 行处打上断点,让写线程暂停。
此时,读线程依然会“不受影响”的从数组中读取数据,可是还是只能读到 1,2,3。
如果读线程能够立即读到新添加的数据就叫数据实时性。当对第 5 行的断点放开后,读线程感知到了数据的变化,所以读到了完整的数据 1,2,3,4,这叫数据最终一致性,尽管有可能中间间隔了好几秒才感知到。
二、小林-图解系统-进程管理
1、线程进程崩溃
正常情况下,操作系统为了保证系统安全,所以针对非法内存访问会发送一个 SIGSEGV 信号,而操作系统一般会调用默认的信号处理函数(一般会让相关的进程崩溃)。
但如果进程觉得"罪不致死",那么它也可以选择自定义一个信号处理函数,这样的话它就可以做一些自定义的逻辑,比如记录 crash 信息等有意义的事。
回过头来看为什么虚拟机会针对 StackoverflowError 和 NullPointerException 做额外处理让线程恢复呢,针对 stackoverflow 其实它采用了一种栈回溯的方法保证线程可以一直执行下去,而捕获空指针错误主要是这个错误实在太普遍了。
1.1线程崩溃,进程一定会崩溃吗
一般来说如果线程是因为非法访问内存引起的崩溃,那么进程肯定会崩溃,为什么系统要让进程崩溃呢,这主要是因为在进程中,各个线程的地址空间是共享的,既然是共享,那么某个线程对地址的非法访问就会导致内存的不确定性,进而可能会影响到其他线程,这种操作是危险的,操作系统会认为这很可能导致一系列严重的后果,于是干脆让整个进程崩溃
线程共享代码段,数据段,地址空间,文件非法访问内存有以下几种情况,我们以 C 语言举例来看看。
1.、针对只读内存写入数据
#include <stdio.h>
#include <stdlib.h>
int main() {
char *s = "hello world";
// 向只读内存写入数据,崩溃
s[1] = 'H';
}
2、访问了进程没有权限访问的地址空间(比如内核空间)
#include <stdio.h>
#include <stdlib.h>
int main() {
int *p = (int *)0xC0000fff;
// 针对进程的内核空间写入数据,崩溃
*p = 10;
}
在 32 位虚拟地址空间中,p 指向的是内核空间,显然不具有写入权限,所以上述赋值操作会导致崩溃
3、访问了不存在的内存,比如:
#include <stdio.h>
#include <stdlib.h>
int main() {
int *a = NULL;
*a = 1;
}
以上错误都是访问内存时的错误,所以统一会报 Segment Fault 错误(即段错误),这些都会导致进程崩溃
1.2 进程是如何崩溃的-信号机制简介
那么线程崩溃后,进程是如何崩溃的呢,这背后的机制到底是怎样的,答案是信号。
大家想想要干掉一个正在运行的进程是不是经常用 kill -9 pid 这样的命令,这里的 kill 其实就是给指定 pid 发送终止信号的意思,其中的 9 就是信号。
那么发个信号进程怎么就崩溃了呢,这背后的原理到底是怎样的?
其背后的机制如下
- CPU 执行正常的进程指令
- 调用 kill系统调用向进程发送信号
- 进程收到操作系统发的信号,CPU 暂停当前程序运行,并将控制权转交给操作系统
- 调用 kill 系统调用向进程发送信号(假设为 11,即 SIGSEGV,一般非法访问内存报的都是这个错误)
- 操作系统根据情况执行相应的信号处理程序(函数),一般执行完信号处理程序逻辑后会让进程退出
注意上面的第五步,如果进程没有注册自己的信号处理函数,那么操作系统会执行默认的信号处理程序(一般最后会让进程退出),但如果注册了,则会执行自己的信号处理函数,这样的话就给了进程一个垂死挣扎的机会,它收到 kill 信号后,可以调用 exit() 来退出,但也可以使用 sigsetjmp,siglongjmp 这两个函数来恢复进程的执行
说到这大家是否想起了一道经典面试题:如何让正在运行的 Java 工程的优雅停机?
通过上面的介绍大家不难发现,其实是 JVM 自己定义了信号处理函数,这样当发送 kill pid 命令(默认会传 15 也就是 SIGTERM)后,JVM 就可以在信号处理函数中执行一些资源清理之后再调用 exit 退出。
这种场景显然不能用 kill -9,不然一下把进程干掉了资源就来不及清除了。
1.3 为什么线程崩溃不会导致 JVM 进程崩溃
现在我们再来看看开头这个问题,相信你多少会心中有数,想想看在 Java 中有哪些是常见的由于非法访问内存而产生的 Exception 或 error 呢,常见的是大家熟悉的 StackoverflowError 或者 NPE(NullPointerException),NPE 我们都了解,属于是访问了不存在的内存。
但为什么栈溢出(Stackoverflow)也属于非法访问内存呢,这得简单聊一下进程的虚拟空间,也就是前面提到的共享地址空间。
现代操作系统为了保护进程之间不受影响,所以使用了虚拟地址空间来隔离进程,进程的寻址都是针对虚拟地址,每个进程的虚拟空间都是不一样的,而线程会共用进程的地址空间。
以 32 位虚拟空间,进程的虚拟空间分布如下:
那么 stackoverflow 是怎么发生的呢?
进程每调用一个函数,都会分配一个栈桢,然后在栈桢里会分配函数里定义的各种局部变量。
假设现在调用了一个无限递归的函数,那就会持续分配栈帧,但 stack 的大小是有限的(Linux 中默认为 8 M,可以通过 ulimit -a 查看),如果无限递归很快栈就会分配完了,此时再调用函数试图分配超出栈的大小内存,就会发生段错误,也就是 stackoverflowError。
好了,现在我们知道了 StackoverflowError 怎么产生的。
那问题来了,既然 StackoverflowError 或者 NPE 都属于非法访问内存, JVM 为什么不会崩溃呢?
有了上一节的铺垫,相信你不难回答,其实就是因为 JVM 自定义了自己的信号处理函数,拦截了 SIGSEGV 信号,针对这两者不让它们崩溃。
怎么证明这个推测呢,我们来看下 JVM 的源码来一探究竟
1.4 openJDK 源码解析
HotSpot 虚拟机目前使用范围最广的 Java 虚拟机。
我们只要研究 Linux 下的 JVM。
可以看到,在启动 JVM 的时候,也设置了信号处理函数,收到 SIGSEGV,SIGPIPE 等信号后最终会调用 JVM_handle_linux_signal 这个自定义信号处理函数。
- 发生 stackoverflow 还有空指针错误,确实都发送了 SIGSEGV,只是虚拟机不选择退出,而是自己内部作了额外的处理,其实是恢复了线程的执行,并抛出 StackoverflowError 和 NPE,这就是为什么 JVM 不会崩溃且我们能捕获这两个错误/异常的原因
- 如果针对 SIGSEGV 等信号,在以上的函数中 JVM 没有做额外的处理,那么最终会走到 report_and_die 这个方法,这个方法主要做的事情是生成 hs_err_pid_xxx.log crash 文件(记录了一些堆栈信息或错误),然后退出
至此我相信大家明白了为什么发生了 StackoverflowError 和 NPE 这两个非法访问内存的错误,JVM 却没有崩溃。
原因其实就是虚拟机内部定义了信号处理函数,而在信号处理函数中对这两者做了额外的处理以让 JVM 不崩溃,另一方面也可以看出如果 JVM 不对信号做额外的处理,最后会自己退出并产生 crash 文件 hs_err_pid_xxx.log,这个文件记录了虚拟机崩溃的重要原因。