JAVA基础

Java基础面试

说说进程和线程的区别?

进程是程序的一次执行,是系统进行资源分配和调度的独立单位。线程是比进程更小的能独立运行的单位,基本不拥有系统资源,只有少量的必要资源,比如程序计数器、寄存器和栈,进程则占有堆、栈。

知道synchronized原理吗?

synchronized是java提供的原子性内置锁,这种内置的并且使用者看不到的锁也被称为监视器锁。在编译之后在同步的代码块前后加上monitorentermonitorexit字节码指令,他依赖操作系统底层互斥锁实现。执行monitorenter指令时会尝试获取对象锁,取到锁的计数器+1,其他竞争锁的线程则会进入等待队列中。执行monitorexit的时候,计数器-1。锁释放,处于等待队列中的线程再继续竞争锁。

那锁的优化机制了解吗?

JDK 1.6前,synchronized使用的是重量级锁。1.6后进行了优化,存在锁升级。无锁->偏向锁->轻量级锁->重量级锁

https://blog.csdn.net/zzti_erlie/article/details/103997713

图片

那对象头具体都包含哪些内容?

图片

对象包括对象头、实例数据、对齐填充。对像头包括两部分,(mark word、对象和锁信息)、对象类型指针,如果是数组,还包含数组长度。

对于加锁,那再说下ReentrantLock原理?他和synchronized有什么区别?
CAS的原理呢?

CAS叫做CompareAndSwap,比较并交换,主要是通过处理器的指令来保证操作的原子性。只有当V等于A(旧值)时,才会用B去更新V的值,否则就不会执行更新操作。

那么CAS有什么缺点吗?

ABA问题循环时间长开销大(自旋CAS长时间不成功,对cpu消耗大)只能保证一个共享变量的原子操作

好,说说HashMap原理吧?

HashMap主要由数组和链表组成,他不是线程安全的。核心的点就是put插入数据的过程,get查询数据以及扩容的方式。JDK1.7和1.8的主要区别在于头插和尾插方式的修改,头插容易导致HashMap链表死循环,并且1.8之后加入红黑树对性能有提升。

put插入数据流程:key hash,进行位运算(等同于取模),找到数组中对应位置。如果数组中没有元素直接存入,反之则判断key是否相同,key相同就覆盖,否则就会插入到链表的尾部,如果链表的长度超过8,则会转换成红黑树。最后判断数组长度是否超过默认的长度*负载因子也就是12,超过则进行扩容。

get查询数据:首先计算出hash值,然后去数组查询,是红黑树就去红黑树查,链表就遍历链表查询就可以了。

resize扩容过程:扩容的过程就是对key重新计算hash,然后把数据拷贝到新的数组。

那多线程环境怎么使用Map呢?ConcurrentHashmap了解过吗?

ConcurrentHashmap在JDK1.7和1.8的版本改动比较大,1.7使用Segment+HashEntry分段锁的方式实现,1.8则抛弃了Segment,改为使用CAS+synchronized+Node实现,同样也加入了红黑树,避免链表过长导致性能的问题。

1.7版本

1.7版本的ConcurrentHashMap采用分段锁机制,里面包含一个Segment数组,Segment继承于ReentrantLock,Segment则包含HashEntry的数组,HashEntry本身就是一个链表的结构,具有保存key、value的能力能指向下一个节点的指针。默认的Segment长度是16,也就是支持16个线程的并发写,Segment之间相互不会受到影响。

put流程和HashMap非常类似,只不过是先定位到具体的Segment,然后通过ReentrantLock去操作而已。使用ReentrantLock加锁,如果获取锁失败则尝试自旋,自旋超过次数就阻塞获取,保证一定获取锁成功。

get流程也一样,先定位到Segment,需要注意的是value是volatile的,所以get是不需要加锁的

1.8版本

1.8抛弃分段锁,转为用CAS+synchronized来实现,同样HashEntry改为Node,也加入了红黑树的实现。

img

put写入

  1. 首先计算hash,遍历node数组,如果node是空的话,就通过CAS+自旋的方式初始化
  2. 如果当前数组位置是空则直接通过CAS自旋写入数据
  3. 如果hash==MOVED,说明需要扩容,执行扩容
  4. 如果都不满足,就使用synchronized写入数据,写入数据同样判断链表、红黑树,链表写入和HashMap的方式一样,key hash一样就覆盖,反之就尾插法,链表长度超过8就转换成红黑树
volatile原理知道吗?

相比synchronized的加锁方式来解决共享变量的内存可见性问题,volatile就是更轻量的选择,他没有上下文切换的额外开销成本。使用volatile声明的变量,可以确保值被更新的时候对其他线程立刻可见。volatile使用内存屏障来保证不会发生指令重排,解决了内存可见性的问题。

两级缓存的双核CPU架构,包含L1、L2两级缓存(例子展开)。线程都是从主内存中读取共享变量到工作内存来操作,完成之后再把结果写回主内存。如果X变量用volatile修饰的话,当线程A再次读取变量X的话,CPU就会根据缓存一致性协议强制线程A重新从主内存加载最新的值到自己的工作内存,而不是直接用缓存中的值。

内存屏障没看明白

那么说说你对JMM内存模型的理解?为什么需要JMM?

cpu引入高速缓存,多核的存在,其缓存值可能跟主内存不一致。所以加入了缓存一致性协议,同时导致了内存可见性的问题,而编译器和CPU的重排序导致了原子性和有序性的问题,JMM内存模型正是对多线程操作下的一系列规范约束。通过JMM我们才屏蔽了不同硬件和操作系统内存的访问差异,这样保证了Java程序在不同的平台下达到一致的内存访问效果,同时也是保证在高效并发的时候程序能够正确执行。待加固

说了半天,到底工作内存和主内存是什么?

主内存可以认为就是物理内存,Java内存模型中实际就是虚拟机内存的一部分。而工作内存就是CPU缓存,他有可能是寄存器也有可能是L1\L2\L3缓存,都是有可能的。

说说ThreadLocal原理?

ThreadLocal可以理解为线程本地变量,他会在每个线程都创建一个副本,那么在线程之间访问内部副本变量就行了,做到了线程之间互相隔离,相比于synchronized的做法是用空间来换时间。

会存在内存泄露的问题,在使用完之后调用remove方法删除Entry对象,实际上是不会出现这个问题的。

那引用类型有哪些?有什么区别?

强软弱虚四种

  1. 强引用指的就是代码中普遍存在的赋值方式,比如A a = new A()这种。强引用关联的对象,永远不会被GC回收。
  2. 软引用可以用SoftReference来描述,指的是那些有用但是不是必须要的对象。系统在发生内存溢出前会对这类引用的对象进行回收。
  3. 弱引用可以用WeakReference来描述,他的强度比软引用更低一点,弱引用的对象下一次GC的时候一定会被回收,而不管内存是否足够。
  4. 虚引用也被称作幻影引用,是最弱的引用关系,可以用PhantomReference来描述,他必须和ReferenceQueue一起使用,同样的当发生GC的时候,虚引用也会被回收。可以用虚引用来管理堆外内存。
线程池原理知道吗?当提交一个新任务到线程池时,具体的执行流程如下:
  1. 当我们提交任务,线程池会根据corePoolSize大小创建若干任务数量线程执行任务
  2. 当任务的数量超过corePoolSize数量,后续的任务将会进入阻塞队列阻塞排队
  3. 当阻塞队列也满了之后,那么将会继续创建(maximumPoolSize-corePoolSize)个数量的线程来执行任务,如果任务处理完成,maximumPoolSize-corePoolSize额外创建的线程等待keepAliveTime之后被自动销毁
  4. 如果达到maximumPoolSize,阻塞队列还是满的状态,那么将根据不同的拒绝策略对应处理
拒绝策略有哪些?

主要有4种拒绝策略:

  1. AbortPolicy:直接丢弃任务,抛出异常,这是默认策略
  2. CallerRunsPolicy:只用调用者所在的线程来处理任务
  3. DiscardOldestPolicy:丢弃等待队列中最旧的任务,并执行当前任务
  4. DiscardPolicy:直接丢弃任务,也不抛出异常

零拷贝

为什么要有 DMA 技术?

DMA 技术,也就是直接内存访问(Direct Memory Access) 技术。在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务

无DMA前,CPU需要参与数据搬运工工作,如下图:

图片

有DMA后,这部分工作交给DMA。DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 可以执行其他任务

图片

早期 DRM 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里面都有自己的 DMA 控制器。

传统的文件传输有多糟糕?

场景:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。

图片

共4次拷贝,2次DMA拷贝,2次CPU拷贝。同时伴随着冗余的上下文切换(用户态和内核态)

  • 第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。
  • 第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。
  • 第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。
  • 第四次拷贝,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。
如何优化文件传输的性能?

要发生上下文切换,这是因为用户空间没有权限操作磁盘或网卡,内核的权限最高,这些操作设备的过程都需要交由操作系统内核来完成

减少用户态与内核态的上下文切换次数,减少拷贝次数。

如何实现零拷贝?

零拷贝技术实现的方式通常有 2 种:

  • mmap + write
  • sendfile
mmap + write

read() 系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap() 替换 read() 系统调用函数。

buf = mmap(file, len);
write(sockfd, buf, len);

mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。

图片

通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程。4 次上下文切换,3次拷贝。

sendfile
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

Linux 内核版本 2.1中加入sendfile()。它可以替代前面的 read()write() 这两个系统调用,这样就可以减少一次系统调用。2次上下文切换,3次拷贝。

图片

这还不是真正的零拷贝技术,如果网卡支持 SG-DMA,我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。由网卡的 SG-DMA 控制器完成,直接将内核缓存中的数据拷贝到网卡的缓冲区里,不需要拷贝到socket缓冲区。从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术:

图片

这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。

零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。

使用零拷贝技术的项目

Kafka文件传输的代码,调用Java NIO 库里的 transferTo方法,如果 Linux 系统支持 sendfile() 系统调用,那么 transferTo() 实际上最后就会使用到 sendfile() 系统调用函数。

Nginx 也支持零拷贝技术,一般默认是开启零拷贝技术,这样有利于提高文件传输的效率。

PageCache 有什么作用?

文件传输过程,其中第一步都是先需要先把磁盘文件数据拷贝「内核缓冲区」里,这个「内核缓冲区」实际上是磁盘高速缓存(PageCache)。通常,刚被访问的数据在短时间内再次被访问的概率很高,于是我们可以用 PageCache 来缓存最近被访问的数据,当空间不足时淘汰最久未被访问的缓存。

假设 read 方法每次只会读 32 KB 的字节,虽然 read 刚开始只会读 0 ~ 32 KB 的字节,但内核会把其后面的 32~64 KB 也读取到 PageCache,这样后面读取 32~64 KB 的成本就很低,如果在 32~64 KB 淘汰出 PageCache 前,进程读取到它了,收益就非常大。

PageCache 的优点主要是两个:

  • 缓存最近被访问的数据;
  • 预读功能;

在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,那就白白浪费 DRM 多做的一次数据拷贝,造成性能的降低,即使使用了 PageCache 的零拷贝也会损失性能。

  • PageCache 由于长时间被大文件占据,其他「热点」的小文件可能就无法充分使用到 PageCache,于是这样磁盘读写的性能就会下降了;
  • PageCache 中的大文件数据,由于没有享受到缓存带来的好处,但却耗费 DMA 多拷贝到 PageCache 一次;
大文件传输用什么方式实现?

read 方法读取文件时,进程实际上会阻塞在 read 方法调用,因为要等待磁盘数据的返回。此时数据会先从磁盘控制器缓冲区拷贝到PageCache,然后再从PageCache拷贝到用户缓冲区。

图片

对于阻塞的问题,可以用异步 I/O 来解决。异步 I/O 并没有涉及到 PageCache,数据从磁盘控制器缓冲区直接拷贝到用户缓冲区,所以使用异步 I/O 就意味着要绕开 PageCache。

图片

绕开 PageCache 的 I/O 叫直接 I/O,使用 PageCache 的 I/O 则叫缓存 I/O。通常,对于磁盘,异步 I/O 只支持直接 I/O。在高并发的场景下,针对大文件的传输的方式,应该使用「异步 I/O + 直接 I/O」来替代零拷贝技术

直接 I/O 应用场景常见的两种:

  • 应用程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存,减少额外的性能损耗。在 MySQL 数据库中,可以通过参数设置开启直接 I/O,默认是不开启;
  • 传输大文件的时候,由于大文件难以命中 PageCache 缓存,而且会占满 PageCache 导致「热点」文件无法充分利用缓存,从而增大了性能开销,因此,这时应该使用直接 I/O。

由于直接 I/O 绕过了 PageCache,就无法享受内核的这两点的优化:

  • 内核的 I/O 调度算法会缓存尽可能多的 I/O 请求在 PageCache 中,最后「合并」成一个更大的 I/O 请求再发给磁盘,这样做是为了减少磁盘的寻址操作;
  • 内核也会「预读」后续的 I/O 请求放在 PageCache 中,一样是为了减少对磁盘的操作;

所以,传输文件的时候,我们要根据文件的大小来使用不同的方式:

  • 传输大文件的时候,使用「异步 I/O + 直接 I/O」;
  • 传输小文件的时候,则使用「零拷贝技术」;

在 nginx 中,我们可以用如下配置,来根据文件的大小来使用不同的方式:

location /video/ { 
    sendfile on; 
    aio on; 
    directio 1024m; 
}

当文件大小大于 directio 值后,使用「异步 I/O + 直接 I/O」,否则使用「零拷贝技术」。

总结

早期 I/O 操作,内存与磁盘的数据传输的工作都是由 CPU 完成的,而此时 CPU 不能执行其他任务,会特别浪费 CPU 资源。

于是,为了解决这一问题,DMA 技术就出现了,每个 I/O 设备都有自己的 DMA 控制器,通过这个 DMA 控制器,CPU 只需要告诉 DMA 控制器,我们要传输什么数据,从哪里来,到哪里去,就可以放心离开了。后续的实际数据传输工作,都会由 DMA 控制器来完成,CPU 不需要参与数据传输的工作。

传统 IO 的工作方式,从硬盘读取数据,然后再通过网卡向外发送,我们需要进行 4 上下文切换,和 4 次数据拷贝,其中 2 次数据拷贝发生在内存里的缓冲区和对应的硬件设备之间,这个是由 DMA 完成,另外 2 次则发生在内核态和用户态之间,这个数据搬移工作是由 CPU 完成的。

为了提高文件传输的性能,于是就出现了零拷贝技术,它通过一次系统调用(sendfile 方法)合并了磁盘读取与网络发送两个操作,降低了上下文切换次数。另外,拷贝数据都是发生在内核中的,天然就降低了数据拷贝的次数。

Kafka 和 Nginx 都有实现零拷贝技术,这将大大提高文件传输的性能。

零拷贝技术是基于 PageCache 的,PageCache 会缓存最近访问的数据,提升了访问缓存数据的性能,同时,为了解决机械硬盘寻址慢的问题,它还协助 I/O 调度算法实现了 IO 合并与预读,这也是顺序读比随机读性能好的原因。这些优势,进一步提升了零拷贝的性能。

需要注意的是,零拷贝技术是不允许进程对文件内容作进一步的加工的,比如压缩数据再发送。

另外,当传输大文件时,不能使用零拷贝,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,并且大文件的缓存命中率不高,这时就需要使用「异步 IO + 直接 IO 」的方式。

在 Nginx 里,可以通过配置,设定一个文件大小阈值,针对大文件使用异步 IO 和直接 IO,而对小文件使用零拷贝。

一次相亲经历,我彻底搞懂了什么叫阻塞非阻塞,同步异步

  • 阻塞/非阻塞:描述的是调用者调用方法后的状态,比如:线程A调用了B方法,A线程处于阻塞状态。
  • 同步/异步:描述的方法跟调用者间通信的方式,如果不需要调用者主动等待,调用者调用后立即返回,然后方法本身通过回调,消息通知等方式通知调用者结果,就是异步的。如果调用方法后一直需要调用者一直等待方法返回结果,那么就是同步的

我们要知道一次IO的过程必然会有三个角色的参与

  1. 应用程序
  2. 内核
  3. 数据

以一次数据的读取为例,应用程序是没有办法直接操纵硬件设备的,只有通过内核才能跟硬件交互。当网卡接收到数据之后,此时数据在网卡中,需要内核将网卡中的数据读取到内核空间中,再从内核空间拷贝到用户空间,这个时候应用程序才拿到数据,读取数据结束。

同步阻塞:应用程序发起了一个IO请求(以读取数据为例),此时需要进行一次系统调用,内核由用户态切换到内核态,内核开始跟硬件设备进行交互并从硬件设备中读取数据,此时可能硬件设备还没有接收到数据,所以内核函数一直阻塞,直到数据到达才进行返回。这就是同步阻塞模型。

同步非阻塞:当应用程序发起了一次读取数据的请求,还是会发起系统调用,但是此时内核根据硬件中是否有数据执行不同的操作,如果有数据,那么将数据拷贝到用户空间,如果没有数据也会返回一个标志,比如-1,应用程序在轮询期间并没有一直阻塞,而是可以进行执行。这就是同步非阻塞。

异步非阻塞:应用程序只需要发起一次读取数据的请求,接下来就等着内核将数据拷贝到用户空间,并且内核将数据拷贝完成后会通知应用程序(如回调),在整个过程中程序可以继续往下执行。

没有异步阻塞一说。

大厂面试Linux就这5个问题

1.CPU负载和CPU利用率的区别是什么?

CPU负载

我们可以通过uptimew或者top命令看到CPU的平均负载。他代表的是当前系统正在运行的和处于等待运行的进程数之和。也指的是处于可运行状态不可中断状态的平均进程数。

如果单核CPU的话,负载达到1就代表CPU已经达到满负荷的状态了,超过1,后面的进行就需要排队等待处理了。如果是是多核多CPU的话,假设现在服务器是2个CPU,每个CPU2个核,那么总负载不超过4都没什么问题。

CPU 利用率

和负载不同,CPU利用率指的是当前正在运行的进程实时占用CPU的百分比,他是对一段时间内CPU使用状况的统计。

例子

假设你们公司厕所有1个坑位,有一个人占了坑位,这时候负载就是1,如果还有一个人在排队,那么负载就是2。如果在1个小时内,A上厕所花了10分钟,B上厕所花了20分钟,剩下30分钟厕所都没人使用,那么这一个小时内利用率就是50%。

2.那如果CPU负载很高,利用率却很低该怎么办?

CPU负载很高,利用率却很低,说明处于等待状态的任务很多,负载越高,代表可能很多僵死的进程。通常这种情况是IO密集型的任务,大量请求在请求相同的IO,导致任务队列堆积。

3.那如果负载很低,利用率却很高呢?

这表示CPU的任务并不多,但是任务执行的时间很长,大概率就是你写的代码本身有问题,通常是计算密集型任务生成了大量耗时短的计算任务

直接top命令找到使用率最高的任务,定位到去看看就行了。

4.那如果CPU使用率达到100%呢?怎么排查?
  1. 通过top找到占用率高的进程。
  2. 通过top -Hp pid找到占用CPU高的线程ID。这里找到958的线程ID
  3. 再把线程ID转化为16进制,printf "0x%x\n" 958,得到线程ID0x3be
  4. 通过命令jstack 163 | grep '0x3be' -C5 --color 或者 jstack 163|vim +/0x3be - 找到有问题的代码

总结来说,就是通过top命令找到占用率高的进程和对应线程,再通过jstack找到有问题的代码。

5.说说常见的Linux命令吧?

常用的文件、目录命令

ls:用户查看目录下的文件。ls -a可以用来查看隐藏文件,ls -l可以用于查看文件的详细信息,包括权限、大小、所有者等信息。

touch:用于创建文件。如果文件不存在,则创建一个新的文件,如果文件已存在,则会修改文件的时间戳。

cat:cat是英文concatenate的缩写,用于查看文件内容。使用cat查看文件的话,不管文件的内容有多少,都会一次性显示,所以他不适合查看太大的文件。

tail:可能是平时用的最多的命令了,查看日志文件基本靠他了。一般用户tail -fn 100 xx.log查看最后的100行内容

常用的权限命令

chmod:修改权限命令。chmod 777 文件名这就是最高权限了。

chown:用于修改文件和目录的所有者和所属组。一般用法chown user 文件用于修改文件所有者,chown user:user 文件修改文件所有者和组,冒号前面是所有者,后面是组。

常用的压缩命令

zip、upzip、tar、gzip等

带宽、延时、吞吐率、PPS 这些都是啥?

Linux 网络协议栈是根据 TCP/IP 模型来实现的,TCP/IP 模型由应用层、传输层、网络层和网络接口层,共四层组成。应用程序要发送数据包时,通常是通过 socket 接口,于是就会发生系统调用,把应用层的数据拷贝到内核里的 socket 层,接着由网络协议栈从上到下逐层处理后,最后才会送到网卡发送出去。接收时反向处理。

图片

性能指标有哪些?

通常是以 4 个指标来衡量网络的性能,分别是带宽、延时、吞吐率、PPS(Packet Per Second)。

  • 带宽,表示链路的最大传输速率,单位是 b/s (比特 / 秒)。
  • 延时,表示请求数据包发送后,收到对端响应,所需要的时间延迟。不同的场景有着不同的含义,比如可以表示建立 TCP 连接所需的时间延迟,或一个数据包往返所需的时间延迟。
  • 吞吐率,表示单位时间内成功传输的数据量,吞吐受带宽限制。
  • PPS,全称是 Packet Per Second(包 / 秒),表示以网络包为单位的传输速率,一般用来评估系统对于网络的转发能力。

其它性能指标:

  • 网络的可用性,表示网络能否正常通信;
  • 并发连接数,表示 TCP 连接数量;
  • 丢包率,表示所丢失数据包数量占所发送数据组的比率;
  • 重传率,表示重传网络包的比例;
网络配置如何看?

ifconfig和ip。这两个命令功能都差不多,不过它们属于不同的软件包,ifconfig 属于 net-tools 软件包,ip 属于 iproute2 软件包。

socket 信息如何查看?

我们可以使用 netstat 或者 ss,这两个命令查看 socket、网络协议栈、网口以及路由表的信息。如果频繁使用 netstat 命令则会对性能的开销雪上加霜,所以更推荐你使用性能更好的 ss 命令。

网络吞吐率和 PPS 如何查看?

可以使用 sar 命令当前网络的吞吐率和 PPS

连通性和延时如何查看?

要测试本机与远程主机的连通性和延时,通常是使用 ping 命令,它是基于 ICMP 协议的,工作在网络层。需要注意的是,ping 不通服务器并不代表 HTTP 请求也不通,因为有的服务器的防火墙是会禁用 ICMP 协议的。

进程和线程基础知识全家桶,30 张图一套带走

前言

几个基本概念:进程、操作系统、cpu、调度、时间片、线程。

进程

我们编写的代码只是一个存储在硬盘的静态文件,通过编译后就会生成二进制可执行文件,当我们运行这个可执行文件后,它会被装载到内存中,接着 CPU 会执行程序中的每一条指令,那么这个运行中的程序,就被称为「进程」

并发:单核上交替运行。并行:多核上同时运行。

进程的状态
  • 运行状态(Runing):该时刻进程占用 CPU;
  • 就绪状态(Ready):可运行,但因为其他进程正在运行而暂停停止;
  • 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行;

当然,进程另外两个基本状态:

  • 创建状态(new):进程正在被创建时的状态;
  • 结束状态(Exit):进程正在从系统中消失时的状态;

图片

另外,还有一个状态叫挂起状态,它表示进程没有占有内存空间。

由于虚拟内存管理原因,进程的所使用的空间可能并没有映射到物理内存,而是在硬盘上,这时进程就会出现挂起状态。挂起状态可以分为两种:

  • 阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现;
  • 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行;

图片

进程的控制结构

在操作系统中,是用进程控制块process control block,PCB)数据结构来描述进程的。

PCB 是进程存在的唯一标识,里面包含了进程的各种信息,包括:进程标识符、用户标识符、进程当前状态、进程优先级、资源分配清单、CPU 相关信息(各寄存器值、程序计数器等)。

PCB通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。如就绪队列阻塞队列。运行队列在单核 CPU 系统中则只有一个运行指针了,因为单核 CPU 在某个时间,只能运行一个程序。

图片

除了链接的组织方式,还有索引方式,不过链表居多,便于插入和删除。

进程的控制

我们熟知了进程的状态变迁和进程的数据结构 PCB 后,再来看看进程的创建、终止、阻塞、唤醒的过程,这些过程也就是进程的控制。

操作系统允许一个进程创建另一个进程,而且允许子进程继承父进程所拥有的资源,当子进程被终止时,其在父进程处继承的资源应当还给父进程。同时,终止父进程时同时也会终止其所有的子进程。

创建进程,为新进程分配一个唯一的进程标识号,并申请一个空白的 PCB。分配资源,条件允许则进入就绪队列等待被调度。

终止进程,3 种终止方式:正常结束、异常结束以及外界干预(信号 kill 掉)。执行状态直接停止运行。有子进程,则应将其所有子进程终止。将该进程所拥有的全部资源都归还给父进程或操作系统。删除PCB。

阻塞进程,如果该进程为运行状态,则保护其现场,将其状态转为阻塞状态,停止运行;将该 PCB 插入的阻塞队列中去。一旦被阻塞等待,它只能由另一个进程唤醒。

唤醒进程,阻塞队列中找到相应进程的 PCB;将其从阻塞队列中移出,并置其状态为就绪状态;把该 PCB 插入到就绪队列中,等待调度程序调度。

进程的上下文切换

一个进程切换到另一个进程运行,称为进程的上下文切换。

任务是交给 CPU 运行的,那么在每个任务运行前,CPU 需要知道任务从哪里加载,又从哪里开始运行。操作系统需要事先帮 CPU 设置好 CPU 寄存器和程序计数器CPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。CPU 上下文切换分成:进程上下文切换、线程上下文切换和中断上下文切换

进程是由内核管理和调度的,所以进程的切换只能发生在内核态。**进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。**通常,会把交换的信息保存在进程的 PCB,切换时取出对应和保存对应的PCB即可。

进程切换场景:

  • 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,就会被系统挂起,切换到其它正在等待 CPU 的进程运行;
  • 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行;
  • 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度;
  • 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;
  • 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序;
线程

在早期的操作系统中都是以进程作为独立运行的基本单位,直到后面,计算机科学家们又提出了更小的能独立运行的基本单位,也就是线程

为什么使用线程?

进程创建和切换开销大,而且进程间通信也麻烦。需要新的实体(thread)来解决。

什么是线程?

线程是进程当中的一条执行流程。同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程都有独立一套的寄存器和栈,这样可以确保线程的控制流是相对独立的。

线程与进程的比较
  • 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位;
  • 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;
  • 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;
  • 线程能减少并发执行的时间和空间开销;

线程相比进程能减少开销,体现在:

  • 线程的创建和销毁快,涉及资源少
  • 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;
  • 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了;
线程的上下文切换

线程与进程最大的区别在于:线程是调度的基本单位,而进程则是资源拥有的基本单位

所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。

线程上下文切换,这还得看线程是不是属于同一个进程:

  • 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样;
  • 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据
线程的实现

主要有三种线程的实现方式:

  • 用户线程(User Thread):在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理;
  • 内核线程(Kernel Thread):在内核中实现的线程,是由内核管理的线程;
  • 轻量级进程(LightWeight Process):在内核中来支持用户线程;

用户线程是基于用户态的线程管理库来实现的,那么线程控制块(*Thread Control Block, TCB*) 也是在库里面来实现的,对于操作系统而言是看不到这个 TCB 的,它只能看到整个进程的 PCB。用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等。用户级线程的模型,也就类似前面提到的多对一的关系。

内核线程是由操作系统管理的,线程对应的 TCB 自然是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责。内核线程的模型,也就类似前面提到的一对一

**轻量级进程(*Light-weight process,LWP*)是内核支持的用户线程,一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持。**在大多数系统中,LWP与普通进程的区别也在于它只有一个最小的执行上下文和调度程序所需的统计信息。用户线程和LWP的对应关系有3种,1:1、N:1、N:M。

调度
调度时机

如果硬件时钟提供某个频率的周期性中断,那么可以根据如何处理时钟中断,把调度算法分为两类:

  • 非抢占式调度算法挑选一个进程,然后让该进程运行直到被阻塞,或者直到该进程退出,才会调用另外一个进程,也就是说不会理时钟中断这个事情。
  • 抢占式调度算法挑选一个进程,然后让该进程只运行某段时间,如果在该时段结束时,该进程仍然在运行时,则会把它挂起,接着调度程序从就绪队列挑选另外一个进程。这种抢占式调度处理,需要在时间间隔的末端发生时钟中断,以便把 CPU 控制返回给调度程序进行调度,也就是常说的时间片机制
调度原则

针对上面的五种调度原则,总结成如下:

  • CPU 利用率:调度程序应确保 CPU 是始终匆忙的状态,这可提高 CPU 的利用率;
  • 系统吞吐量:吞吐量表示的是单位时间内 CPU 完成进程的数量,长作业的进程会占用较长的 CPU 资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量;
  • 周转时间:周转时间是进程运行和阻塞时间总和,一个进程的周转时间越小越好;
  • 等待时间:这个等待时间不是阻塞状态的时间,而是进程处于就绪队列的时间,等待的时间越长,用户越不满意;
  • 响应时间:用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的主要标准。
调度算法

先来先服务(First Come First Severd, FCFS)算法,每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。

最短作业优先(Shortest Job First, SJF)调度算法同样也是顾名思义,它会优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量。

高响应比优先 (Highest Response Ratio Next, HRRN)调度算法,每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行。优先权=(等待时间+要求服务时间)/要求服务时间,权衡了短作业和长作业。

时间片轮转(Round Robin, RR)调度算法,每个进程被分配一个时间段,称为时间片(Quantum),即允许该进程在该时间段中运行。最古老、最简单、最公平,但需要时间片长短的设置,通常时间片设为 20ms~50ms

最高优先级(Highest Priority First,HPF)调度算法,从就绪队列中选择最高优先级的进程进行运行。

多级反馈队列(Multilevel Feedback Queue)调度算法,是「时间片轮转算法」和「最高优先级算法」的综合和发展。顾名思义:

  • 「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。
  • 「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列;

对于短作业可能可以在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也会更长了,所以该算法很好的兼顾了长短作业,同时有较好的响应时间。

20 张图揭开内存管理的迷雾

虚拟内存

如果两个程序都引用了绝对物理地址,那有可能会出现问题。为了将进程所使用的地址隔离开来,操作系统引入了虚拟内存。**操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。**这里就引出了两种地址的概念:

  • 我们程序所使用的内存地址叫做虚拟内存地址Virtual Memory Address
  • 实际存在硬件里面的空间地址叫物理内存地址Physical Memory Address)。

进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存。即CPU–>虚拟地址–>MMU–>物理地址。

操作系统对虚拟地址和物理地址的管理方式,主要有两种:内存分段和内存分页

内存分段

分段机制下的虚拟地址由两部分组成,段选择子段内偏移量。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。

知道了虚拟地址是通过段表与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个段,分别是代码、数据、堆、栈等。每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址。

分段会带来两个问题:

  • 第一个就是内存碎片的问题。
  • 第二个就是内存交换的效率低的问题。

比如1G的数据,分拆512m,128m,256m,剩余128m。这时候128m释放,产生2个128m空余地址,如果要使用246m地址段,由于不连续,无法满足。这就是内存碎片。另外,程序所有内存都被装载到物理内存,而有部分数据可能不常用,这也会造成内存的浪费

解决内存碎片的方法就是内存交换,把256m数据刷入硬盘,然后再刷回内存,产生连续的空间。但是硬盘读写是比较慢的,效率低。如果遇上大内存,则可能造成卡顿。

内存分页

分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫Page)。在 Linux 下,每一页的大小为 4KB

虚拟地址与物理地址之间通过页表来映射,页表实际上存储在 CPU 的内存管理单元MMU) 中,于是 CPU 就可以直接通过 MMU,找出要实际要访问的物理内存地址。而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。

图片

分页是以页为单位释放内存的,不会产生无法给内存使用的小内存。同时跟硬盘指间的换入(swap in)换出(swap out)效率也高,不像内存分段那样有大量的数据需要置换。另外,程序在运行时不需要把所有数据都加载到物理内存中,在用到的时候再加载即可。总结一下,对于一个内存地址转换,其实就是这样三个步骤:

  • 把虚拟内存地址,切分成页号和偏移量;
  • 根据页号,从页表里面,查询对应的物理页号;
  • 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。

页表一定要覆盖全部虚拟地址空间,简单页面映射,每个进程的页表都比较大,比如4GB内存,假设每页4KB,共100万页,每页4字节,共需要4M。如果100个进程,则需要400M。这时候就有了多级页表

多级页表

还是上面例子,比如2级页面。已上面例子来看,将页表(一级页表)分为 1024 个页表(二级页表),每个表(二级页表)中包含 1024 个「页表项」,形成二级分页如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表

假设一个进程用到了20%的一级页表,那么页表占用的内存空间就只有 4KB(一级页表) + 20% * 4MB(二级页表)= 0.804MB,这对比单级页表的 4MB 是不是一个巨大的节约。

页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。

对于 64 位的系统,两级分页肯定不够了,就变成了四级目录,分别是:

  • 全局页目录项 PGD(Page Global Directory);
  • 上层页目录项 PUD(Page Upper Directory);
  • 中间页目录项 PMD(Page Middle Directory);
  • 页表项 PTE(Page Table Entry);

类似于2级,不赘述。

TLB

多级页表虽然节省了空间,但由于多了几层转换,还降低虚拟地址到物理地址的转换速度。其实是时间换空间。但程序是有局限性的,有一部分空间是经常会被访问的,而大部分是不常访问的。于是科学家们就在CPU中加入了最常访问的页表项Cache ,也就是TLB,通常称为页表缓存、转址旁路缓存、快表等。

在 CPU 芯片里面,封装了内存管理单元(Memory Management Unit,MMU)芯片,它用来完成地址转换和 TLB 的访问与交互。有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。TLB 的命中率其实是很高的。

段页式内存管理

内存分段和内存分页并不是对立的,它们是可以组合起来在同一个系统中使用的,那么组合起来后,通常称为段页式内存管理

段页式内存管理实现的方式:

  • 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制;代码段、数据段、栈段
  • 接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页;

这样,地址结构就由段号、段内页号和页内位移三部分组成。

段页式地址变换中要得到物理地址须经过三次内存访问:

  • 第一次访问段表,得到页表起始地址;
  • 第二次访问页表,得到物理页号;
  • 第三次将物理页号与页内位移组合,得到物理地址。

可用软、硬件相结合的方法实现段页式地址变换,这样虽然增加了硬件成本和系统开销,但提高了内存的利用率。

Linux 内存管理

x86的历史原因,早期适用的内存分段管理,后来在完善内存分段的同时新增了内存分页。但是x86内存分页的设计没有绕开段式管理,而是基于段式设计的。页式内存管理的作用是在由段式内存管理所映射而成的的地址上再加上一层地址映射。段式内存管理先将逻辑地址映射成线性地址(虚拟地址),然后再由页式内存管理将线性地址映射成物理地址。Linux 内存主要采用的是页式内存管理,但同时也不可避免地涉及了段机制

Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。

在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分。

图片

通过这里可以看出:

  • 32 位系统的内核空间占用 1G,位于最高处,剩下的 3G 是用户空间;
  • 64 位系统的内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。

再来说说,内核空间与用户空间的区别:

  • 进程在用户态时,只能访问用户空间内存;
  • 只有进入内核态后,才可以访问内核空间的内存;

虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。如图,用户空间隔离,内核空间共享。

图片

Linxu 系统中虚拟空间分布可分为用户态内核态两部分,其中用户态的分布:代码段、全局变量、BSS、函数栈、堆内存、映射区。

总总结结(重要)

为了在多进程环境下,使得进程之间的内存地址不受影响,相互隔离,于是操作系统就为每个进程独立分配一套的虚拟地址空间,每个程序只关心自己的虚拟地址就可以,实际上大家的虚拟地址都是一样的,但分布到物理地址内存是不一样的。作为程序,也不用关心物理地址的事情。

每个进程都有自己的虚拟空间,而物理内存只有一个,所以当启用了大量的进程,物理内存必然会很紧张,于是操作系统会通过内存交换技术,把不常使用的内存暂时存放到硬盘(换出),在需要的时候再装载回物理内存(换入)。

那既然有了虚拟地址空间,那必然要把虚拟地址「映射」到物理地址,这个事情通常由操作系统来维护。

那么对于虚拟地址与物理地址的映射关系,可以有分段分页的方式,同时两者结合都是可以的。

内存分段是根据程序的逻辑角度,分成了栈段、堆段、数据段、代码段等,这样可以分离出不同属性的段,同时是一块连续的空间。但是每个段的大小都不是统一的,这就会导致内存碎片和内存交换效率低的问题。

于是,就出现了内存分页,把虚拟空间和物理空间分成大小固定的页,如在 Linux 系统中,每一页的大小为 4KB。由于分了页后,就不会产生细小的内存碎片。

同时在内存交换的时候,写入硬盘也就一个页或几个页,这就大大提高了内存交换的效率。

再来,为了解决简单分页产生的页表过大的问题,就有了多级页表,它解决了空间上的问题,但这就会导致 CPU 在寻址的过程中,需要有很多层表参与,加大了时间上的开销。

于是根据程序的局部性原理,在 CPU 芯片中加入了 TLB,负责缓存最近常被访问的页表项,大大提高了地址的转换速度。

Linux 系统主要采用了分页管理,但是由于 Intel 处理器的发展史,Linux 系统无法避免分段管理。于是 Linux 就把所有段的基地址设为 0,也就意味着所有程序的地址空间都是线性地址空间(虚拟地址),相当于屏蔽了 CPU 逻辑地址的概念,所以段只被用于访问控制和内存保护。

另外,Linxu 系统中虚拟空间分布可分为用户态内核态两部分,其中用户态的分布:代码段、全局变量、BSS、函数栈、堆内存、映射区。

看完这篇操作系统,和面试官扯皮就没问题了。

解释一下什么是操作系统

操作系统是运行在计算机上最重要的一种软件,它管理计算机的资源和进程以及所有的硬件和软件。它为计算机硬件和软件提供了一种中间层。

  • 管理计算机资源,这些资源包括 CPU、内存、磁盘驱动器、打印机等。
  • 提供一种图形界面,就像我们前面描述的那样,它提供了用户和计算机之间的桥梁。
  • 为其他软件提供服务,操作系统与软件进行交互,以便为其分配运行所需的任何必要资源。
什么是按需分页

在操作系统中,进程是以页为单位加载到内存中的,按需分页是一种虚拟内存的管理方式。在使用请求分页的系统中,只有在尝试访问页面所在的磁盘并且该页面尚未在内存中时,也就发生了缺页异常,操作系统才会将磁盘页面复制到内存中。

什么是内核

在计算机中,内核是一个计算机程序,它是操作系统的核心,可以控制操作系统中所有的内容。内核通常是在 boot loader 装载程序之前加载的第一个程序。

boot loader 又被称为引导加载程序,它是一个程序,能够将计算机的操作系统放入内存中。在电源通电或者计算机重启时,BIOS 会执行一些初始测试,然后将控制权转移到引导加载程序所在的主引导记录(MBR) 。

什么是进程和进程表

进程就是正在执行程序的实例,操作系统为了跟踪每个进程的活动状态,维护了一个进程表。在进程表的内部,列出了每个进程的状态以及每个进程使用的资源等。

页面置换算法都有哪些
算法注释
最优算法不可实现,但可以用作基准
NRU(最近未使用) 算法和 LRU 算法很相似
FIFO(先进先出) 算法有可能会抛弃重要的页面
第二次机会算法比 FIFO 有较大的改善
时钟算法实际使用
LRU(最近最少)算法比较优秀,但是很难实现
NFU(最不经常使用)算法和 LRU 很类似
老化算法近似 LRU 的高效算法
工作集算法实施起来开销很大
工作集时钟算法比较有效的算法
  • 老化 算法是一种更接近 LRU 算法的实现,并且可以更好的实现,因此是一个很好的选择
  • 最后两种算法都使用了工作集算法。工作集算法提供了合理的性能开销,但是它的实现比较复杂。WSClock 是另外一种变体,它不仅能够提供良好的性能,而且可以高效地实现。

**最好的算法是老化算法和 WSClock 算法。**他们分别是基于 LRU 和工作集算法。他们都具有良好的性能并且能够被有效的实现。还存在其他一些好的算法,但实际上这两个可能是最重要的。

什么是僵尸进程

僵尸进程是已完成且处于终止状态,但在进程表中却仍然存在的进程。僵尸进程通常发生在父子关系的进程中,由于父进程仍需要读取其子进程的退出状态所造成的。

进程间通信

管道

Linux 一切皆文件的理念。管道一端写入另一端读出。管道这种通信方式效率低,不适合进程间频繁地交换数据

命名管道也被称为FIFO文件,它是一种特殊类型的文件,它在文件系统中以文件名的形式存在,但是它的行为却和之前所讲的没有名字的管道(匿名管道)类似。

匿名管道的创建,需要通过下面这个系统调用:

int pipe(int fd[2])

这里表示创建一个匿名管道,并返回了两个描述符,一个是管道的读取端描述符 fd[0],另一个是管道的写入端描述符 fd[1]。注意,这个匿名管道是特殊的文件只存在于内存,不存于文件系统中。所谓的管道,就是内核里面的一串缓存。这两个描述符都是在一个进程里面,并没有起到进程间通信的作用。

我们可以使用 fork 创建子进程,创建的子进程会复制父进程的文件描述符,这样就做到了两个进程各有两个「 fd[0]fd[1]」,两个进程就可以通过各自的 fd 写入和读取同一个管道文件实现跨进程通信了。为了防止混乱,可以进行如下操作:

  • 父进程关闭读取的 fd[0],只保留写入的 fd[1];
  • 子进程关闭写入的 fd[1],只保留读取的 fd[0];

如果需要双向通信,则需要建立2个管道。

在 shell 里面执行 A | B命令的时候,A 进程和 B 进程都是 shell 创建出来的子进程,A 和 B 之间不存在父子关系,它俩的父进程都是 shell。AB进程进行通信时,同样需要进行关闭读写fd的操作。

我们可以得知,对于匿名管道,它的通信范围是存在父子关系的进程。因为管道没有实体,也就是没有管道文件,只能通过 fork 来复制父进程 fd 文件描述符,来达到通信的目的。

另外,对于命名管道,它可以在不相关的进程间也能相互通信。因为命令管道,提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。

不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。

消息队列

消息队列是保存在内核中的消息链表,消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。

消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在,而前面提到的匿名管道的生命周期,是随进程的创建而建立,随进程的结束而销毁。

消息队列不适合比较大数据的传输,有单消息体大小限制和所有队列的总长度限制(可设置)。消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销

共享内存

现代操作系统,对于内存管理,采用的是虚拟内存技术,也就是每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中。共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。

信号量

共享内存带来的一个问题时,多进程容易同时修改同一块内存空间。为了解决这个问题,引入了信号量的保护机制。信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据

互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。

同步信号量,进程 A 是负责生产数据,而进程 B 是负责读取数据,它可以保证进程 A 应在进程 B 之前执行。

信号

上面说的进程间通信,都是常规状态下的工作模式。对于异常情况下的工作模式,就需要用「信号」的方式来通知进程。

信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式。

1.执行默认操作。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思。Core 的意思是 Core Dump,也即终止进程后,通过 Core Dump 将当前进程的运行状态保存在文件里面,方便程序员事后进行分析问题在哪里。

2.捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。

3.忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILLSEGSTOP,它们用于在任何时候中断或结束某一进程。

Socket

前面提到的管道、消息队列、共享内存、信号量和信号都是在同一台主机上进行进程间通信,那要想跨网络与不同主机上的进程之间通信,就需要 Socket 通信了。

针对 TCP 协议通信的 socket 编程模型

图片

  • 服务端和客户端初始化 socket,得到文件描述符;
  • 服务端调用 bind,将绑定在 IP 地址和端口;
  • 服务端调用 listen,进行监听;
  • 服务端调用 accept,等待客户端连接;
  • 客户端调用 connect,向服务器端的地址和端口发起连接请求;
  • 服务端 accept 返回用于传输的 socket 的文件描述符;
  • 客户端调用 write 写入数据;服务端调用 read 读取数据;
  • 客户端断开连接时,会调用 close,那么服务端 read 读取数据的时候,就会读取到了 EOF,待处理完数据后,服务端调用 close,表示连接关闭。

这里需要注意的是,服务端调用 accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。

所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket

针对 UDP 协议通信的 socket 编程模型

UDP 是没有连接的,所以不需要三次握手,也就不需要像 TCP 调用 listen 和 connect,但是 UDP 的交互仍然需要 IP 地址和端口号,因此也需要 bind。

针对本地进程间通信的 socket 编程模型

本地 socket 被用于在同一台主机上进程间通信的场景。本地字节流 socket 和 本地数据报 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是绑定一个本地文件,这也就是它们之间的最大区别。

总结

由于每个进程的用户空间都是独立的,不能相互访问,这时就需要借助内核空间来实现进程间通信,原因很简单,每个进程都是共享一个内核空间。

Linux 内核提供了不少进程间通信的方式,其中最简单的方式就是管道,管道分为「匿名管道」和「命名管道」。

匿名管道顾名思义,它没有名字标识,匿名管道是特殊文件只存在于内存,没有存在于文件系统中,shell 命令中的「|」竖线就是匿名管道,通信的数据是无格式的流并且大小受限,通信的方式是单向的,数据只能在一个方向上流动,如果要双向通信,需要创建两个管道,再来匿名管道是只能用于存在父子关系的进程间通信,匿名管道的生命周期随着进程创建而建立,随着进程终止而消失。

命名管道突破了匿名管道只能在亲缘关系进程间的通信限制,因为使用命名管道的前提,需要在文件系统创建一个类型为 p 的设备文件,那么毫无关系的进程就可以通过这个设备文件进行通信。另外,不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。

消息队列克服了管道通信的数据是无格式的字节流的问题,消息队列实际上是保存在内核的「消息链表」,消息队列的消息体是可以用户自定义的数据类型,发送数据时,会被分成一个一个独立的消息体,当然接收数据时,也要与发送方发送的消息体的数据类型保持一致,这样才能保证读取的数据是正确的。消息队列通信的速度不是最及时的,毕竟每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程。

共享内存可以解决消息队列通信中用户态与内核态之间数据拷贝过程带来的开销,它直接分配一个共享空间,每个进程都可以直接访问,就像访问进程自己的空间一样快捷方便,不需要陷入内核态或者系统调用,大大提高了通信的速度,享有最快的进程间通信方式之名。但是便捷高效的共享内存通信,带来新的问题,多进程竞争同个共享资源会造成数据的错乱。

那么,就需要信号量来保护共享资源,以确保任何时刻只能有一个进程访问共享资源,这种方式就是互斥访问。信号量不仅可以实现访问的互斥性,还可以实现进程间的同步,信号量其实是一个计数器,表示的是资源个数,其值可以通过两个原子操作来控制,分别是 P 操作和 V 操作

与信号量名字很相似的叫信号,它俩名字虽然相似,但功能一点儿都不一样。信号是进程间通信机制中唯一的异步通信机制,信号可以在应用进程和内核之间直接交互,内核也可以利用信号来通知用户空间的进程发生了哪些系统事件,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令),一旦有信号发生,进程有三种方式响应信号 1. 执行默认操作、2. 捕捉信号、3. 忽略信号。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILLSEGSTOP,这是为了方便我们能在任何时候结束或停止某个进程。

前面说到的通信机制,都是工作于同一台主机,如果要与不同主机的进程间通信,那么就需要 Socket 通信了。Socket 实际上不仅用于不同的主机进程间通信,还可以用于本地主机进程间通信,可根据创建 Socket 的类型不同,分为三种常见的通信方式,一个是基于 TCP 协议的通信方式,一个是基于 UDP 协议的通信方式,一个是本地进程间通信方式。

以上,就是进程间通信的主要机制了。你可能会问了,那线程通信间的方式呢?

同个进程下的线程之间都是共享进程的资源,只要是共享变量都可以做到线程间通信,比如全局变量,所以对于线程间关注的不是通信方式,而是关注多线程竞争共享资源的问题,信号量也同样可以在线程间实现互斥与同步:

  • 互斥的方式,可保证任意时刻只有一个线程访问共享资源;
  • 同步的方式,可保证线程 A 应在线程 B 之前执行;

进程之间究竟有哪些通信方式?

同上总结

键盘敲入 A 字母时,操作系统期间发生了什么…

设备控制器

为了屏蔽设备之间的差异,每个设备都有一个叫设备控制器(Device Control) 的组件,比如硬盘有硬盘控制器、显示器有视频控制器等。设备控制器里有芯片,它可执行自己的逻辑,也有自己的寄存器,用来与 CPU 进行通信。CPU通过读写寄存器来控制设备或者获取设备状态、数据等。

实际上,控制器是有三类寄存器,它们分别是状态寄存器(Status Register)命令寄存器(Command Register)以及数据寄存器(Data Register),另外,块设备的控制器还有数据缓冲区,比如键盘、硬盘等。字符型的没有,比如鼠标。

那 CPU 是如何与设备的控制寄存器和数据缓冲区进行通信的?存在两个方法:

  • 端口 I/O,每个控制寄存器被分配一个 I/O 端口,可以通过特殊的汇编指令操作这些寄存器,比如 in/out 类似的指令。
  • 内存映射 I/O,将所有控制寄存器映射到内存空间中,这样就可以像读写内存一样读写数据缓冲区。
I/O 控制方式

写入的时候,通过写入寄存器来进行。在设备准备好数据后,会通知中断控制器。中断控制器发送中断请求给CPU,CPU收到中断信号,需要停下手里的事来处理中断事件。

中断有两种,一种软中断,例如代码调用 INT 指令触发,一种是硬件中断,就是硬件通过中断控制器触发的。

CPU需要频繁接收中断信号,参与数据搬运的工作,效率极低。引入DMA控制器,可以解放CPU,将数据从硬件搬运到内核空间,然后DMA控制器发送中断给CPU进行后续处理。

设备驱动程序

设备驱动程序主要用来屏蔽设备控制器的差异,设备驱动程序会提供统一的接口给操作系统。**中断处理程序就在设备驱动程序里。**一个中断处理大概如下:

  1. 在 I/O 时,设备控制器如果已经准备好数据,则会通过中断控制器向 CPU 发送中断请求;
  2. 保护被中断进程的 CPU 上下文;
  3. 转入相应的设备中断处理函数;
  4. 进行中断处理;
  5. 恢复被中断进程的上下文;
通用块层

对于块设备,为了减少不同块设备的差异带来的影响,Linux 通过一个统一的通用块层,来管理不同的块设备。通用块层是处于文件系统和磁盘驱动中间的一个块设备抽象层。

键盘敲入字母时,期间发生了什么?

那当用户输入了键盘字符,键盘控制器就会产生扫描码数据,并将其缓冲在键盘控制器的寄存器中,紧接着键盘控制器通过总线给 CPU 发送中断请求

CPU 收到中断请求后,操作系统会保存被中断进程的 CPU 上下文,然后调用键盘的中断处理程序

键盘的中断处理程序是在键盘驱动程序初始化时注册的,那键盘中断处理函数的功能就是从键盘控制器的寄存器的缓冲区读取扫描码,再根据扫描码找到用户在键盘输入的字符,如果输入的字符是显示字符,那就会把扫描码翻译成对应显示字符的 ASCII 码,比如用户在键盘输入的是字母 A,是显示字符,于是就会把扫描码翻译成 A 字符的 ASCII 码。

得到了显示字符的 ASCII 码后,就会把 ASCII 码放到「读缓冲区队列」,接下来就是要把显示字符显示屏幕了,显示设备的驱动程序会定时从「读缓冲区队列」读取数据放到「写缓冲区队列」,最后把「写缓冲区队列」的数据一个一个写入到显示设备的控制器的寄存器中的数据缓冲区,最后将这些数据显示在屏幕里。

显示出结果后,恢复被中断进程的上下文

一口气搞懂「文件系统」,就靠这 25 张图了

文件系统的基本组成

Linux 最经典的一句话是:「一切皆文件」,不仅普通的文件和目录,就连块设备、管道、socket 等,也都是统一交给文件系统管理的。

Linux 文件系统会为每个文件分配两个数据结构:索引节点(index node)和目录项(directory entry)

  • 索引节点,也就是 inode,是文件的唯一标识,用来记录文件的元信息,比如 inode 编号、文件大小、访问权限、创建时间、修改时间、数据在磁盘的位置等。存储在硬盘中,同样占用磁盘空间

  • 目录项,也就是 dentry,用来记录文件的名字、索引节点指针以及与其他目录项的层级关联关系。多个目录项关联起来,就会形成目录结构,但它与索引节点不同的是,目录项是由内核维护的一个数据结构,不存放于磁盘,而是缓存在内存

目录也是文件,也是用索引节点唯一标识,和普通文件不同的是,普通文件在磁盘里面保存的是文件数据,而目录文件在磁盘里面保存子目录或文件。

目录是个文件,持久化存储在磁盘,而目录项是内核一个数据结构,缓存在内存。内核会把已经读过的目录用目录项这个数据结构缓存在内存,下次再次读到相同的目录时,只需从内存读就可以,大大提高了文件系统的效率。

磁盘读写的最小单位是扇区,扇区的大小只有 512B 大小。文件系统把多个扇区组成了一个逻辑块,每次读写的最小单位就是逻辑块(数据块),Linux 中的逻辑块大小为 4KB,也就是一次性读写 8 个扇区。

图片

另外,磁盘进行格式化的时候,会被分成三个存储区域,分别是超级块、索引节点区和数据块区。

  • 超级块,用来存储文件系统的详细信息,比如块个数、块大小、空闲块等等。
  • 索引节点区,用来存储索引节点;
  • 数据块区,用来存储文件或目录数据;

我们不可能把超级块和索引节点区全部加载到内存,这样内存肯定撑不住,所以只有当需要使用的时候,才将其加载进内存,它们加载进内存的时机是不同的:

  • 超级块:当文件系统挂载时进入内存;
  • 索引节点区:当文件被访问时进入内存;
虚拟文件系统

虚拟文件系统(Virtual File System,VFS),用户层与文件系统层引入了中间层,操作系统希望对用户提供一个统一的接口。VFS 定义了一组所有文件系统都支持的数据结构和标准接口。

图片

文件的使用

图片

fd = open(name, flag); # 打开文件
...
write(fd,...);         # 写数据
...
close(fd);             # 关闭文件

先open,拿到文件描述符fd,然后使用fd写入数据,最后关闭。

操作系统为每个进程维护一个打开文件表,文件表里的每一项代表「文件描述符」,所以说文件描述符是打开文件的标识。

用户和操作系统对文件的读写操作是有差异的,用户习惯以字节的方式读写文件,而操作系统则是以数据块来读写文件,那屏蔽掉这种差异的工作就是文件系统了。我们来分别看一下,读文件和写文件的过程:

  • 当用户进程从文件读取 1 个字节大小的数据时,文件系统则需要获取字节所在的数据块,再返回数据块对应的用户进程所需的数据部分。
  • 当用户进程把 1 个字节大小的数据写进文件时,文件系统则找到需要写入数据的数据块的位置,然后修改数据块中对应的部分,最后再把数据块写回磁盘。

所以说,文件系统的基本操作单位是数据块

文件的存储

两种:连续空间存放方式、非连续空间存放方式。

连续空间存放方式

文件存放在磁盘「连续的」物理空间中,读写效率很高。文件头里需要指定「起始块的位置」和「长度」。读写效率高,但是有**「磁盘空间碎片」和「文件长度不易扩展」的缺陷**。

非连续空间存放方式

链表的方式存放是离散的,不用连续的,于是就可以消除磁盘碎片,可大大提高磁盘空间的利用率,同时文件的长度可以动态扩展。根据实现的方式的不同,链表可分为「隐式链表」和「显式链接」两种形式。

隐式链表,实现的方式是文件头要包含「第一块」和「最后一块」的位置,并且每个数据块里面留出一个指针空间,用来存放下一个数据块的位置。隐式链表的存放方式的缺点在于无法直接访问数据块,只能通过指针顺序访问文件,以及数据块指针消耗了一定的存储空间。隐式链接分配的稳定性较差,系统在运行过程中由于软件或者硬件错误导致链表中的指针丢失或损坏,会导致文件数据的丢失。

显式链接把用于链接文件各数据块的指针,显式地存放在内存的一张链接表中,该表在整个磁盘仅设置一张,每个表项中存放链接指针,指向下一个数据块号内存中的这样一个表格称为文件分配表(File Allocation Table,FAT)。查找在内存中进行,效率高,而且减少了磁盘访问次数。缺点是不适合打文件的存储。对于 200GB 的磁盘和 1KB 大小的块,这张表需要有 2 亿项,每一项对应于这 2 亿个磁盘块中的一个块,每项如果需要 4 个字节,那这张表要占用 800MB 内存,很显然 FAT 方案对于大磁盘而言不太合适。

索引的实现是为每个文件创建一个「索引数据块」,里面存放的是指向文件数据块的指针列表文件头需要包含指向「索引数据块」的指针,这样就可以通过文件头知道索引数据块的位置,再通过索引数据块里的索引信息找到对应的数据块。引数据也是存放在磁盘块的,如果文件很小,明明只需一块就可以存放的下,但还是需要额外分配一块来存放索引数据,所以缺陷之一就是存储索引带来的开销。

链式索引块在索引数据块留出一个存放下一个索引数据块的指针,用来处理大文件,文件大到一个索引数据块放不下索引信息。

多级索引块通过一个索引块来存放多个索引数据块

Unix 文件的实现方式

空闲空间管理

几种常见的方法:

  • 空闲表法
  • 空闲链表法
  • 位图法

空闲表法

为所有空闲空间建立一张表,表内容包括空闲区的第一个块号和该空闲区的块个数,注意,这个方式是连续分配的。

当请求分配磁盘空间时,系统依次扫描空闲表里的内容,直到找到一个合适的空闲区域为止。当用户撤销一个文件时,系统回收文件空间。这时,也需顺序扫描空闲表,寻找一个空闲表条目并将释放空间的第一个物理块号及它占用的块数填到这个条目中。

这种方法仅当有少量的空闲区时才有较好的效果。因为,如果存储空间中有着大量的小的空闲区,则空闲表变得很大,这样查询效率会很低。另外,这种分配技术适用于建立连续文件

空闲链表法

使用「链表」的方式来管理空闲空间。每一个空闲块里有一个指针指向下一个空闲块,这样也能很方便的找到空闲块并管理起来。这种技术只要在主存中保存一个指针,令它指向第一个空闲块。其特点是简单,但不能随机访问,工作效率低,因为每当在链上增加或移动空闲块时需要做很多 I/O 操作,同时数据块的指针消耗了一定的存储空间。

空闲表法和空闲链表法都不适合用于大型文件系统,因为这会使空闲表或空闲链表太大。

位图法

位图是利用二进制的一位来表示磁盘中一个盘块的使用情况,磁盘上所有的盘块都有一个二进制位与之对应。

当值为 0 时,表示对应的盘块空闲,值为 1 时,表示对应的盘块已分配。

在 Linux 文件系统就采用了位图的方式来管理空闲空间,不仅用于数据空闲块的管理,还用于 inode 空闲块的管理,因为 inode 也是存储在磁盘的,自然也要有对其管理。

文件系统的结构

数据块的位图是放在磁盘块里的,假设是放在一个块里,一个块 4K,每位表示一个数据块,共可以表示 4 * 1024 * 8 = 2^15 个空闲块,由于 1 个数据块是 4K 大小,那么最大可以表示的空间为 2^15 * 4 * 1024 = 2^27 个 byte,也就是 128M。

在 Linux 文件系统,把这个结构称为一个块组,那么有 N 多的块组,就能够表示 N 大的文件。

下图给出了 Linux Ext2 整个文件系统的结构和块组的内容,文件系统都由大量块组组成,在硬盘上相继排布:

图片

目录的存储

普通文件的块里面保存的是文件数据,而目录文件的块里面保存的是目录里面一项一项的文件信息。

在目录文件的块中,最简单的保存格式就是列表,就是一项一项地将目录下的文件信息(如文件名、文件 inode、文件类型等)列在表里。如果一个目录有超级多的文件,我们要想在这个目录下找文件,按照列表一项一项的找,效率就不高了。

于是,保存目录的格式改成哈希表,对文件名进行哈希计算,把哈希值保存起来,如果我们要查找一个目录下面的文件名,可以通过名称取哈希。如果哈希能够匹配上,就说明这个文件的信息在相应的块里面。

Linux 系统的 ext 文件系统就是采用了哈希表,来保存目录的内容,这种方法的优点是查找非常迅速,插入和删除也较简单,不过需要一些预备措施来避免哈希冲突。

目录查询是通过在磁盘上反复搜索完成,需要不断地进行 I/O 操作,开销较大。所以,为了减少 I/O 操作,把当前使用的文件目录缓存在内存,以后要使用该文件时只要在内存中操作,从而降低了磁盘操作次数,提高了文件系统的访问速度。

文件 I/O

文件的读写方式各有千秋,对于文件的 I/O 分类也非常多,常见的有

  • 缓冲与非缓冲 I/O
  • 直接与非直接 I/O
  • 阻塞与非阻塞 I/O VS 同步与异步 I/O
缓冲与非缓冲 I/O

文件操作的标准库是可以实现数据的缓存,那么根据「是否利用标准库缓冲」,可以把文件 I/O 分为缓冲 I/O 和非缓冲 I/O

  • 缓冲 I/O,利用的是标准库的缓存实现文件的加速访问,而标准库再通过系统调用访问文件。
  • 非缓冲 I/O,直接通过系统调用访问文件,不经过标准库缓存。

这里所说的「缓冲」特指标准库内部实现的缓冲。

直接与非直接 I/O

根据是「否利用操作系统的缓存」,可以把文件 I/O 分为直接 I/O 与非直接 I/O

  • 直接 I/O,不会发生内核缓存和用户程序之间数据复制,而是直接经过文件系统访问磁盘。
  • 非直接 I/O,读操作时,数据从内核缓存中拷贝给用户程序,写操作时,数据从用户程序拷贝给内核缓存,再由内核决定什么时候写入数据到磁盘。

非直接I/O的刷盘 (时间、write后累积多、主动、内存紧张)

  • 在调用 write 的最后,当发现内核缓存的数据太多的时候,内核会把数据写到磁盘上;
  • 用户主动调用 sync,内核缓存会刷到磁盘上;
  • 当内存十分紧张,无法再分配页面时,也会把内核缓存的数据刷到磁盘上;
  • 内核缓存的数据的缓存时间超过某个时间时,也会把数据刷到磁盘上;
阻塞与非阻塞 I/O VS 同步与异步 I/O

I/O 是分为两个过程的:

  1. 数据准备的过程
  2. 数据从内核空间拷贝到用户进程缓冲区的过程

阻塞 I/O 会阻塞在「过程 1 」和「过程 2」,而非阻塞 I/O 和基于非阻塞 I/O 的多路复用只会阻塞在「过程 2」,所以这三个都可以认为是同步 I/O。

「网络IO套路」当时就靠它追到女友

https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247489558&idx=1&sn=7a96604032d28b8843ca89cb8c129154&scene=21#wechat_redirect

待看和整理

10 张图打开 CPU 缓存一致性的大门

CPU Cache 的数据写入

CPU Cache 通常分为三级缓存:L1 Cache、L2 Cache、L3 Cache,级别越低的离 CPU 核心越近,访问速度也快,但是存储容量相对就会越小。每个核心有自己的L1/L2,L3为所有核心共享

图片

CPU Cache 是由很多个 Cache Line 组成的,CPU Line 是 CPU 从内存读取数据的基本单位,而 CPU Line 是由各种标志(Tag)+ 数据块(Data Block)组成。如果能有效命中缓存,那么我们就不需要每一次都要从内存中获取数据。

Cache数据刷到内存有两种策略:写直达(Write Through写回(Write Back

写直达

保持内存与 Cache 一致性最简单的方式是,把数据同时写入内存和 Cache 中,这种方法称为写直达(Write Through)

在这个方法里,写入前会先判断数据是否已经在 CPU Cache 里面了:

  • 如果数据已经在 Cache 里面,先将数据更新到 Cache 里面,再写入到内存里面;
  • 如果数据没有在 Cache 里面,就直接把数据更新到内存里面。

每次写操作都会写回到内存,这样写操作将会花费大量的时间,无疑性能会受到很大的影响。

写回

在写回机制中,当发生写操作时,新的数据仅仅被写入 Cache Block 里,只有当修改过的 Cache Block「被替换」时才需要写到内存中,减少了数据写回内存的频率,这样便可以提高系统的性能。

图片

可以发现写回这个方法,在把数据写入到 Cache 的时候,只有在缓存不命中,同时数据对应的 Cache 中的 Cache Block 为脏标记的情况下,才会将数据写到内存中,而在缓存命中的情况下,则在写入后 Cache 后,只需把该数据对应的 Cache Block 标记为脏即可,而不用写到内存里。

这样的好处是,如果我们大量的操作都能够命中缓存,那么大部分时间里 CPU 都不需要读写内存,自然性能相比写直达会高很多。

缓存一致性问题

CPU多核带来的问题。假设CPU有两个核心,A 号核心和 B 号核心同时运行两个线程,都操作共同的变量 i(初始值为 0 )。 A 号核心执行了 i++ 语句的时候,为了考虑性能,使用了我们前面所说的写回策略,先把值为 1 的执行结果写入到 L1/L2 Cache 中,然后把 L1/L2 Cache 中对应的 Block 标记为脏的,这个时候数据其实没有被同步到内存中的。这时旁边的 B 号核心尝试从内存读取 i 变量的值,则读到的将会是错误的值,因为刚才 A 号核心更新 i 值还没写入到内存中,内存中的值还依然是 0。这个就是所谓的缓存一致性问题,A 号核心和 B 号核心的缓存,在这个时候是不一致,从而会导致执行结果的错误。

要实现的这个机制的话,要保证做到下面这 2 点:

  • 第一点,某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache,这个称为写传播(Wreite Propagation)
  • 第二点,某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,这个称为事务的串行化(Transaction Serialization)

要实现事务串形化,要做到 2 点:

  • CPU 核心对于 Cache 中数据的操作,需要同步给其他 CPU 核心;
  • 要引入「锁」的概念,如果两个 CPU 核心里有相同数据的 Cache,那么对于这个 Cache 数据的更新,只有拿到了「锁」,才能进行对应的数据更新。
总线嗅探

写传播的原则就是当某个 CPU 核心更新了 Cache 中的数据,要把该事件广播通知到其他核心。最常见实现的方式是总线嗅探(Bus Snooping)

总线嗅探方法很简单,每个 CPU 核心每时每刻监听总线上的一切活动,但是不管别的核心的 Cache 是否缓存相同的数据,都需要发出一个广播事件,这无疑会加重总线的负载。另外,总线嗅探只是保证了某个 CPU 核心的 Cache 更新数据这个事件能被其他 CPU 核心知道,但是并不能保证事务串形化。

MESI 协议

有一个协议基于总线嗅探机制实现了事务串形化,也用状态机机制降低了总线带宽压力,这个协议就是 MESI 协议,这个协议就做到了 CPU 缓存一致性。

MESI 协议其实是 4 个状态单词的开头字母缩写,分别是:

  • Modified,已修改
  • Exclusive,独占
  • Shared,共享
  • Invalidated,已失效

可以发现当 Cache Line 状态是「已修改」或者「独占」状态时,修改更新其数据不需要发送广播给其他 CPU 核心,这在一定程度上减少了总线带宽压力。另外,串行化需要自己去理解记忆了。

总结

CPU 在读写数据的时候,都是在 CPU Cache 读写数据的,原因是 Cache 离 CPU 很近,读写性能相比内存高出很多。对于 Cache 里没有缓存 CPU 所需要读取的数据的这种情况,CPU 则会从内存读取数据,并将数据缓存到 Cache 里面,最后 CPU 再从 Cache 读取数据。

而对于数据的写入,CPU 都会先写入到 Cache 里面,然后再在找个合适的时机写入到内存,那就有「写直达」和「写回」这两种策略来保证 Cache 与内存的数据一致性:

  • 写直达,只要有数据写入,都会直接把数据写入到内存里面,这种方式简单直观,但是性能就会受限于内存的访问速度;
  • 写回,对于已经缓存在 Cache 的数据的写入,只需要更新其数据就可以,不用写入到内存,只有在需要把缓存里面的脏数据交换出去的时候,才把数据同步到内存里,这种方式在缓存命中率高的情况,性能会更好;

当今 CPU 都是多核的,每个核心都有各自独立的 L1/L2 Cache,只有 L3 Cache 是多个核心之间共享的。所以,我们要确保多核缓存是一致性的,否则会出现错误的结果。

要想实现缓存一致性,关键是要满足 2 点:

  • 第一点是写传播,也就是当某个 CPU 核心发生写入操作时,需要把该事件广播通知给其他核心;
  • 第二点是事物的串行化,这个很重要,只有保证了这个,次啊能保障我们的数据是真正一致的,我们的程序在各个不同的核心上运行的结果也是一致的;

基于总线嗅探机制的 MESI 协议,就满足上面了这两点,因此它是保障缓存一致性的协议。

MESI 协议,是已修改、独占、共享、已失效这四个状态的英文缩写的组合。整个 MESI 状态的变更,则是根据来自本地 CPU 核心的请求,或者来自其他 CPU 核心通过总线传输过来的请求,从而构成一个流动的状态机。另外,对于在「已修改」或者「独占」状态的 Cache Line,修改更新其数据不需要发送广播给其他 CPU 核心。

敖丙说了这么多次 I/O,可你知道其中的原理么?

没啥特殊的

迄今为止把同步/异步/阻塞/非阻塞/BIO/NIO/AIO讲的这么清楚的好文章(快快珍藏)

这篇文章的一些观点感觉不太对。可pass

I/O多路复用:虽然I/O多路复用的函数也是阻塞的,但是其与以上两种还是有不同的,I/O多路复用是阻塞在select,epoll这样的系统调用之上,而没有阻塞在真正的I/O系统调用如recvfrom之上。select函数与【阻塞/非阻塞socket】没有半毛钱的关系。select函数本身是阻塞的(与socket是否阻塞并没有关系)。select的意义是让进程休眠等待被唤醒,它可以同时监测多个socket。 直到:

  1. 有监测时间发生(返回 > 0)
  2. 超时(返回0)
  3. select函数错误 (返回-1)

原来 8 张图,就能学废 Reactor 和 Proactor

演进

Reactor 模式,市面上常见的开源软件很多都采用了这个方案,比如 Redis、Nginx、Netty 等等

我们熟悉的 select/poll/epoll 就是内核提供给用户态的多路复用系统调用,线程可以通过一个系统调用函数从内核中获取多个事件。在获取事件时,先把我们要关心的连接传给内核,再由内核检测:

  • 如果没有事件发生,线程只需阻塞在这个系统调用,而无需像前面的线程池方案那样轮询调用 read 操作来判断是否有数据。
  • 如果有事件发生,内核会返回产生了事件的连接,线程就会从阻塞状态返回,然后在用户态中再处理这些连接对应的业务即可。

Reactor 模式也叫 Dispatcher 模式,我觉得这个名字更贴合该模式的含义,即 I/O 多路复用监听事件,收到事件后,根据事件类型分配(Dispatch)给某个进程 / 线程

Reactor 模式主要由 Reactor 和处理资源池这两个核心部分组成,它俩负责的事情如下:

  • Reactor 负责监听和分发事件,事件类型包含连接事件、读写事件;
  • 处理资源池负责处理事件,如 read -> 业务逻辑 -> send;

理论上就可以有 4 种方案选择:

  • 单 Reactor 单进程 / 线程
  • 单 Reactor 多进程 / 线程
  • 多 Reactor 单进程 / 线程;
  • 多 Reactor 多进程 / 线程

第3个方案复杂,没有优势,实际中并没有应用。剩下的3个都比较经典。方案具体使用进程还是线程,要看使用的编程语言以及平台有关:

  • Java 语言一般使用线程,比如 Netty;
  • C 语言使用进程和线程都可以,例如 Nginx 使用的是进程,Memcache 使用的是线程。
Reactor
单 Reactor 单进程 / 线程
图片

可以看到进程里有 Reactor、Acceptor、Handler 这三个对象:

  • Reactor 对象的作用是监听和分发事件;
  • Acceptor 对象的作用是获取连接;
  • Handler 对象的作用是处理业务;

但是,这种方案存在 2 个缺点:

  • 第一个缺点,因为只有一个进程,无法充分利用 多核 CPU 的性能
  • 第二个缺点,Handler 对象在业务处理时,整个进程是无法处理其他连接的事件的,如果业务处理耗时比较长,那么就造成响应的延迟

所以,单 Reactor 单进程的方案不适用计算机密集型的场景,只适用于业务处理非常快速的场景。如Redis,性能瓶颈不在CPU上。

单 Reactor 多线程 / 多进程
图片
  • Handler 对象不再负责业务处理,只负责数据的接收和发送,Handler 对象通过 read 读取到数据后,会将数据发给子线程里的 Processor 对象进行业务处理;
  • 子线程里的 Processor 对象就进行业务处理,处理完后,将结果发给主线程中的 Handler 对象,接着由 Handler 通过 send 方法将响应结果发送给 client;

单 Reator 多线程的方案优势在于能够充分利用多核 CPU 的能,那既然引入多线程,那么自然就带来了多线程竞争资源的问题。

事实上,单 Reactor 多进程相比单 Reactor 多线程实现起来很麻烦,主要因为要考虑子进程 <-> 父进程的双向通信,并且父进程还得知道子进程要将数据发送给哪个客户端。

另外,「单 Reactor」的模式还有个问题,因为一个 Reactor 对象承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方

多 Reactor 多进程 / 线程
图片

方案详细说明如下:

  • 主线程中的 MainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor 对象中的 accept 获取连接,将新的连接分配给某个子线程;
  • 子线程中的 SubReactor 对象将 MainReactor 对象分配的连接加入 select 继续进行监听,并创建一个 Handler 用于处理连接的响应事件。
  • 如果有新的事件发生时,SubReactor 对象会调用当前连接对应的 Handler 对象来进行响应。
  • Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程。

多 Reactor 多线程的方案虽然看起来复杂的,但是实际实现时比单 Reactor 多线程的方案要简单的多,原因如下:

  • 主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理。
  • 主线程和子线程的交互很简单,主线程只需要把新连接传给子线程,子线程无须返回数据,直接就可以在子线程将处理结果发送给客户端。

大名鼎鼎的两个开源软件 Netty 和 Memcache 都采用了「多 Reactor 多线程」的方案。

采用了「多 Reactor 多进程」方案的开源软件是 Nginx,不过方案与标准的多 Reactor 多进程有些差异。

Proactor

前面提到的 Reactor 是非阻塞同步网络模式,而 Proactor 是异步网络模式

而真正的异步 I/O 是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待

当我们发起 aio_read (异步 I/O) 之后,就立即返回,内核自动将数据从内核空间拷贝到用户空间,这个拷贝过程同样是异步的,内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作

Proactor 正是采用了异步 I/O 技术,所以被称为异步网络模型。

现在我们再来理解 Reactor 和 Proactor 的区别,就比较清晰了。

  • Reactor 是非阻塞同步网络模式,感知的是就绪可读写事件。在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主动调用 read 方法来完成数据的读取,也就是要应用进程主动将 socket 接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据。
  • Proactor 是异步网络模式, 感知的是已完成的读写事件。在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据。

无论是 Reactor,还是 Proactor,都是一种基于「事件分发」的网络编程模式,区别在于 Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式则是基于「已完成」的 I/O 事件

图片

可惜的是,在 Linux 下的异步 I/O 是不完善的,这也使得基于 Linux 的高性能网络程序都是使用 Reactor 方案。

而 Windows 里实现了一套完整的支持 socket 的异步编程接口。Windows 里实现高性能网络程序可以使用效率更高的 Proactor 方案。

总结

常见的 Reactor 实现方案有三种。

第一种方案单 Reactor 单进程 / 线程,不用考虑进程间通信以及数据同步的问题,因此实现起来比较简单,这种方案的缺陷在于无法充分利用多核 CPU,而且处理业务逻辑的时间不能太长,否则会延迟响应,所以不适用于计算机密集型的场景,适用于业务处理快速的场景,比如 Redis 采用的是单 Reactor 单进程的方案。

第二种方案单 Reactor 多线程,通过多线程的方式解决了方案一的缺陷,但它离高并发还差一点距离,差在只有一个 Reactor 对象来承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方。

第三种方案多 Reactor 多进程 / 线程,通过多个 Reactor 来解决了方案二的缺陷,主 Reactor 只负责监听事件,响应事件的工作交给了从 Reactor,Netty 和 Memcache 都采用了「多 Reactor 多线程」的方案,Nginx 则采用了类似于 「多 Reactor 多进程」的方案。

Reactor 可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」,而 Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应用进程」。

因此,真正的大杀器还是 Proactor,它是采用异步 I/O 实现的异步网络模型,感知的是已完成的读写事件,而不需要像 Reactor 感知到事件后,还需要调用 read 来从内核中获取数据。

不过,无论是 Reactor,还是 Proactor,都是一种基于「事件分发」的网络编程模式,区别在于 Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式则是基于「已完成」的 I/O 事件。

计算机网络

计算机网络按规模进行划分,有 WAN(Wide Area Network, 广域网)LAN(Local area Network, 局域网)

单播、广播、多播和任播
  • 单播最大的特点就是 1 对 1,早期的固定电话就是单播的一个例子
  • 主机和与他连接的所有端系统相连,主机将信号发送给所有的端系统。
  • 多播与广播很类似,也是将消息发送给多个接收主机,不同之处在于多播需要限定在某一组主机作为接收端。
  • 任播是在特定的多台主机中选出一个接收端的通信方式。虽然和多播很相似,但是行为与多播不同,任播是从许多目标机群中选出一台最符合网络条件的主机作为目标主机发送消息。然后被选中的特定主机将返回一个单播信号,然后再与目标主机进行通信。

其它没什么特别的

什么是单点登录(SSO)

单点登录的英文名叫做:Single Sign On(简称SSO)。简单来说,单点登录就是在多个系统中,用户只需一次登录,各个系统即可感知该用户已经登录。

枚举

3. 枚举的原理

假设Season extends Enum

Season是一个继承了Enum类的抽象类,而且枚举中定义的枚举变量变成了相应的public static final属性其类型为抽象类Season类型,名字就是枚举变量的名字。这四个枚举常量分别使用了内部类来实现(Season$1.class…),同时还Season添加了两个方法values()和valueOf(String s)

>javap Season.class
Compiled from "Season.java"
public abstract class Season extends java.lang.Enum<Season> {
  public static final Season SPRING;
  public static final Season SUMMER;
  public static final Season AUTUMN;
  public static final Season WINTER;
  public static Season[] values();
  public static Season valueOf(java.lang.String);
  public abstract Season getNextSeason();
  Season(java.lang.String, int, Season$1);
  static {};
}

从Enum中我们可以看到,每个枚举都定义了两个属性,name和ordinal,name表示枚举变量的名称,而ordinal则是根据变量定义的顺序授予的整型值,从0开始。

在枚举变量初始化的时候,会自动初始化这两个字段,设置相应的值,所以会在Season()的构造方法中添加两个参数。

而且我们可以从Enum的源码中看到,大部分的方法都是final修饰的,特别是clone、readObject、writeObject这三个方法,保证了枚举类型的不可变性,不能通过克隆、序列化和反序列化复制枚举,这就保证了枚举变量只是一个实例,即是单例的。

重要总结

总结一下,其实枚举本质上也是通过普通的类来实现的,只是编译器为我们进行了处理。每个枚举类型都继承自Enum类,并由编译器自动添加了values()和valueOf()方法每个枚举变量是一个静态常量字段,由内部类实现,而这个内部类继承了此枚举类。

所有的枚举变量都是通过静态代码块进行初始化,也就是说在类加载期间就实现了。

另外,通过把clone、readObject、writeObject这三个方法定义为final,保证了每个枚举类型及枚举常量都是不可变的,也就是说,可以用枚举实现线程安全的单例。

4. 枚举与单例

枚举类实现单例模式相当硬核,因为枚举类型是线程安全的,且只会装载一次。使用枚举类来实现单例模式,是所有的单例实现中唯一一种不会被破坏的单例模式实现。

public class SingletonObject {

    private SingletonObject() {
    }

    private enum Singleton {
        INSTANCE;

        private final SingletonObject instance;

        Singleton() {
            instance = new SingletonObject();
        }

        private SingletonObject getInstance() {
            return instance;
        }
    }

    public static SingletonObject getInstance() {
        return Singleton.INSTANCE.getInstance();
    }
}

从String中移除空白字符的多种方式!?差别竟然这么大!

想要直接移除掉字符串开头的空白字符,可以使用stripLeading、replaceAll和replaceFirst

想要直接移除掉字符串末尾的空白字符,可以使用stripTrailing、replaceAll和replaceFirst

想要同时移除掉字符串开头和结尾的空白字符,可以使用strip、trim

想要移除掉字符串中的所有空白字符,可以使用replace和replaceAll

而Java 11种新增的strip、stripTrailing以及stripLeading方法,可以移除的字符要比其他方法多,他可以移除的空白字符不仅仅局限于ASCII中的字符,而是Unicode中的所有空白字符,具体判断方式可以使用Character.isWhitespace进行判断。

自我总结:

  • trim() : 删除字符串开头和结尾的空格。trim移除的空白字符指的是指ASCII值小于或等于32的任何字符(’ U+0020 ')
  • strip() : 删除字符串开头和结尾的空格。**根据Unicode标准,除了ASCII中的字符以外,还是有很多其他的空白字符的。**jdk11后加入,Character.isWhitespace(int)来标识空白字符
  • stripLeading() : 只删除字符串开头的空格。jdk11后加入,Character.isWhitespace(int)来标识空白字符
  • stripTrailing() : 只删除字符串的结尾的空格。jdk11后加入,Character.isWhitespace(int)来标识空白字符
  • replace() : 用新字符替换所有目标字符。replace是从java 1.5中添加的,可以用指定的字符串替换每个目标子字符串。
  • replaceAll() : 将所有匹配的字符替换为新字符。此方法将正则表达式作为输入,以标识需要替换的目标子字符串。Java 1.4,使用正则。
  • replaceFirst() : 仅将目标子字符串的第一次出现的字符替换为新的字符串。java 1.4中添加的,它只将给定正则表达式的第一个匹配项替换为替换字符串。

双亲委派原则

什么是Java类加载?

java类加载器负责将编译好的 Java class 件加载到 Java 虚拟机(JVM)中的运行时数据区(Runtime Data Areas)中,供执行引擎调用。

JVM类加载层级关系

执行java程序时,会启动一个JVM进程,JVM在启动时会做一些初始化操作,比如获取系统参数等等,然后创建一个启动类加载器,用于加载JVM运行时必须的一些类到内存中,同时也会创建其他两个类加载器扩展类加载器系统类加载器

图片

  • **启动类加载器(BootClassLoader):**java虚拟机启动后创建的第一个类加载器,由C++语言实现,所以我们在java代码中查看其信息时,看到的均为null。
  • 扩展类加载器(ExtClassLoader):由启动类加载器加载,并将扩展类加载器中的parent的值设置为null表示指向启动类加载器),同时继承自URLClassLoader。
  • 系统类加载器(AppClassLoader):由启动类加载器加载,并将系统类加载期中的parent的值设置为上述创建的扩展类加载器。同时继承自URLClassLoader。

这里的parent不是java的继承机制,而是类加载器中的一个实例属性,用于在类加载时的委托对象,parent属性定义在其所继承的ClassLoader中

JVM类加载的默认加载路径

每种类型的类加载器默认都会有自己的加载路径,启动类加载器、扩展类加载器和系统类加载器的默认加载路径如下:

  • 启动类加载器(BootstrapClassLoader)由C++语言编写,负责在JVM启动时加载jdk自身的一些核心class类(jar包形式)到JVM中,加载时寻找资源的路径由只读系统属性:”sun.boot.class.path“ 指定。JAVA_HOME/jre/lib下,jdk8还包含JAVA_HOME/jre/classes。
  • 扩展类加载器(ExtClassLoader),负责加载位于系统属性:"java.ext.dirs"指向的目录下加载class文件(jar包或者直接class文件形式)到JVM中,比如通常ext类加载路径为:”$JAVA_HOME/jre/lib/ext“ 。支持在JVM启动之前进行修改路径,运行中修改路径不生效,扩展类路径中仅支持jar包的加载
  • 系统类加载器(AppClassLoader),负责加载应用classpath路径下的class文件(jar包或者直接class文件形式)到JVM中,当系统中没有设置classpath路径时,默认加载当前路径下的class文件。
委托给扩展类加载器加载

在当前目录:E:\java_app下仍然保留有A.class文件,在扩展类加载器路径下多了一个包含了A.class的A.jar文件。在当前目录下执行java命令执行TestMain,命令为:java TestMain。

由输出结果可知,class A虽然在系统类加载器的加载路径中,但由于类加载的委托机制,A首先将由系统类加载器委托给其双亲扩展类加载器进行加载,刚好在扩展类加载器的加载路径中包含了A.class(包含在A.jar中),所以A最终由扩展类加载器进行了加载。

委托给启动类加载器进行加载

同上,class A虽然在系统类加载器的加载路径中,也存在扩展类加载器的加载路径中,但由于类加载的委托机制,A首先将由系统类加载器委托给其双亲扩展类加载器进行加载。

扩展类加载器又会继续进行委托加载(实际上因为扩展类加载器的parent:启动类加载器为null,所以此时的委托动作实际上就是去启动类加载器的加载路径中寻找class A),最终由启动类加载进行了A的加载。

双亲委托加载方向

类加载器在加载类时,只能向上递归委托其双亲进行类加载,而不可能从双亲再反向委派当前类加载器来进行类加载。

同样的例子,TestMain中依赖了A。TestMain.class打进TestMain.jar中,放到扩展类加载器的加载路径中,同时也保留TestMain.class到当前目录。A.class在当前目录。

  1. 运行java命令执行TestMain程序时,系统类加载器准备加载TestMain,根据双亲委派机制,先委派给其双亲进行加载,最后,双亲扩展类加载器在其加载路径中的TestMain.jar中找到了TestMain.class,完成了TestMain的加载。
  2. TestMain中依赖了A,此时,会根据加载了TestMain的类加载器:扩展类加载器去加载A,加载方式根据委托机制递归委托给双亲加载,扩展类加载器的双亲为启动类加载器,在启动类加载器的加载路径中不存在A,加载失败,此时由扩展类加载器在自己的加载路径中加载A,也因为加载路径中没有A.class存在,A.class存在于系统类加载器的加载路径中,但是扩展类加载器不会再返回去委托系统类加载器进行加载,所以直接抛出加载失败异常,出现了上述的错误。
双亲委派机制的作用
  1. 防止重复加载同一个.class。通过委托去向上面问一问,加载过了,就不用再加载一遍。保证数据安全
  2. 保证核心.class不能被篡改。通过委托方式,不会去篡改核心.class,即使篡改也不会去加载,即使加载也不会是同一个.class对象了。不同的加载器加载同一个.class也不是同一个Class对象。这样保证了Class执行安全。

Java 集合框架看这一篇就够了

Java 集合,也称作容器,主要是由两大接口 (Interface) 派生出来的:Collection 和 Map

  • Collection 存放单一元素;
  • Map 存放 key-value 键值对。

图片

Collection

图片

List
图片

List 最大的特点就是:有序可重复。Set 的特点也说出来了,和 List 完全相反,Set 是 无序不重复的。

List 的实现方式有 LinkedList 和 ArrayList 两种,那面试时最常问的就是这两个数据结构如何选择。对于这类选择问题:
一是考虑数据结构是否能完成需要的功能
如果都能完成,二是考虑哪种更高效

  • 因为 ArrayList 是用数组来实现的。
  • 而数组和链表的最大区别就是数组是可以随机访问的(random access)

这个特点造成了在数组里可以通过下标用 O(1) 的时间拿到任何位置的数,而链表则做不到,只能从头开始逐个遍历。

也就是说在「改查」这两个功能上,因为数组能够随机访问,所以 ArrayList 的效率高。

那「增删」呢?

如果不考虑找到这个元素的时间,

数组因为物理上的连续性,当要增删元素时,在尾部还好,但是其他地方就会导致后续元素都要移动,所以效率较低;而链表则可以轻松的断开和下一个元素的连接,直接插入新元素或者移除旧元素。

但是呢,实际上你不能不考虑找到元素的时间啊。。。而且如果是在尾部操作,数据量大时 ArrayList 会更快的。

所以说:

  1. 改查选择 ArrayList;
  2. 增删在尾部的选择 ArrayList;
  3. 其他情况下,如果时间复杂度一样,推荐选择 ArrayList,因为 overhead 更小,或者说内存使用更有效率。
Vector

Vector 和 ArrayList 一样,也是继承自 java.util.AbstractList,底层也是用数组来实现的。

但是现在已经被弃用了,因为…它加了太多的 synchronized!线程安全,但是效率低。现在大家不再在数据结构的层面加 synchronized,而是把这个任务转移给我们程序员。

Vector 和 ArrayList 的区别是什么

一是刚才已经说过的线程安全问题;
二是扩容时扩多少的区别。ArrayList定义的新容量就是原容量的 1.5 倍,Vector默认情况下它是扩容两倍

Queue & Deque
Queue

那 Queue 的方法官网[1]都总结好了,它有两组 API,基本功能是一样的,但是呢:

  • 一组是会抛异常的;
  • 另一组会返回一个特殊值。
功能抛异常返回值
add(e)offer(e)
remove()poll()
element()peek()

那怎么选择呢?:

  • 首先,要用就用同一组 API,前后要统一;
  • 其次,根据需求。如果你需要它抛异常,那就是用抛异常的;不过做算法题时基本不用,所以选那组返回特殊值的就好了。
Deque

Deque 是两端都可以进出的,那自然是有针对 First 端的操作和对 Last 端的操作,那每端都有两组,一组抛异常,一组返回特殊值

功能抛异常返回值
addFirst(e)/ addLast(e)offerFirst(e)/ offerLast(e)
removeFirst()/ removeLast()pollFirst()/ pollLast()
getFirst()/ getLast()peekFirst()/ peekLast()

使用时同理,要用就用同一组。

Queue 和 Deque 的这些 API 都是 O(1) 的时间复杂度,准确来说是均摊时间复杂度。

实现类
  • 如果想实现「普通队列 - 先进先出」的语义,就使用 LinkedList 或者 ArrayDeque 来实现;
  • 如果想实现「优先队列」的语义,就使用 PriorityQueue;
  • 如果想实现「栈」的语义,就使用 ArrayDeque。

如何选择用 LinkedList 还是 ArrayDeque 呢?

推荐使用 ArrayDeque,因为效率高,而 LinkedList 还会有其他的额外开销(overhead)。

  1. ArrayDeque 是一个可扩容的数组,LinkedList 是链表结构;
  2. ArrayDeque 里不可以存 null 值,但是 LinkedList 可以;
  3. ArrayDeque 在操作头尾端的增删操作时更高效,但是 LinkedList 只有在当要移除中间某个元素且已经找到了这个元素后的移除才是 O(1) 的;
  4. ArrayDeque 在内存使用方面更高效。

ArrayDeque 在 Java 6 之后才有的,6以前用LinkedList。

Stack

Stack 在语义上是 后进先出(LIFO) 的线性数据结构。想实现 Stack 的语义,就用 ArrayDeque 。因为 Vector 已经过被弃用了,而 Stack 是继承 Vector 的。

Set

Set 的特定是无序不重复的。

Set 的常用实现类有三个:

HashSet: 采用 Hashmap 的 key 来储存元素,主要特点是无序的,基本操作都是 O(1) 的时间复杂度,很快。

LinkedHashSet: 这个是一个 HashSet + LinkedList 的结构,特点就是既拥有了 O(1) 的时间复杂度,又能够保留插入的顺序。

TreeSet: 采用红黑树结构,特点是可以有序,可以用自然排序或者自定义比较器来排序;缺点就是查询速度没有 HashSet 快。

那每个 Set 的底层实现其实就是对应的 Map:

数值放在 map 中的 key 上,value 上放了个 PRESENT,是一个静态的 Object,相当于 place holder,每个 key 都指向这个 object。

那么具体的实现原理增删改查四种操作,以及哈希冲突hashCode()/equals() 等问题都在 HashMap 那篇文章里讲过了,这里就不赘述了。

Java 集合中「堆」是啥?

堆其实就是一种特殊的队列——优先队列。

普通的队列游戏规则很简单:就是先进先出;但这种优先队列搞特殊,不是按照进队列的时间顺序,而是按照每个元素的优先级来比拼,优先级高的在堆顶。这里要区别于操作系统里的那个“堆”,这两个虽然都叫堆,但是没有半毛钱关系,都是借用了 Heap 这个英文单词而已。

堆的特点:

  1. 堆是一棵完全二叉树;

  2. 堆序性 (heap order): 任意节点都优于它的所有孩子

    a. 如果是任意节点都大于它的所有孩子,这样的堆叫大顶堆,Max Heap;

    b. 如果是任意节点都小于它的所有孩子,这样的堆叫小顶堆,Min Heap;

HashMap

HashMap是我们非常常用的数据结构,由数组和链表组合构成的数据结构。数组里面每个地方都存了Key-Value这样的实例,在Java7叫Entry在Java8中叫Node。

先根据哈希计算找到对应的插入位置,然后插入数据。jdk 8以前使用链表头插,作者认为刚插入的数据访问频率会更高,所以放在头部。但是并发情况下可能出现链表死循环。get数据会出错。jdk 8之后链表尾插,没有对应的问题。jdk 8之后还有红黑数的实现。

什么时候resize呢?

有两个因素:

  • Capacity:HashMap当前长度。
  • LoadFactor:负载因子,默认值0.75f。

比如当前的容量大小为100,当你存进第76个的时候,判断发现需要进行resize了,那就进行扩容。扩容分为两步

  • 扩容:创建一个新的Entry空数组,长度是原数组的2倍。
  • ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组。

使用头插会改变链表的上的顺序,但是如果使用尾插,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了。

那是不是意味着Java8就可以把HashMap用在多线程中呢?

即使不会出现死循环,但是通过源码看到put/get方法都没有加同步锁,多线程情况最容易出现的就是:无法保证上一秒put的值,下一秒get的时候还是原值,所以线程安全还是无法保证。

你那知道为啥HashMap的默认初始化长度是16么?

这样是为了位运算的方便,2的幂,位与运算比算数计算的效率高了很多,之所以选择16,是为了服务将Key映射到index的算法。另外,16这个数字是多次验证后得出的最佳实践。

为啥我们重写equals方法的时候需要重写hashCode方法呢?你能用HashMap给我举个例子么?

Java约定:equals相同,hashcode必然相同。equals不同,hashcode可能相同。

如果我们对equals方法进行了重写,建议一定要对hashCode方法重写,以保证相同的对象返回相同的hash值,不同的对象返回不同的hash值。

怎么处理HashMap在线程安全的场景?

在这样的场景,我们一般都会使用HashTable或者CurrentHashMap,但是因为前者的并发度的原因基本上没啥使用场景了,所以存在线程不安全的场景我们都使用的是CorruentHashMap。

HashTable我看过他的源码,很简单粗暴,直接在方法上锁,并发度很低,最多同时允许一个线程访问,currentHashMap就好很多了,1.7和1.8有较大的不同,不过并发度都比前者好太多了。

总结

HashMap常见面试题:

  • HashMap的底层数据结构?
  • HashMap的存取原理?
  • Java7和Java8的区别?
  • 为啥会线程不安全?
  • 有什么线程安全的类代替么?
  • 默认初始化大小是多少?为啥是这么多?为啥大小都是2的幂?
  • HashMap的扩容方式?负载因子是多少?为什是这么多?
  • HashMap的主要参数都有哪些?
  • HashMap是怎么处理hash碰撞的?
  • hash的计算规则?

面试官:HashMap 为什么线程不安全?

首先HashMap是线程不安全的,其主要体现:

#1.在jdk1.7中,在多线程环境下,扩容时会造成环形链或数据丢失。

#2.在jdk1.8中,在多线程环境下,插入数据会发生数据覆盖的情况。

万万没想到,HashMap默认容量的选择,竟然背后有这么多思考!?

什么是容量

在HashMap中,有两个比较容易混淆的关键字段:size和capacity ,这其中capacity就是Map的容量(数组的长度),而size我们称之为Map中的元素个数。

当我们创建一个HashMap的时候,如果没有指定其容量,那么会得到一个默认容量为16的Map

因为位运算直接对内存数据进行操作,不需要转成十进制,所以位运算要比取模运算的效率更高,所以HashMap在计算元素要存放在数组中的index的时候,使用位运算代替了取模运算。之所以可以做等价代替,前提是要求HashMap的容量一定要是2^n

HashMap根据用户传入的初始化容量,利用无符号右移和按位或运算等方式计算出第一个大于该数的2的幂。

扩容

2倍扩容。HashMap的扩容条件就是当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容。

threshold = loadFactor * capacity。

loadFactor是装载因子,表示HashMap满的程度,默认值为0.75f,设置成0.75有一个好处,那就是0.75正好是3/4,而capacity又是2的幂。所以,两个数的乘积都是整数。

总结

HashMap作为一种数据结构,元素在put的过程中需要进行hash运算,目的是计算出该元素存放在hashMap中的具体位置。

hash运算的过程其实就是对目标元素的Key进行hashcode,再对Map的容量进行取模,而JDK 的工程师为了提升取模的效率,使用位运算代替了取模运算,这就要求Map的容量一定得是2的幂。

而作为默认容量,太大和太小都不合适,所以16就作为一个比较合适的经验值被采用了。

为了保证任何情况下Map的容量都是2的幂,HashMap在两个地方都做了限制。

首先是,如果用户制定了初始容量,那么HashMap会计算出比该数大的第一个2的幂作为初始容量。

另外,在扩容的时候,也是进行成倍的扩容,即4变成8,8变成16。

本文,通过分析为什么HashMap的默认容量是16,我们深入HashMap的原理,分析了下背后的原理,从代码中我们可以发现,JDK 的工程师把各种位运算运用到了极致,想尽各种办法优化效率。值得我们学习!

ConcurrentHashMap & Hashtable(文末送书)

Hashmap中的链表大小超过八个时会自动转化为红黑树,当删除小于六时重新变为链表,为啥呢?

根据泊松分布,在负载因子默认为0.75的时候,单个hash槽内元素个数为8的概率小于百万分之一,所以将7作为一个分水岭,等于7的时候不转换,大于等于8的时候才进行转换,小于等于6的时候就化为链表。

一般在多线程的场景,我都会使用好几种不同的方式去代替HashMap:

  • 使用Collections.synchronizedMap(Map)创建线程安全的map集合;
  • Hashtable
  • ConcurrentHashMap

不过出于线程并发度的原因,我都会舍弃前两者使用最后的ConcurrentHashMap,他的性能和效率明显高于前两者。

SynchronizedMap

内部维护了一个普通对象Map,还有排斥锁mutex。我们在调用这个方法的时候就需要传入一个Map,可以看到有两个构造器,如果你传入了mutex参数,则将对象排斥锁赋值为传入的对象。如果没有,则将对象排斥锁赋值为this,即调用synchronizedMap的对象,就是上面的Map。

创建出synchronizedMap之后,再操作map的时候,就会对方法上锁

Hashtable

Hashtable在对数据操作的时候都会上锁(synchronized),所以效率比较低下。

Hashtable 是不允许键或值为 null 的,HashMap 的键值则都可以为 null。

  • 实现方式不同:Hashtable 继承了 Dictionary类,而 HashMap 继承的是 AbstractMap 类。

    Dictionary 是 JDK 1.0 添加的,貌似没人用过这个,我也没用过。

  • 初始化容量不同:HashMap 的初始容量为:16,Hashtable 初始容量为:11,两者的负载因子默认都是:0.75。

  • 扩容机制不同:当现有容量大于总容量 * 负载因子时,HashMap 扩容规则为当前容量翻倍,Hashtable 扩容规则为当前容量翻倍 + 1。

  • 迭代器不同:HashMap 中的 Iterator 迭代器是 fail-fast 的,而 Hashtable 的 Enumerator 不是 fail-fast 的。

**快速失败(fail—fast)**是java集合中的一种机制, 在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出Concurrent Modification Exception。

迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。

java.util包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)算是一种安全机制吧。

**安全失败(fail—safe)**大家也可以了解下,java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改。

采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。

**原理:**由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发 Concurrent Modification Exception。

缺点:基于拷贝内容的优点是避免了 Concurrent Modification Exception,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。

**场景:**java.util.concurrent 包下的容器都是安全失败,可以在多线程下并发使用,并发修改。

ConcurrentHashMap

1.8 在 1.7 的数据结构上做了大的改动,采用红黑树之后可以保证查询效率(O(logn)),甚至取消了 ReentrantLock 改为了 synchronized,这样可以看出在新版的 JDK 中对 synchronized 优化是很到位的。

其它看之前的纪录。

常见问题
  • 谈谈你理解的 Hashtable,讲讲其中的 get put 过程。ConcurrentHashMap同问。
  • 1.8 做了什么优化?
  • 线程安全怎么做的?
  • 不安全会导致哪些问题?
  • 如何解决?有没有线程安全的并发容器?
  • ConcurrentHashMap 是如何实现的?
  • ConcurrentHashMap并发度为啥好这么多?
  • 1.7、1.8 实现有何不同?为什么这么做?
  • CAS是啥?
  • ABA是啥?场景有哪些,怎么解决?
  • synchronized底层原理是啥?
  • synchronized锁升级策略
  • 快速失败(fail—fast)是啥,应用场景有哪些?安全失败(fail—safe)同问。

ArrayList

小结:ArrayList底层是用数组实现的存储。

特点:查询效率高,增删效率低,线程不安全。使用频率很高。

ArrayList可以通过构造方法在初始化的时候指定底层数组的大小。无参时,1.8以前默认长度是10,1.8改为0,添加数据时才分配10的容量。10是经验所得。

指定位置插入:比如index=5,则大于等于5的copy一个数组,放到index=6的位置。然后5插入数据。System.arrayCopy(没有实际创建数组?)

指定位置删除:比如index=6,则大于等于6的copy一个数组,放到index=5的位置。尾部置null。System.arrayCopy(没有实际创建数组?)

ArrayList是线程安全的么?

当然不是,线程安全版本的数组容器是Vector。

Vector的实现很简单,就是把所有的方法统统加上synchronized就完事了。

你也可以不使用Vector,用Collections.synchronizedList把一个普通ArrayList包装成一个线程安全版本的数组容器也可以,原理同Vector是一样的,就是给所有的方法套上一层synchronized。

ArrayList的遍历和LinkedList遍历性能比较如何?

论遍历ArrayList要比LinkedList快得多,ArrayList遍历最大的优势在于内存的连续性,CPU的内部缓存结构会缓存连续的内存片段,可以大幅降低读取内存的性能开销。

扩容

基于1.8,int newCapacity = oldCapacity + (oldCapacity >> 1)此行代码即为扩容的核心,oldCapacity为原来的容量,右移一位,即除以2,因此这句的意思就是新的容量newCapacity=oldCapacity+oldCapacity /2,即原来的1.5倍。

  1. ArrayList每次扩容是原来得1.5倍。
  2. 数组进行扩容时,会将老数据中得元素重新拷贝一份道新的数组中,每次数组容量得增长大于时原用量得1.5倍。
  3. 代价是很高得,因此再实际使用时,我们因该避免数组容量得扩张。尽可能避免数据容量得扩张。尽可能,就至指定容量,避免数组扩容的发生。
  4. 创建方式不同,容量不同。
总结

ArrayList就是动态数组,用MSDN中的说法,就是Array的复杂版本,它提供了动态的增加和减少元素,实现了ICollection和IList接口,灵活的设置数组的大小等好处。

ArrayList源码解析,老哥,来一起复习一哈?

没啥特殊的

面试官问我同步容器(如Vector)的所有操作一定是线程安全的吗?我懵了!

虽然同步容器的所有方法都加了锁,但是对这些容器的复合操作无法保证其线程安全性。需要客户端通过主动加锁来保证。比如我们定义如下方法:

public Object deleteLast(Vector v){
    int lastIndex  = v.size()-1;
    v.remove(lastIndex);
}

上面这个方法是一个复合方法,包括size()和remove(),乍一看上去好像并没有什么问题,无论是size()方法还是remove()方法都是线程安全的,那么整个deleteLast方法应该也是线程安全的。

同步容易由于对其所有方法都加了锁,这就导致多个线程访问同一个容器的时候,只能进行顺序访问,即使是不同的操作,也要排队,如get和add要排队执行。这就大大的降低了容器的并发能力。

可以考虑并发容器。从Java5开始,java.util.concurent包下,提供了大量支持高效并发的访问的集合类,我们称之为并发容器。

图片

总结

同步容器是通过加锁实现线程安全的,并且只能保证单独的操作是线程安全的,无法保证复合操作的线程安全性。并且同步容器的读和写操作之间会互相阻塞。

并发容器是Java 5中提供的,主要用来代替同步容器。有更好的并发能力。而且其中的ConcurrentHashMap定义了线程安全的复合操作。

在多线程场景中,如果使用并发容器,一定要注意复合操作的线程安全问题。必要时候要主动加锁。

在并发场景中,建议直接使用java.util.concurent包中提供的容器类,如果需要复合操作时,建议使用有些容器自身提供的复合方法。

【JVM故事】了解JVM的结构,好在面试时吹牛

https://blog.csdn.net/ago_lei/article/details/88786176

img img

1.类加载器(ClassLoader):在JVM启动时或者在类运行时将需要的class加载到JVM中。

**2.执行引擎:**负责执行class文件中包含的字节码指令

3.内存区(也叫运行时数据区)

是在JVM运行的时候操作所分配的内存区。运行时内存区主要可以划分为5个区域:

1.方法区(Method Area):用于存储类结构信息的地方,包括常量池、静态变量、构造函数等。虽然JVM规范把方法区描述为堆的一个逻辑部分, 但它却有个别名non-heap(非堆),所以大家不要搞混淆了。方法区还包含一个运行时常量池。

2.java堆(Heap):存储java实例或者对象的地方。这块是GC的主要区域(后面解释)。从存储的内容我们可以很容易知道,方法区和堆是被所有java线程共享的。

3.java栈(Stack):java栈总是和线程关联在一起,每当创建一个线程时,JVM就会为这个线程创建一个对应的java栈。在这个java栈中又会包含多个栈帧,每运行一个方法就创建一个栈帧,用于存储局部变量表、操作栈、方法返回值等。每一个方法从调用直至执行完成的过程,就对应一个栈帧在java栈中入栈到出栈的过程。所以java栈是线程私有的。

4.程序计数器(PC Register):用于保存当前线程执行的内存地址。由于JVM程序是多线程执行的(线程轮流切换),所以为了保证线程切换回来后,还能恢复到原先状态,就需要一个独立的计数器,记录之前中断的地方,可见程序计数器也是线程私有的

5.本地方法栈(Native Method Stack):和java栈的作用差不多,只不过是为JVM使用到的native方法服务的。

4.本地方法接口:主要是调用C或C++实现的本地方法及返回结果。

写在最后

高级语言(Java,C#)中的很多操作如文件操作,网络操作,内存操作,线程操作,I/O操作等,都不是高级语言自身能够实现的。

也不是它们的虚拟机(JVM,CLR)能够实现的,实际最终是由操作系统实现的,因为这些都是系统资源,只有操作系统才有权限访问。

如果你用Java或C#代码创建了一个文件,千万不要以为是Java或C#创建了这个文件,它们只是层层向下调用了操作系统的API,然后到文件系统API,最后可能到磁盘驱动程序。

由此可以看出,要想设计一门语言,不单单是关键字、语法、编译器,类库,虚拟机这些,还要深度了解操作系统,甚至是硬件,如CPU架构和CPU指令集等。

所以,和语言相关的事情,每一项都是异常的繁琐复杂,都需要投入大量的人力、财力、时间去研究,最后即使研究成功了,可能没有生态,没人使用,自然也无法赚钱。

因此,国人现在还没有一门属于自己的真正语言。

Java堆内存是线程共享的!面试官:你确定吗?

总结

因为堆是全局共享的,因此在同一时间,可能有多个线程在堆上申请空间,那么,在并发场景中,如果两个线程先后把对象引用指向了同一个内存区域,怎么办。

为了保证对象的内存分配过程中的线程安全性,HotSpot虚拟机提供了一种叫做TLAB(Thread Local Allocation Buffer)的技术。

在线程初始化时,虚拟机会为每个线程分配一块TLAB空间,只给当前线程使用,当需要分配内存时,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。

所以,“堆是线程共享的内存区域”这句话并不完全正确,因为TLAB是堆内存的一部分,他在读取上确实是线程共享的,但是在内存分配上,是线程独享的。

我们说TLAB是线程独享的,但是只是在“分配”这个动作上是线程独享的,至于在读取、垃圾回收等动作上都是线程共享的。而且在使用上也没有什么区别。

还有一点需要注意的是,我们说TLAB是在eden区分配的,因为eden区域本身就不太大,而且TLAB空间的内存也非常小,默认情况下仅占有整个Eden空间的1%。所以,必然存在一些大对象是无法在TLAB直接分配。所以大对象还是可能需要在堆内存中直接分配。那么,对象的内存分配步骤就是先尝试TLAB分配,空间不足之后,再判断是否应该直接进入老年代,然后再确定是再eden分配还是在老年代分配。

图片

TLAB带来的问题

比如一个线程的TLAB空间有100KB,其中已经使用了80KB,当需要再分配一个30KB的对象时,就无法直接在TLAB中分配。为了解决这个问题,虚拟机定义了一个refill_waste的值,这个值可以翻译为“最大浪费空间”。

当请求分配的内存大于refill_waste的时候,会选择在堆内存中分配。若小于refill_waste值,则会废弃当前TLAB,重新创建TLAB进行对象内存分配。

前面的例子中,TLAB总空间100KB,使用了80KB,剩余20KB,如果设置的refill_waste的值为25KB,那么如果新对象的内存大于25KB,则直接堆内存分配,如果小于25KB,则会废弃掉之前的那个TLAB,重新分配一个TLAB空间,给新对象分配内存。

线上服务的FGC问题排查,看这篇就够了!

从一次FGC频繁的线上案例说起

4步:

  1. 检查JVM配置
  2. 观察老年代的内存变化
  3. 通过jmap命令查看堆内存中的对象
  4. 进一步dump堆内存文件进行分析
  5. 通过代码分析可疑对象
  6. 解决方案
GC的运行原理介绍
1. 堆内存结构

GC分为YGC和FGC,它们均发生在JVM的堆内存上。可以看到,堆内存采用了分代结构,包括新生代和老年代。新生代又分为:Eden区,From Survivor区(简称S0),To Survivor区(简称S1区),三者的默认比例为8:1:1。另外,新生代和老年代的默认比例为1:2。

图片
2. YGC是什么时候触发的?

大多数情况下,对象直接在年轻代中的Eden区进行分配,如果Eden区域没有足够的空间,那么就会触发YGC(Minor GC),YGC处理的区域只有新生代。因为大部分对象在短时间内都是可收回掉的,因此YGC后只有极少数的对象能存活下来,而被移动到S0区(采用的是复制算法)。

当触发下一次YGC时,会将Eden区和S0区的存活对象移动到S1区,同时清空Eden区和S0区。当再次触发YGC时,这时候处理的区域就变成了Eden区和S1区(即S0和S1进行角色交换)。每经过一次YGC,存活对象的年龄就会加1。

3.FGC又是什么时候触发的?

进入老年代的4种情况:

  • YGC时,To Survivor区不足以存放存活的对象,对象会直接进入到老年代。
  • 经过多次YGC后,如果存活对象的年龄达到了设定阈值(默认15次,可配置),则会晋升到老年代中。
  • 动态年龄判定规则,To Survivor区中相同年龄的对象,如果其大小之和占到了 To Survivor区一半以上的空间,那么大于此年龄的对象会直接进入老年代,而不需要达到默认的分代年龄。
  • 大对象:由-XX:PretenureSizeThreshold启动参数控制,若对象大小大于此值,就会绕过新生代, 直接在老年代中分配。

FGC触发条件:

  • 晋升到老年代的对象大于了老年代的剩余空间时
  • 老年代的内存使用率达到了一定阈值(可通过参数调整),直接触发FGC。
  • 空间分配担保:在YGC之前,会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。如果小于,说明YGC是不安全的,则会查看参数 HandlePromotionFailure 是否被设置成了允许担保失败,如果不允许则直接触发Full GC;如果允许,那么会进一步检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果小于也会触发 Full GC。
  • Metaspace(元空间)在空间不足时会进行扩容,当扩容到了-XX:MetaspaceSize 参数的指定值时,也会触发FGC。
  • System.gc() 或者Runtime.gc() 被显式调用时,触发FGC。
4. 在什么情况下,GC会对程序产生影响?

不管YGC还是FGC,都会造成一定程度的程序卡顿(即Stop The World问题:GC线程开始工作,其他工作线程被挂起),即使采用ParNew、CMS或者G1这些更先进的垃圾回收算法,也只是在减少卡顿时间,而并不能完全消除卡顿。

根据严重程度从高到底,我认为包括以下4种情况:FGC过于频繁,YGC耗时过长,FGC耗时过长,YGC过于频繁。

03 排查FGC问题的实践指南

清楚从程序角度,有哪些原因导致FGC?

  • 大对象:系统一次性加载了过多数据到内存中(比如SQL查询未做分页),导致大对象进入了老年代。
  • 内存泄漏:频繁创建了大量对象,但是无法被回收(比如IO对象使用完后未调用close方法释放资源),先引发FGC,最后导致OOM.
  • 程序频繁生成一些长生命周期的对象,当这些对象的存活年龄超过分代年龄时便会进入老年代,最后引发FGC. (即本文中的案例)
  • 程序BUG导致动态生成了很多新类,使得 Metaspace 不断被占用,先引发FGC,最后导致OOM.
  • 代码中显式调用了gc方法,包括自己的代码甚至框架中的代码。
  • JVM参数设置问题:包括总内存大小、新生代和老年代的大小、Eden区和S区的大小、元空间大小、垃圾回收算法等等。

清楚排查问题时能使用哪些工具

  • 公司的监控系统:大部分公司都会有,可全方位监控JVM的各项指标。

  • JDK的自带工具,包括jmap、jstat等常用命令:

    # 查看堆内存各区域的使用率以及GC情况

    jstat -gcutil -h20 pid 1000

    # 查看堆内存中的存活对象,并按空间排序

    jmap -histo pid | head -n20

    # dump堆内存文件

    jmap -dump:format=b,file=heap pid

  • 可视化的堆内存分析工具:JVisualVM(JDK自带)、MAT(eclipse)、JProfiler(IDEA)等。可以查看大对象,引用,类,线程等信息

排查指南

  • 查看监控,以了解出现问题的时间点以及当前FGC的频率(可对比正常情况看频率是否正常)
  • 了解该时间点之前有没有程序上线、基础组件升级等情况。
  • 了解JVM的参数设置,包括:堆空间各个区域的大小设置,新生代和老年代分别采用了哪些垃圾收集器,然后分析JVM参数设置是否合理。
  • 再对步骤1中列出的可能原因做排除法,其中元空间被打满、内存泄漏、代码显式调用gc方法比较容易排查
  • 针对大对象或者长生命周期对象导致的FGC,可通过 jmap -histo 命令并结合dump堆内存文件作进一步分析,需要先定位到可疑对象。
  • 通过可疑对象定位到具体代码再次分析,这时候要结合GC原理和JVM参数设置,弄清楚可疑对象是否满足了进入到老年代的条件才能下结论。

CPU飙高排查

场景:因为最近接入了新的业务,业务方给出的数据是日常QPS可以达到2000,大促峰值QPS可能会达到1万。压测过程中发现,当单机QPS达到200左右时,接口的rt没有明显变化,但是CPU利用率急剧升高,直到被打满。

总结来说,就是通过top命令找到占用率高的进程和对应线程。再通过jstack找到有问题的代码,也可以用阿里开源的Arthas工具。

场景:首页压测(thread local)、持仓压测(打到库上面)。

怎么排查堆内存溢出啊?

GC种类
Major GC

老年代的垃圾收集叫做Major GC,Major GC通常是跟full GC是等价的,收集整个GC堆。

分代GC
  1. Young GC:只收集年轻代的GC
  2. Old GC:只收集年老代的GC(只有CMS的concurrent collection是这个模式)
  3. Mixed GC:收集整个young gen以及部分old gen的GC(只有G1有这个模式)
Full GC

Full GC定义是相对明确的,就是针对整个新生代、老生代、元空间(metaspace,java8以上版本取代perm gen)的全局范围的GC。

其它

看上一篇

JVM必问知识点:类加载过程

类加载过程

系统加载 Class 类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析

图片

类加载过程的第一步,主要完成下面3件事情:

  1. 通过全类名获取定义此类的二进制字节流
  2. 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
  3. 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口

总结来说,就是读取class二进制字节流,将其转换为方法区的静态数据结构,然后在堆中生成一个可供用户调用的java.lang.Class对象。

数组类型不通过类加载器创建,它由 Java 虚拟机直接创建。

验证

验证流程其实是分散的,文件验证是在加载阶段进行的。元数据和字节码验证是连接的第一步,对其语法和语义进行分析,保证起不会产生危害虚拟机的行为。符号引用验证发生在解析阶段,可在初始化之前或之后进行。

准备

准备阶段是正式为类变量(静态变量)分配内存并设置类变量初始值的阶段。static变量赋零值,static final常量不是赋零值。

解析

解析就是符号引用变直接引用直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

A引用B,A类加载中,B未加载。这时候A持有B的符号引用,比如字符串s。然后加载B,B加载完后,内存中有B的地址,这时候讲符号引用s转化为实际指向B地址的直接引用。

解析这个过程不一定是发生在初始化之前。比如多态的后期绑定,引用的是接口或抽象类。实际引用的类可能在调用时才知道,这时候就发生在初始化之后。

初始化

对类变量进行初始化,比如成员变量代码中定义了初值,或者static代码块。

JVM快速入门篇

https://blog.csdn.net/qq_41784749/article/details/111570487

https://www.bilibili.com/video/BV1iJ411d7jS?p=9&spm_id_from=pageDriver

JVM 沙箱机制
Native、方法区

native

  • 凡是带native关键字,说明java的作用范围达不到,需要调用底层c语言库
  • 会进入本地方法栈,Native Method Area,登记native方法
  • 通过JNI调用本地方法接口
  • JNI作用:扩展Java使用,融合其它语言为Java所用,最初为C/C++设计
  • Java诞生时,C/C++为主流,想要立足,必须有调用底层C/C++的能力
  • Java驱动打印机,android的so等

PC寄存器

线程私有,指向方法区中的方法字节码,主要用来标识当前线程执行到哪一项。内存可以忽略不计

方法区

线程共享。静态变量、常量、类信息、运行时的常量池等在方法区中。

栈跟线程的生命周期同步,是一种数据结构,先进后出。栈内存不存在垃圾回收,随线程的消亡而回收。

栈存储的数据有:8大基本数据类型+对象引用+实例的方法

栈的运行原理:栈帧

程序正在执行的方法,一定在栈顶

栈满了抛出StackOverFlowError

永久代

这个时hotspot对方法区的实现,别的虚拟机都没这个概念。这个区域常驻内存,存储的是java运行时的一些环境或类的信息,这个区域不存在垃圾回收。关闭VM就会释放这个区域内存。如果永久代满了或者超过了临界值,会触发完全垃圾回收(Full Gc)

jdk 1.6:常量池在方法区

jdk 1.7:强调去永久代,这时候常量池在方法区

jdk 1.8:无永久代,常量池在元空间

元空间在本地内存,永久代在堆中实现分配。

元空间,逻辑上存在,物理上不存在。元空间是非堆,即不在堆上分配。它可以直接使用操作系统的内存,但是如果使用不当,也会导致操作系统死亡。可以通过-XX:MaxMetaspaceSize来控制其大小。

手动调节内存大小:

-Xms1024m -Xmx1024m -XX:+PrintGCDetails

-Xmx 堆内存的最大大小,默认值为你当前机器最大内存的 1/4
-Xms 堆内存的初始大小,默认值为你当前机器最大内存的 1/64

-Xmn 堆内新生代的大小。通过这个值也可以得到老生代的大小:-Xmx减去-Xmn

-Xss 设置每个线程可使用的内存大小,即栈的大小。

-XX:+PrintGCDetails 打印gc信息

-XX:+HeapDumpOnOutOfMemoryError oom dump

看完这篇垃圾回收,和面试官扯皮没问题了

JVM 内存区域

图片

  • 虚拟机栈:描述的是方法执行时的内存模型,是线程私有的,生命周期与线程相同,每个方法被执行的同时会创建栈桢
  • 本地方法栈:与虚拟机栈功能非常类似,主要区别在于虚拟机栈为虚拟机执行 Java 方法时服务,而本地方法栈为虚拟机执行本地方法时服务的。
  • 程序计数器:看作是当前线程执行的字节码的行号指示器。
  • 本地内存:线程共享区域,Java 8 中,本地内存,也是我们通常说的堆外内存,包含元空间和直接内存。在 Java 8 之前有个永久代的概念,实际上指的是 HotSpot 虚拟机上的永久代,它用永久代实现了 JVM 规范定义的方法区功能,主要存储类的信息,常量,静态变量,即时编译器编译后代码等,这部分由于是在堆中实现的,受 GC 的管理,不过由于永久代有 -XX:MaxPermSize 的上限,所以如果动态生成类(将类信息放入永久代)或大量地执行 String.intern (将字段串放入永久代中的常量区),很容易造成 OOM,有人说可以把永久代设置得足够大,但很难确定一个合适的大小,受类数量,常量数量的多少影响很大。所以在 Java 8 中就把方法区的实现移到了本地内存中的元空间中,这样方法区就不受 JVM 的控制了,也就不会进行 GC,也因此提升了性能(发生 GC 会发生 Stop The Word,造成性能受到一定影响,后文会提到),也就不存在由于永久代限制大小而导致的 OOM 异常了(假设总内存1G,JVM 被分配内存 100M, 理论上元空间可以分配 2G-100M = 1.9G,空间大小足够),也方便在元空间中统一管理。综上所述,在 Java 8 以后这一区域也不需要进行 GC堆外内存不受 GC控制,无法通过 GC 释放内存。通过Cleaner回收
  • :对象实例和数组都是在堆上分配的,GC 也主要对这两类数据进行回收。
如何识别垃圾
引用计数法

简单地说,就是对象被引用一次,在它的对象头上加一次引用次数,如果没有被引用(引用次数为 0),则此对象可回收。无法解决循环引用的问题,现代虚拟机一般不用。

可达性算法

以一系列叫做 GC Root 的对象为起点出发,引出它们指向的下一个节点,再以下个节点为起点,引出此节点指向的下一个结点。。。(这样通过 GC Root 串成的一条线就叫引用链),直到所有的结点都遍历完毕,如果相关对象不在任意一个以 GC Root 为起点的引用链中,则这些对象会被判断为「垃圾」,会被 GC 回收。

GC Roots 到底是什么东西呢,哪些对象可以作为 GC Root 呢,有以下几类

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。 如Test a = new Test();
  • 方法区中类静态属性引用的对象,如类中定义的static对象
  • 方法区中常量引用的对象,如类中定义的static final 常量
  • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。本地方法栈不像虚拟机栈的对象引用,只能通过简单动态链接连接到指定的本地方法。
垃圾回收的主要方法
标记清除算法
  1. 先根据可达性算法标记出相应的可回收对象(图中黄色部分)
  2. 对可回收的对象进行回收

缺点是容易产生内存碎片 ,另外需要两次遍历

复制算法

把堆等分成两块区域,创建对象在A中分配内存,回收时将A中存活的对象直接复制到B中,再清空A。速度快,但是存在空间浪费,使用率不高。在复制对象不多的时候适合使用。

标记整理法

前两步跟标记清除一样,加入了第3步,即内存整理,产生连续空间。因为存在着内存搬运的动作,效率低

分代收集算法

其实就是对上面这些算法的整合。

IBM 专业研究表明,一般来说,98% 的对象都是朝生夕死的,经过一次 Minor GC 后就会被回收。分代,就是分成新生代和老年代,1:2。新生代中又分为Eden区、from Survivor区、to Survivor区,比例为8:1:1。新生代发生的叫Minor GC(YGC),老年代的叫Old GC(Full GC)。

分代收集工作原理

1、对象在新生代的分配与回收

对象在Eden区分配内存。当Eden区快满的时候,触发YGC。将存活对象复制到S0,对象年龄+1,然后清除Eden区。下次触发YGC,将Eden区存活对象和S0存活对象全部放倒S1,同时清空YGC和S0。如此反复。YGC使用复制算法,因为新生代对象一般存活时间不长,朝生夕死,需要复制的对象不多,复制效率比较高。

2、对象何时晋升老年代

  1. 对象年龄达到我们设定的阈值,默认15
  2. 大对象 阈值可配置。因为大对象移动带来的开销大,同时也容易占满S0、S1区。
  3. S0(或S1)区中相同年龄对象之和大于其一半空间大小,年龄大于等于该想通年龄的对象晋升到老年代。

3、空间分配担保

在发生 MinorGC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果大于,那么Minor GC 可以确保是安全的,如果不大于,那么虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于则进行 Minor GC,否则可能进行一次 Full GC。

4、Stop The World

如果老年代满了,会触发 Full GC, Full GC 会同时回收新生代和老年代(即对整个堆进行GC),它会导致 Stop The World(简称 STW),造成挺大的性能开销。Minor GC 也会造成 STW,但只会触发轻微的 STW,因为 Eden 区的对象大部分都被回收了,只有极少数存活对象会通过复制算法转移到 S0 或 S1 区,所以相对还好。

一般 Full GC 会导致工作线程停顿时间过长(因为Full GC 会清理整个堆中的不可用对象,一般要花较长的时间),如果在此 server 收到了很多请求,则会被拒绝服务!所以我们要尽量减少 Full GC。

由于 Full GC(或Minor GC) 会影响性能,所以我们要在一个合适的时间点发起 GC,这个时间点被称为 Safe Point,这个时间点的选定既不能太少以让 GC 时间太长导致程序过长时间卡顿,也不能过于频繁以至于过分增大运行时的负荷。safepoint指的特定位置主要有:

  1. 循环的末尾 (防止大循环的时候一直不进入safepoint,而其他线程在等待它进入safepoint)

  2. 方法返回前

  3. 调用方法的call之后

  4. 抛出异常的位置

垃圾收集器种类

图片

  • 在新生代工作的垃圾回收器:Serial, ParNew, ParallelScavenge
  • 在老年代工作的垃圾回收器:CMS,Serial Old, Parallel Old
  • 同时在新老生代工作的垃圾回收器:G1
新生代收集器
Serial 收集器

Serial 收集器是工作在新生代的,单线程的垃圾收集器,单线程意味着它只会使用一个 CPU 或一个收集线程来完成垃圾回收。如果数据量大的话,STW时间会比较长。它比较适合Client端或其它内存不太大的虚拟机,简单有效,停顿时间也不长。

ParNew 收集器

ParNew 收集器是 Serial 收集器的多线程版本,除了使用多线程,其他像收集算法,STW,对象分配规则,回收策略与 Serial 收集器完成一样。除了Serial收集器,只有它能与 CMS 收集器配合工作,CMS 是一个划时代的垃圾收集器,是真正意义上的并发收集器

Parallel Scavenge 收集器

Parallel Scavenge 收集器也是一个使用复制算法多线程,工作于新生代的垃圾收集器,看起来功能和 ParNew 收集器一样。不能跟CMS配合使用。

关注点不同,CMS 等垃圾收集器关注的是尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 目标是达到一个可控制的吞吐量,更适合做后台运算等不需要太多用户交互的任务。

老年代收集器
Serial Old 收集器

Serial 收集器是工作于新生代的单线程收集器,与之相对地,Serial Old 是工作于老年代的单线程收集器,此收集器的主要意义在于给 Client 模式下的虚拟机使用,如果在 Server 模式下,则它还有两大用途:一种是在 JDK 1.5 及之前的版本中与 Parallel Scavenge 配合使用,另一种是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用(后文讲述)。

Parallel Old 收集器

Parallel Old 是相对于 Parallel Scavenge 收集器的老年代版本,使用多线程和标记整理法,两者组合示意图如下,这两者的组合由于都是多线程收集器,真正实现了「吞吐量优先」的目标。GC线程需要STW

CMS 收集器

CMS 收集器是以实现最短 STW 时间为目标的收集器,如果应用很重视服务的响应速度,希望给用户最好的体验,则 CMS 收集器是个很不错的选择!

而 CMS 虽然工作于老年代,但采用的是标记清除法,主要有以下四个步骤

  1. 初始标记
  2. 并发标记,会产生浮动垃圾和错标的情况
  3. 重新标记
  4. 并发清除

图片

从图中可以的看到初始标记和重新标记两个阶段会发生 STW,造成用户线程挂起,不过初始标记仅标记 GC Roots 能关联的对象,速度很快,并发标记是进行 GC Roots Tracing 的过程,重新标记是为了修正并发标记期间因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录,这一阶段停顿时间一般比初始标记阶段稍长,但远比并发标记时间短

整个过程中耗时最长的是并发标记和标记清理,不过这两个阶段用户线程都可工作,所以不影响应用的正常使用,所以总体上看,可以认为 CMS 收集器的内存回收过程是与用户线程一起并发执行的。

CMS的缺点:

  • CMS 收集器对 CPU 资源非常敏感。因为要跟用户线程一起执行。
  • CMS 无法处理浮动垃圾(Floating Garbage),可能出现 「Concurrent Mode Failure」而导致另一次 Full GC 的产生。由于在并发清理阶段用户线程还在运行,所以清理的同时新的垃圾也在不断出现,这部分垃圾只能在下一次 GC 时再清理掉(即浮云垃圾)。同时在垃圾收集阶段用户线程也要继续运行,就需要预留足够多的空间要确保用户线程正常执行,这就意味着 CMS 收集器不能像其他收集器一样等老年代满了再使用。JDK 1.5 默认当老年代使用了68%空间后就会被激活,可调整。预留内存无法满足用户线程使用时,触发Concurrent Mode Failure,需要使用Serial Old收集器单线程进行回收,STW时间更长。
  • CMS 采用的是标记清除法,会产生大量的内存碎片。可以启内存碎片的合并整理。
G1(Garbage First) 收集器

G1 收集器是面向服务端的垃圾收集器,被称为驾驭一切的垃圾回收器,主要有以下几个特点

  • 像 CMS 收集器一样,能与应用程序线程并发执行。
  • 整理空闲空间更快。
  • 需要 GC 停顿时间更好预测。
  • 不会像 CMS 那样牺牲大量的吞吐性能。
  • 不需要更大的 Java Heap

与 CMS 相比,它在以下两个方面表现更出色

  1. 运作期间不会产生内存碎片,G1 从整体上看采用的是标记-整理法,局部(两个 Region)上看是基于复制算法实现的,两个算法都不会产生内存碎片,收集后提供规整的可用内存,这样有利于程序的长时间运行。
  2. 在 STW 上建立了可预测的停顿时间模型,用户可以指定期望停顿时间,G1 会将停顿时间控制在用户设定的停顿时间以内。

图片

传统的收集器如果发生 Full GC 是对整个堆进行全区域的垃圾收集,而分配成各个 Region 的话,方便 G1 跟踪各个 Region 里垃圾堆积的价值大小(回收所获得的空间大小及回收所需经验值),这样根据价值大小维护一个优先列表,根据允许的收集时间,优先收集回收价值最大的 Region,也就避免了整个老年代的回收,也就减少了 STW 造成的停顿时间。同时由于只收集部分 Region,可就做到了 STW 时间的可控。

G1 收集器的工作步骤如下

  1. 初始标记
  2. 并发标记
  3. 最终标记
  4. 筛选回收

图片

可以看到整体过程与 CMS 收集器非常类似,筛选阶段会根据各个 Region 的回收价值和成本进行排序,根据用户期望的 GC 停顿时间来制定回收计划。

从官网的描述中,我们知道G1是一种服务器端的垃圾收集器,应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能的满足垃圾收集暂停时间的要求。它是专门针对以下应用场景设计的: * 像CMS收集器一样,能与应用程序线程并发执行。 * 整理空闲空间更快。 * 需要GC停顿时间更好预测。 * 不希望牺牲大量的吞吐性能。 * 不需要更大的Java Heap。

G1收集器的设计目标是取代CMS收集器,它同CMS相比,在以下方面表现的更出色: * G1是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片。 * G1的Stop The World(STW)更可控,G1在停顿时间上添加了预测机制,用户可以指定期望停顿时间。

总结

本文简述了垃圾回收的原理与垃圾收集器的种类,相信大家对开头提的一些问题应该有了更深刻的认识,在生产环境中我们要根据不同的场景来选择垃圾收集器组合,如果是运行在桌面环境处于 Client 模式的,则用 Serial + Serial Old 收集器绰绰有余,如果需要响应时间快,用户体验好的,则用 ParNew + CMS 的搭配模式,即使是号称是「驾驭一切」的 G1,也需要根据吞吐量等要求适当调整相应的 JVM 参数,没有最牛的技术,只有最合适的使用场景,切记!

https://www.bilibili.com/video/BV1sp4y1Y7ap?p=9&spm_id_from=pageDriver

可重入锁

什么是重入锁?

同一个线程,如果连续两次对同一把锁进行lock,对于一般的锁,会产生死锁。

void handle() {
    lock();
    lock();  //和上一个lock()操作同一个锁对象,那么这里就永远等待了
    unlock();
    unlock();
}

x

重入锁使得同一个线程可以对同一把锁,在不释放的前提下,反复加锁,而不会导致线程卡死。但是我们要保证unlock()和lock()的次数一样多。

Java中的重入锁

java.util.concurrent.locks.ReentrantLock,其对应的方法如下:

  • void lock():加锁,如果锁已经被别人占用了,就无限等待。复杂场景下可能死锁
  • boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException:尝试获取锁,等待timeout时间。同时,可以响应中断。好处:1、不用无限等待,不会死锁,超时后可以考虑释放资源。2、应用层可以自己实现自旋、次数自己定。3、锁等待过程中可以响应中断,比如关机,可以做清理操作
  • void unlock() :释放锁
  • public boolean tryLock():不会进行任何等待,如果能够获得锁,直接返回true,如果获取失败,就返回false,特别适合在应用层自己对锁进行管理,在应用层进行自旋等待。
重入锁的实现原理

图片

重入锁的核心功能委托给内部类Sync实现,并且根据是否是公平锁有FairSync和NonfairSync两种实现。这是一种典型的策略模式。实现重入锁的方法很简单,就是基于一个状态变量state。这个变量保存在AbstractQueuedSynchronizer对象中。当这个state==0时,表示锁是空闲的,大于零表示锁已经被占用, 它的数值表示当前线程重复占用这个锁的次数。

公平的重入锁

如果有1,2,3,4 这四个线程,按顺序,依次请求锁。那等锁可用的时候,谁会先拿到锁呢?在非公平情况下,答案是随机的。谁前抢到算谁的。而公平锁则是通过队列依次获取。

默认是非公平锁,性能更高。维护公平锁需要代价,维持公平是以牺牲系统性能为代价的。因为后入的抢占的线程可能通过CAS直接拿到锁,不需要将之前的线程唤醒。线程的唤醒需要切换上下文。非公平锁的lock会进行两次CAS尝试,失败后还是需要排队。

lock:

//非公平锁 
 final void lock() {
     //上来不管三七二十一,直接抢了再说
     if (compareAndSetState(0, 1))
         setExclusiveOwnerThread(Thread.currentThread());
     else
         //抢不到,就进队列慢慢等着
         acquire(1);
 }

 //公平锁
 final void lock() {
     //直接进队列等着
     acquire(1);
 }

trylock:

 //非公平锁 
 final boolean nonfairTryAcquire(int acquires) {
      final Thread current = Thread.currentThread();
      int c = getState();
      if (c == 0) {
          //上来不管三七二十一,直接抢了再说
          if (compareAndSetState(0, acquires)) {
              setExclusiveOwnerThread(current);
              return true;
          }
      }
      //如果就是当前线程占用了锁,那么就更新一下state,表示重复占用锁的次数
      //这是“重入”的关键所在
      else if (current == getExclusiveOwnerThread()) {
          //我又来了哦~~~
          int nextc = c + acquires;
          if (nextc < 0) // overflow
              throw new Error("Maximum lock count exceeded");
          setState(nextc);
          return true;
      }
      return false;
  }
 

 //公平锁
 protected final boolean tryAcquire(int acquires) {
     final Thread current = Thread.currentThread();
     int c = getState();
     if (c == 0) {
         //先看看有没有别人在等,没有人等我才会去抢,有人在我前面 ,我就不抢啦
         if (!hasQueuedPredecessors() &&
             compareAndSetState(0, acquires)) {
             setExclusiveOwnerThread(current);
             return true;
         }
     }
     else if (current == getExclusiveOwnerThread()) {
         int nextc = c + acquires;
         if (nextc < 0)
             throw new Error("Maximum lock count exceeded");
         setState(nextc);
         return true;
     }
     return false;
 }
Condition

Condition可以理解为重入锁的伴生对象。它提供了在重入锁的基础上,进行等待和通知的机制。可以使用 newCondition()方法生成一个Condition对象,如下所示。

 private final Lock lock = new ReentrantLock();
 private final Condition condition = lock.newCondition();

ArrayBlockingQueue是一个队列,你可以把元素塞入队列(enqueue),也可以拿出来take()。但是有一个小小的条件,就是如果队列是空的,那么take()就需要等待,一直等到有元素了,再返回。这个就借助于ReentrantLock和Condition实现。假定Condition notEmpty = lock.newCondition(),流程回忆一下:

  • 队列为空,notEmpty.await()
  • 放入元素,notEmpty.siginal()
  • notEmpty.await()被唤醒,继续执行,拿到第一个元素

await()其实是会释放锁的。

重入锁的使用示例
private final Lock lock = new ReentrantLock();
public void incr() {
        // 访问count时,需要加锁
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }
AQS

AQS的全称为(AbstractQueuedSynchronizer),这个类在java.util.concurrent.locks包下。

AQS的核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态,如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

AQS内部维护这个一个队列,通过双向链表实现。请求锁的的线程被封装成一个节点Node。大白话讲,AQS就是基于CLH队列,用volatile修饰共享变量state,线程通过CAS去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒。state + volatile

**注意:AQS是自旋锁:**在等待唤醒的时候,经常会使用自旋(while(!cas()))的方式,不停地尝试获取锁,直到被其他线程获取成功

AQS共享锁是通过countDownLatch计数器来实现的,比如设置默认5。状态依然通过state标识。

总结

可重入锁算是多线程的入门级别知识点,所以我把他当做多线程系列的第一章节,对于重入锁,我们需要特别知道几点:

  1. 对于同一个线程,重入锁允许你反复获得同一把锁,但是,申请和释放锁的次数必须一致。
  2. 默认情况下,重入锁是非公平的,公平的重入锁性能差于非公平锁
  3. 重入锁的内部实现是基于CAS操作的。
  4. 重入锁的伴生对象Condition提供了await()和singal()的功能,可以用于线程间消息通信。

敖丙稳住了多线程翻车的现场

竞争与协作

其实就是互斥和同步的概念。

临界区(critical section),它是访问共享资源的代码片段,一定不能给多线程同时执行。

互斥(mutualexclusion),也就说保证一个线程在临界区执行时,其他线程应该被阻止进入临界区

同步,就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步

同步与互斥是两种不同的概念:

  • 同步就好比:「操作 A 应在操作 B 之前执行」,「操作 C 必须在操作 A 和操作 B 都完成之后才能执行」等;
  • 互斥就好比:「操作 A 和操作 B 不能在同一时刻执行」;
互斥与同步的实现和使用

为了实现进程/线程间正确的协作,操作系统必须提供实现进程协作的措施和方法,主要的方法有两种:

  • :加锁、解锁操作;
  • 信号量:P、V 操作;

根据锁的实现不同,可以分为「忙等待锁」和「无忙等待锁」。忙等待锁又称自旋锁,当获取不到锁时,线程就会一直 wile 循环,不做任何事情。无等待锁顾明思议就是获取不到锁的时候,不用自旋,把当前线程放入到锁的等待队列,然后执行调度程序,把 CPU 让给其他线程执行。

P操作的主要动作是:

①S减1;

②若S减1后仍大于或等于0,则进程继续执行;

③若S减1后小于0,则该进程被阻塞后放入等待该信号量的等待队列中,然后转进程调度。

V操作的主要动作是:

①S加1;

②若相加后结果大于0,则进程继续执行;

③若相加后结果小于或等于0,则从该信号的等待队列中释放一个等待进程,然后再返回原进程继续执行或转进程调度。

经典同步问题
哲学就餐问题
  • 5 个老大哥哲学家,闲着没事做,围绕着一张圆桌吃面;
  • 巧就巧在,这个桌子只有 5 支叉子,每两个哲学家之间放一支叉子;
  • 哲学家围在一起先思考,思考中途饿了就会想进餐;
  • 奇葩的是,这些哲学家要两支叉子才愿意吃面,也就是需要拿到左右两边的叉子才进餐
  • 吃完后,会把两支叉子放回原处,继续思考

锁 or PV,自己思考

读者-写者问题
  • 「读-读」允许:同一时刻,允许多个读者同时读
  • 「读-写」互斥:没有写者时读者才能读,没有读者时写者才能写
  • 「写-写」互斥:没有其他写者时,写者才能写

多个信号量控制,思考一下

2w字 + 40张图带你参透并发编程!

图片

线程池的创建方式

在 Java 中,创建线程的方式主要有三种

  • 通过继承 Thread 类来创建线程
  • 通过实现 Runnable 接口来创建线程
  • 通过 CallableFuture 来创建线程

使用 Callable 接口的好处你已经知道了吧,既能够实现多个接口,也能够得到执行结果的返回值。Callable 和 Runnable 接口还是有一些区别的,主要区别如下

  • Callable 执行的任务有返回值,而 Runnable 执行的任务没有返回值
  • Callable(重写)的方法是 call 方法,而 Runnable(重写)的方法是 run 方法。
  • call 方法可以抛出异常,而 Runnable 方法不能抛出异常
public class CallableTask implements Callable {

    static int count;
    public CallableTask(int count){
        this.count = count;
    }

    @Override
    public Object call() {
        return count;
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {

        FutureTask<Integer> task = new FutureTask((Callable<Integer>) () -> {
            for(int i = 0;i < 1000;i++){
                count++;
            }
            return count;
        });
        Thread thread = new Thread(task);
        thread.start();

        Integer total = task.get();
        System.out.println("total = " + total);
    }
}

乐观锁、悲观锁

乐观锁

顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量。

乐观锁适用于写比较少的情况下,即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果经常产生冲突,上层应用会不断的进行retry,这样反倒是降低了性能,所以这种情况下用悲观锁就比较合适。

CAS就是乐观锁的一种实现。CAS的缺陷:

  • ABA的问题,可以通过加版本号、时间戳等方式来解决
  • CAS不成功时,自旋带来的性能损耗,CPU压力大
  • 只能保证一个共享变量的原子操作,如果要实现多个,可以使用AtomicReference
悲观锁

顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。

synchronized是悲观锁的实现。

synchronized锁方法时,通过ACC_SYNCHRONIZED标识来判定是否锁住。其它线程进入时看是否有该标识,有就认为被锁了。

synchronized锁代码段时,通过monitorenter和monitorexit来操作。

对象包含3部分数据,对象头、实例数据、对齐填充。对象头,以hotspot为例,主要包含Mark Word(标记字段)、Klass Pointer(类型指针)。

  • Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
  • Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

可以看到,对象头中保存了锁标志位和指向 monitor 对象的起始地址。每个对象都会与一个monitor相关联,当某个monitor被拥有之后就会被锁住,当线程执行到monitorenter指令时,就会去尝试获得对应的monitor。

  1. 每个monitor维护着一个记录着拥有次数的计数器。未被拥有的monitor的该计数器为0,当一个线程获得monitor(执行monitorenter)后,该计数器自增变为 1 。

    • 当同一个线程再次获得该monitor的时候,计数器再次自增;
    • 当不同线程想要获得该monitor的时候,就会被阻塞。
  2. 当同一个线程释放 monitor(执行monitorexit指令)的时候,计数器再自减。

    当计数器为0的时候,monitor将被释放,其他线程便可以获得monitor。

互斥锁、自旋锁、读写锁、悲观锁、乐观锁的应用场景

互斥锁与自旋锁:谁更轻松自如?

当已经有一个线程加锁后,其他线程加锁则就会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不一样的:

  • 互斥锁加锁失败后,线程会释放 CPU ,给其他线程;
  • 自旋锁加锁失败后,线程会忙等待,直到它拿到锁;

对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。即两次线程上下文切换

上下切换的耗时有大佬统计过,大概在几十纳秒到几微秒之间,如果你锁住的代码执行时间比较短,那可能上下文切换的时间都比你锁住的代码执行时间还要长。如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁。

CAS 函数是个原子原子指令

当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对

读写锁:读和写还有优先级区分?

读写锁在读多写少的场景,能发挥出优势。读写锁的工作原理是:

  • 当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
  • 但是,一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。

根据实现的不同,读写锁可以分为「读优先锁」和「写优先锁」。举例ABC三个线程,A读B写C读。

读优先:A先拿到读锁,然后B拿写锁阻塞,C拿读锁阻塞。A读锁释放后,C先拿读锁,B继续阻塞。AC都完成后,B才能拿到写锁。

写优先:A先拿到读锁,然后B拿写锁阻塞,C拿读锁阻塞。A读锁释放后,B先拿写锁,C继续阻塞。B完成后,C才能拿到读锁。

读优先锁,如果一直有读线程获取读锁,那么写线程将永远获取不到写锁,这就造成了写线程「饥饿」的现象。写优先锁可以保证写线程不会饿死,但是如果一直有写线程获取写锁,读线程也会被「饿死」。可以用公平读写锁。

公平读写锁比较简单的一种方式是:用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁即可,这样读线程仍然可以并发,也不会出现「饥饿」的现象。

互斥锁和自旋锁都是最基本的锁,读写锁可以根据场景来选择这两种锁其中的一个进行实现。

乐观锁与悲观锁:做事的心态有何不同?

悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁

乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作

乐观锁全程并没有加锁,所以它也叫无锁编程

乐观锁场景:在线文档编辑。如果用悲观,只能一个人编辑,效率低。可以通过版本号去解决冲突。像svn、git都是基于乐观锁的思想实现的。

乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。

总结

开发过程中,最常见的就是互斥锁的了,互斥锁加锁失败时,会用「线程切换」来应对,当加锁失败的线程再次加锁成功后的这一过程,会有两次线程上下文切换的成本,性能损耗比较大。

如果我们明确知道被锁住的代码的执行时间很短,那我们应该选择开销比较小的自旋锁,因为自旋锁加锁失败时,并不会主动产生线程切换,而是一直忙等待,直到获取到锁,那么如果被锁住的代码执行时间很短,那这个忙等待的时间相对应也很短。

如果能区分读操作和写操作的场景,那读写锁就更合适了,它允许多个读线程可以同时持有读锁,提高了读的并发性。根据偏袒读方还是写方,可以分为读优先锁和写优先锁,读优先锁并发性很强,但是写线程会被饿死,而写优先锁会优先服务写线程,读线程也可能会被饿死,那为了避免饥饿的问题,于是就有了公平读写锁,它是用队列把请求锁的线程排队,并保证先入先出的原则来对线程加锁,这样便保证了某种线程不会被饿死,通用性也更好点。

互斥锁和自旋锁都是最基本的锁,读写锁可以根据场景来选择这两种锁其中的一个进行实现。

另外,互斥锁、自旋锁、读写锁都属于悲观锁,悲观锁认为并发访问共享资源时,冲突概率可能非常高,所以在访问共享资源前,都需要先加锁。

相反的,如果并发访问共享资源时,冲突概率非常低的话,就可以使用乐观锁,它的工作方式是,在访问共享资源时,不用先加锁,修改完共享资源后,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。

但是,一旦冲突概率上升,就不适合使用乐观锁了,因为它解决冲突的重试成本非常高。

不管使用的哪种锁,我们的加锁的代码范围应该尽可能的小,也就是加锁的粒度要小,这样执行速度会比较快。再来,使用上了合适的锁,就会快上加快了。

面试官想到,一个Volatile,敖丙都能吹半小时

JMM
https://mp.weixin.qq.com/s?__biz=MzkxNDEyOTI0OQ==&mid=2247484428&idx=1&sn=efceba0b2b7e0b78ba50de3ad69945f5&source=41#wechat_redirect

JavaMemoryModel,java内存模型,是java虚拟机规范中所定义的一种内存模型,Java内存模型是标准化的,屏蔽掉了底层不同计算机的区别。Java内存模型就是为了解决多线程环境下共享变量的一致性问题。

内存模型

定义了程序中各个变量的访问规则,这里所说的变量包括实例字段、静态字段,但不包括局部变量和方法参数

  • Java内存模型规定了所有的变量都存储在主内存中
  • 工作内存中保存着该线程使用到的变量的主内存副本的拷贝
  • 线程对变量的操作都必须在工作内存中进行,包括读取和赋值等,而不能直接读写主内存中的变量
  • 不同的线程之间也无法直接访问对方工作内存中的变量
  • 线程间变量值的传递必须通过主内存来完成
工作内存和主存之间的操作
8种操作
  • lock:作用于主存,把变量标示成线程独享
  • unlock:作用于主存,把锁定的变量释放出来
  • read:作用于主存,把主存中的变量读到工作内存
  • load:作用于工作内存,把read的内存放到工作内存的变量副本中
  • store:作用于工作内存,把工作内存的变量传递给主存
  • write:作用于主存,把store的内容放到主存的变量中
  • assign:作用于工作内存,把执行引擎收到的变量赋值给工作内存的变量
  • use:作用于工作内存,把变量交给执行引擎
8种规则
  • 不允许read/load、store/write单独出现
  • 不允许线程丢弃最近的assign操作
  • 不允许线程在没有assign操作就把变量拷贝到主存
  • 新的变量必须在主存中诞生,不允许工作内存中的使用未进行load或者assign的变量,或者说只能使用assgin、load的变量
  • lock只允许一个线程操作,但是可以多次。在解锁的时候调用多次unlock
  • 执行lock操作,需要清空工作内存中的值,如果需要使用则需要从主存中加载
  • 如果变量没有lock,不允许unlock
  • 对变量lock前,必须先写回主存
JMM 要实现的功能
原子性

主存和工作内存之间的操作对***基本数据类型***是原子的

对复杂类型,jmm提供了lock/unlock、synchronized来保证原子性

可见性

volatile:普通变量与volatile变量的主要区别是是否会在修改之后立即同步回主内存,以及是否在每次读取前立即从主内存刷新

synchronized的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中,即执行store和write操作”这条规则获取的。

final的可见性是指被final修饰的字段在构造器中一旦被初始化完成,那么其它线程中就能看见这个final字段了

有序性

Java中提供了volatile和synchronized两个关键字来保证有序性

volatile天然就具有有序性,因为其禁止重排序。

synchronized的有序性是由“一个变量同一时刻只允许一条线程对其进行lock操作”这条规则获取的。

先行发生原则(Happens-Before)

主要定义了一些A操作必须先于B操作发生的场景

  1. 程序次序原则

    在一个线程内,按照程序书写的顺序执行,书写在前面的操作先行发生于书写在后面的操作,准确地讲是控制流顺序而不是代码顺序,因为要考虑分支、循环等情况。

  2. 监视器锁定原则

    一个unlock操作先行发生于后面对同一个锁的lock操作。

  3. volatile原则

    对一个volatile变量的写操作先行发生于后面对该变量的读操作。

  4. 线程启动原则

    对线程的start()操作先行发生于线程内的任何操作

  5. 线程终止原则

    线程中的所有操作先行发生于检测到线程终止,可以通过Thread.join()、Thread.isAlive()的返回值检测线程是否已经终止

  6. 线程中断原则

    对线程的interrupt()的调用先行发生于线程的代码中检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否发生中断。

  7. 对象终结原则

    一个对象的初始化完成(构造方法执行结束)先行发生于它的finalize()方法的开始。

  8. 传递性原则

    如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C

volatile
  • 可见性

    采用JMM的规定,在写后立马刷新到主存。使用从主存中读取最新的。

    volatile的底层实现满足了MESI的触发实现,才让缓存一致性得到了保障。早期的CPU架构不满足MESI,通过“锁总线”的方式实现,不过代价很大。

  • 指令重排

    内存屏障

    1. 阻止屏障两侧的指令重排
    2. 强制把写缓冲区或者高速缓存中的数据写回主存,让缓存中的数据失效

volatile关键字只能保证可见性和有序性,不能保证原子性,要解决原子性的问题,还是只能通过加锁或使用原子类的方式解决。

总结

JMM:是什么?内存模型?定义的8大原子操作及规则?并发场景对共享变量访问的一致性的一些实现?happens-before原则(定义了8个A先于B的场景)

volatile:如上即可

Synchronized

https://www.bilibili.com/video/BV1tz411q7c2?p=2&spm_id_from=pageDriver

Java的CAS,通过native方法去调用底层实现,比如AtomicInteger的incrementAndGet方法,最后是调用到Unsafe的native方法compareAndSwapInt。最后走到lock cmpxchg指令(lock compare exchange)。cmpxchg也不是原子的,单cpu是原子的,多cpu架构下,其它cpu可能会更改。所以加了lock,将总线锁住。

对象的组成

三部分:对象头、实例数据、对齐填充(padding)。

对象头:Mark Word(8个字节),klass pointer(指向所属的类xx.class,压缩后4字节,不压缩8字节,默认4)

padding:比如64位虚拟机,对象大小必须被8整除。如果前面共分配了12个字节,那padding就是4字节。

Mark Word中记录:锁信息(无锁、偏向锁、轻量级、重量级)、gc信息、hashcode。在不同的锁状态下,标识位不一样。平时说的加锁,就是修改markword中的内容。

synchronized锁升级

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-b6ABykE4-1639215724610)(/Users/mac/Library/Application Support/typora-user-images/image-20210802163759991.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OExA3TQs-1639215724611)(/Users/mac/Library/Application Support/typora-user-images/image-20210802195223311.png)]

锁升级发展历程

1.2之前之内重量级锁,1.2~1.5做了优化,1.6后又是个分水岭。

为什么叫重量级锁,因为申请资源必须需要通过内核调用,包括后续唤醒也要通过中断。

偏向锁在1.6之后是默认开启的,1.5中是关闭的,需要手动开启参数是xx:-UseBiasedLocking=false。另外,轻量级锁向重量级升级的过程,在1.7之前2个维度:默认某个线程自旋次数超过10次,自旋cpu大于cpu核数/2。这两个参数可以通过参数配置。1.7之后取消了这两个配置,有jvm通过算法自适应调整。

为什么有了轻量级锁还需要重量级锁

CAS自旋会给cpu带来压力,单线程自旋过多不合适,另外自旋线程数过多也不行。

偏向锁的效率一定比轻量级锁高吗

不一定,如果明确知道有批量的线程马上要进行竞争,那么偏向锁会马上转化为自旋锁。转化过程中,轻量级锁需要撤销,会有一定的消耗。所以这时候可以直接上轻量级锁、重量级锁。

JVM启动4s后才会开启偏向锁,就是因为启动过程中会有大量逻辑处理需要加锁,会出现大量竞争的情况。偏向锁很容易转化为轻量级锁,这种情况下可以直接转轻量级再到重量级。所以jvm会有延迟开启偏向锁的处理。这个延迟时间可设置,也可设为0。

new对象后,如果偏向锁已启动,尾部标识101,已启动就是001。所以4s前是001,为普通对象。4s后为101,就是匿名偏向。

JIT

Just in time compiler,即时编译器。java为解释型语言,运行的时候需要一行行解释。但是一些热点代码可以通过即时编译器编译成汇编码,后续需要用的时候直接拿出来即可。

ThreadLocal

看马士兵的教程

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值