- Java基础
- JAVA基础八股文
- Java I/O
- Java多线程
- Java线程池
- 为什么要用线程池
- 线程池的创建方式
- 线程的生命周期
- 线程的五个生命周期
- 僵死进程
- JVM
- JVM相关知识
- JVM内存模型
- JVM线程模型
- 垃圾回收
- 可达性分析算法
- 引用类型
- 常见垃圾回收算法
- 堵塞IO模型。
最传统的一种 IO 模型,即在读写数据过程中会发生阻塞现象。当用户线程发出 IO 请求之后,内核会去查看数据是否就绪,如果没有就绪就会等待数据就绪,而用户线程就会处于阻塞状态,用户线程交出 CPU。当数据就绪之后,内核会将数据拷贝到用户线程,并返回结果给用户线程,用户线程才解除block 状态。典型的阻塞 IO 模型的例子为:data = socket.read();如果数据没有就绪,就会一直阻塞在 read 方法。 - 非堵塞IO模型。
当用户线程发起一个 read *** 作后,并不需要等待,而是马上就得到了一个结果。如果结果是一个error 时,它就知道数据还没有准备好,于是它可以再次发送 read *** 作。一旦内核中的数据准备好了,并且又再次收到了用户线程的请求,那么它马上就将数据拷贝到了用户线程,然后返回。所以事实上,在非阻塞 IO 模型中,用户线程需要不断地询问内核数据是否就绪,也就说非阻塞 IO不会交出 CPU,而会一直占用 CPU。但是对于非阻塞 IO 就有一个非常严重的问题,在 while 循环中需要不断地去询问内核数据是否就绪,这样会导致 CPU 占用率非常高,因此一般情况下很少使用 while 循环这种方式来读取数据。 - 多路复用 IO 模型
多路复用 IO 模型是目前使用得比较多的模型。Java NIO 实际上就是多路复用 IO。在多路复用 IO模型中,会有一个线程不断去轮询多个 socket 的状态,只有当 socket 真正有读写事件时,才真正调用实际的 IO 读写 *** 作。因为在多路复用 IO 模型中,只需要使用一个线程就可以管理多个socket,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,并且只有在真正有socket 读写事件进行时,才会使用 IO 资源,所以它大大减少了资源占用。在 Java NIO 中,是通过 selector.select()去查询每个通道是否有到达事件,如果没有事件,则一直阻塞在那里,因此这种方式会导致用户线程的阻塞。多路复用 IO 模式,通过一个线程就可以管理多个 socket,只有当socket 真正有读写事件发生才会占用资源来进行实际的读写 *** 作。因此,多路复用 IO 比较适合连接数比较多的情况。
另外多路复用 IO 为何比非阻塞 IO 模型的效率高?因为在非阻塞 IO 中,不断地询问 socket 状态
时通过用户线程去进行的,而在多路复用 IO 中,轮询每个 socket 状态是内核在进行的,这个效
率要比用户线程要高的多。
不过要注意的是,多路复用 IO 模型是通过轮询的方式来检测是否有事件到达,并且对到达的事件
逐一进行响应。因此对于多路复用 IO 模型来说,一旦事件响应体很大,那么就会导致后续的事件
迟迟得不到处理,并且会影响新的事件轮询。 - 信号驱动IO模型
在信号驱动 IO 模型中,当用户线程发起一个 IO 请求 *** 作,会给对应的 socket 注册一个信号函数,然后用户线程会继续执行,当内核数据就绪时会发送一个信号给用户线程,用户线程接收到信号之后,便在信号函数中调用 IO 读写 *** 作来进行实际的 IO 请求 *** 作。 - 异步 IO 模型
异步 IO 模型才是最理想的 IO 模型,在异步 IO 模型中,当用户线程发起 read *** 作之后,立刻就可以开始去做其它的事。而另一方面,从内核的角度,当它受到一个 asynchronous read 之后,它会立刻返回,说明 read 请求已经成功发起了,因此不会对用户线程产生任何 block。然后,内核会等待数据准备完成,然后将数据拷贝到用户线程,当这一切都完成之后,内核会给用户线程发送一个信号,告诉它 read *** 作完成了。也就说用户线程完全不需要实际的整个 IO *** 作是如何进行的,只需要先发起一个请求,当接收内核返回的成功信号时表示 IO *** 作已经完成,可以直接去使用数据了。
也就说在异步 IO 模型中,IO *** 作的两个阶段都不会阻塞用户线程,这两个阶段都是由内核自动完成,然后发送一个信号告知用户线程 *** 作已完成。用户线程中不需要再次调用 IO 函数进行具体的读写。这点是和信号驱动模型有所不同的,在信号驱动模型中,当用户线程接收到信号表示数据已经就绪,然后需要用户线程调用 IO 函数进行实际的读写 *** 作;而在异步 IO 模型中,收到信号表示 IO *** 作已经完成,不需要再在用户线程中调用 IO 函数进行实际的读写 *** 作。
- 降低资源消耗。通过重复利用已创建的线程降低线程创建、销毁线程造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配、调优和监控。
- 提供更多更强大的功能:线程池具备可拓展性,允许开发人员向其中增加更多的功能。比如延时定时线程池ScheduledThreadPoolExecutor,就允许任务延期执行或定期执行。
线程池的创建方法总共有 7 种,但总体来说可分为 2 类:
一类是通过 ThreadPoolExecutor 创建的线程池;另一个类是通过 Executors 创建的线程池。
线程池的创建方式总共包含以下 7 种(其中 6 种是通过 Executors 创建的,1 种是通过ThreadPoolExecutor 创建的):
Executors.newFixedThreadPool:创建一个固定大小的线程池,可控制并发的线程数,超出的线程会在队列中等待;
Executors.newCachedThreadPool:创建一个可缓存的线程池,若线程数超过处理所需,缓存一段时间后会回收,若线程数不够,则新建线程;
Executors.newSingleThreadExecutor:创建单个线程数的线程池,它可以保证先进先出的执行顺序;
Executors.newScheduledThreadPool:创建一个可以执行延迟任务的线程池;
Executors.newSingleThreadScheduledExecutor:创建一个单线程的可以执行延迟任务的线程池;
Executors.newWorkStealingPool:创建一个抢占式执行的线程池(任务执行顺序不确定)【JDK 1.8 添加】。
ThreadPoolExecutor:最原始的创建线程池的方式,它包含了 7 个参数可供设置。
一般来说,推荐使用ThreadPoolExecutor创建线程池,Executors 返回的线程池对象的弊端如下:
1) FixedThreadPool 和 SingleThreadPool:允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。
2)CachedThreadPool:允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。
新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)和死亡(Dead)五种。
- 线程的实现有两种方式,一是继承Thread类,二是实现Runnable接口,但无论如何,当我们new了这个对象后。线程就进入了初始状态;
- 当该对象调用了start()方法,就进入可执行状态;
- 进入可执行状态后,当该对象被 *** 作系统选中。获得CPU时间片就会进入执行状态;
- 进入执行状态后情况就比較复杂了
4.1. run()方法或main()方法结束后,线程就进入终止状态;
4.2. 当线程调用了自身的sleep()方法或其它线程的join()方法,就会进入堵塞状态(该状态既停止当前线程,但并不释放所占有的资源)。当sleep()结束或join()结束后。该线程进入可执行状态,继续等待OS分配时间片;
4.3. 线程调用了yield()方法,意思是放弃当前获得的CPU时间片,回到可执行状态,这时与其它进程处于同等竞争状态,OS有可能会接着又让这个进程进入执行状态。
4.4. 当线程刚进入可执行状态(注意,还没执行),发现将要调用的资源被synchroniza(同步),获取不到锁标记。将会马上进入锁池状态,等待获取锁标记(这时的锁池里或许已经有了其它线程在等待获取锁标记,这时它们处于队列状态,既先到先得),一旦线程获得锁标记后,就转入可执行状态。等待OS分配CPU时间片。
4.5. 当线程调用wait()方法后会进入等待队列(进入这个状态会释放所占有的全部资源,与堵塞状态不同)。进入这个状态后。是不能自己主动唤醒的,必须依靠其它线程调用notify()或notifyAll()方法才干被唤醒(因为notify()仅仅是唤醒一个线程,但我们由不能确定详细唤醒的是哪一个线程。或许我们须要唤醒的线程不可以被唤醒,因此在实际使用时,一般都用notifyAll()方法,唤醒有所线程),线程被唤醒后会进入锁池。等待获取锁标记。
什么是僵死进程:
僵死进程就是指子进程退出时,父进程并未对其发出的SIGCHLD信号进行适当处理,导致子进程停留在僵死状态等待其父进程,这个状态下的子进程就是僵死进程。这个僵死进程不占有内存,也不会执行代码,更不能被调用,他只是在进程列表中占了个地位而已。
如何结束僵死进程:
- 他只需要父进程调用wait()函数来替他收尸然后就完整的结束这一生。否则会一直保存这个僵死的状态。
- 把他父进程给kill了,这样他就变成了一个孤儿进程,父亲没了没人替他收拾,这时候僵死进程就会被过继给一个名叫init()进程,这个进程会给他收尸。
JVM包括五块数据区域:
其中,JVM堆从GC角度看看能被分为新生代(Eden 区、From Survivor 区和 To Survivor 区)和老年
代。如下图所示:
名词解释:
- 新生代。是用来存放新生的对象。一般占据堆的 1/3 空间。由于频繁创建对象,所以新生代会频繁触发MinorGC 进行垃圾回收。新生代又分为 Eden 区、ServivorFrom、ServivorTo 三个区。
- Eden区。Java 新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当 Eden 区内存不够的时候就会触发 MinorGC,对新生代区进行一次垃圾回收。
- ServivorFrom。 上一次 GC 的幸存者,作为这一次 GC 的被扫描者。
- ServivorTo。保留了一次 MinorGC 过程中的幸存者。
- 老年代。主要存放应用程序中生命周期长的内存对象。老年代的对象比较稳定,所以 MajorGC 不会频繁执行。在进行 MajorGC 前一般都先进行了一次 MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次 MajorGC 进行垃圾回收腾出空间。 MajorGC 采用标记清除算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。MajorGC 的耗时比较长,因为要扫描再回收。MajorGC 会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。当老年代也满了装不下的时候,就会抛出 OOM(Out of Memory)异常。
- 永久代。指内存的永久保存区域,主要存放 Class 和 meta(元数据)的信息,Class 在被加载的时候被放入永久区域,它和存放实例的区域不同,GC 不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的 Class 的增多而胀满,最终抛出 OOM 异常。在 Java8 中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。元空间的本质和永久代类似,元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入 java 堆中,这样可以加载多少类的元数据就不再由MaxPermSize 控制, 而由系统的实际可用空间来控制。
元数据的定义:
- It’s the model of the loaded class base that Java retains at runtime in order to dynamically load, link, JIT compile, and execute Java code.
- Different design choices you make when writing your code can significantly expand or contract the amount of metadata Java needs to retain.
- The JVM can give you a breakdown of metadata storage costs for individual structures that model each loaded class, allowing you to weigh and compare the costs of alternative designs.
这部分参考资料:https://developers.redhat.com/blog/2018/02/14/java-class-metadata
GC过程:
新生代GC采用复制算法。称为Minor GC。
- 首先,把 Eden 和 ServivorFrom 区域中存活的对象复制到 ServicorTo 区域(如果对象的年龄已经达到了老年的标准,则赋值到老年代区),同时把这些对象的年龄+1(如果 ServicorTo 不够位置了就放到老年区)。
- 清空 eden、servicorFrom,以及互换这两个区域名字。
一般来说,线程模型有三种,分别是:
- 内核线程模型。
完全由 *** 作系统内核提供的内核线程(Kernel-Level Thread ,KLT)来实现多线程。在此模型下,线程的切换调度由系统内核完成,系统内核负责将多个线程执行的任务映射到各个CPU中去执行。 *** 作系统内核提供的内核线程(Kernel-Level Thread ,KLT)来实现多线程。在此模型下,线程的切换调度由系统内核完成,系统内核负责将多个线程执行的任务映射到各个CPU中去执行。由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使有一个轻量级进程在系统调用中阻塞了,也不会影响整个进程继续工作,但是轻量级进程具有它的局限性:
首先,由于是基于内核线程实现的,所以各种线程 *** 作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。
其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。
- 用户线程模型。
用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此 *** 作可以是非常快速且低消耗的,也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之间1:N的关系称为一对多的线程模型。使用用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程 *** 作都需要用户程序自己处理。线程的创建、切换和调度都是需要考虑的问题,而且由于 *** 作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”、“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难,甚至不可能完成。因而使用用户线程实现的程序一般都比较复杂,此处所讲的“复杂”与“程序自己完成线程 *** 作”,并不限制程序中必须编写了复杂的实现用户线程的代码,使用用户线程的程序,很多都依赖特定的线程库来完成基本的线程 *** 作,这些复杂性都封装在线程库之中,除了以前在不支持多线程的 *** 作系统中(如DOS)的多线程程序与少数有特殊需求的程序外,现在使用用户线程的程序越来越少了,Java、Ruby等语言都曾经使用过用户线程,最终又都放弃使用它。
- 混合线程模型
线程除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式。在这种混合实现下,既存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等 *** 作依然廉价,并且可以支持大规模的用户线程并发。而 *** 作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级线程来完成,大大降低了整个进程被完全阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,即为N:M的关系。许多UNIX系列的 *** 作系统,如Solaris、HP-UX等都提供了N:M的线程模型实现。
对于Sun JDK来说,它的Windows版与Linux版都是使用一对一的线程模型(即内核线程模型)实现的,一条Java线程就映射到一条轻量级进程之中,因为Windows和Linux系统提供的线程模型就是一对一的。
JVM 允许一个应用并发执行多个线程。Hotspot JVM 中的 Java 线程与原生 *** 作系统线程有直接的映射关系。当线程本地存储、缓冲区分配、同步对象、栈、程序计数器等准备好以后,就会创建一个 *** 作系统原生线程。Java 线程结束,原生线程随之被回收。 *** 作系统负责调度所有线程,并把它们分配到任何可用的 CPU 上。当原生线程初始化完毕,就会调用 Java 线程的 run() 方法。当线程结束时,会释放原生线程和 Java 线程的所有资源。
此部分参考资料:https://www.cnblogs.com/kaleidoscope/p/9598140.html
Hotspot JVM 后台运行的系统线程主要有下面几个:
此部分参考资料: https://openjdk.java.net/groups/hotspot/docs/RuntimeOverview.html
垃圾回收 可达性分析算法可达性分析中GC Roots对象包括下列几种:
- 虚拟机栈引用的对象
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中JNI引用的对象。
- 虚拟机内部的引用,比如基本数据类型对应的class对象。
- 所有同步锁持有的对象。
- 反应虚拟机内部情况的JMXBean、JVMTI注册的回调,本地代码缓存等。
- 局部回收时,某个区域中的对象完全可能被其他区域的对象所引用,所以关联区域的对象也一并加入GC Roots。
- 强引用。即传统引用。
- 软引用。用来描述还有用,但非必须的对象。仅在系统内存即将溢出时回收。SoftReference类。
- 弱引用。必须程度小于软引用。在下一次垃圾收集时回收。WeakReference类。
- 虚引用。最弱的引用,一个对象是否有虚引用的存在,不会对其生存时间构成影响,也无法通过虚引用获取一个对象实例。虚引用一版用于在一个对象被回收时收到一个系统通知。PhantomReference类。
- 标记清除算法(mark-sweep)。标记所有需要回收的对象,并在标记完成后,统一回收所有被标记的对象。会产生内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题,并且如果Java堆中包含大量需要回收的对象,必须进行大量标记和清除的动作。
- 复制算法。 为了解决 Mark-Sweep 算法内存碎片化的缺陷而被提出的算法。按内存容量将内存划分为等大小的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉。这种算法虽然实现简单,内存效率高,不易产生碎片,但是最大的问题是可用内存被压缩到了原本的一半。且存活对象增多的话,Copying 算法的效率会大大降低。
- 标记整理算法(Mark-Compact)。结合了以上两个算法,为了避免缺陷而提出。标记阶段和 Mark-Sweep 算法相同,标记后不是清理对象,而是将存活对象移向内存的一端。然后清除端边界外的对象。
- 分代收集算法。分代收集法是目前大部分 JVM 所采用的方法,其核心思想是根据对象存活的不同生命周期将内存划分为不同的域,一般情况下将 GC 堆划分为老生代(Tenured/Old Generation)和新生代(Young Generation)。老生代的特点是每次垃圾回收时只有少量对象需要被回收,新生代的特点是每次垃圾回收时都有大量垃圾需要被回收,因此可以根据不同区域选择不同的算法。
目前大部分 JVM 的 GC 对于新生代都采取 Copying 算法,因为新生代中每次垃圾回收都要
回收大部分对象,即要复制的 *** 作比较少,但通常并不是按照 1:1 来划分新生代。一般将新生代
划分为一块较大的 Eden 空间和两个较小的 Survivor 空间(From Space, To Space),每次使用
Eden 空间和其中的一块 Survivor 空间,当进行回收时,将该两块空间中还存活的对象复制到另
一块 Survivor 空间中。
而老年代因为每次只回收少量对象,因而采用 Mark-Compact 算法。
当对象在 Survivor 区躲过一次 GC 后,其年龄就会+1。默认情况下年龄到达 15 的对象会被
移到老生代中。
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)