长期的应试教育让我花了太久才明白：学习其实是一件只与自己相关的事。 现在的我，更愿意主动探索那些真正能激发好奇心、让生活变得更有趣的事物。 如果你有能够启发我、让我产生兴趣的想法、机会或故事，欢迎写信给我： （ljr0115@outlook.com） 我会优先回复那些 与我的时间安排契合、或与我当下关注方向相吻合 的邮件。
AI 摘要 文章系统梳理了操作系统三大核心调度机制：进程调度、页面置换与磁盘调度。进程调度部分对比非抢占与抢占场景，详解FCFS、SJF、HRRN、RR、HPF及多级反馈队列六种算法，指出各自对长短作业、响应时间与开销的权衡。内存管理围绕缺页中断流程，阐述OPT、FIFO、LRU、Clock、LFU五种页面置换策略的目标与实现代价。磁盘调度针对寻道耗时，给出FCFS、SSTF、Scan、C-Scan、LOOK、C-LOOK算法并分析饥饿与响应频率问题。 此摘要根据文章内容自动生成，仅用于文章内容的解释与总结进程调度算法进程调度算法也称 CPU 调度算法，毕竟进程是由 CPU 调度的。当 CPU 空闲时，操作系统就选择内存中的某个「就绪状态」的进程，并给其分配 CPU。什么时候会发生 CPU 调度呢？通常有以下情况：当进程从运行状态转到等待状态；当进程从运行状态转到就绪状态；当进程从等待状态转到就绪状态；当进程从运行状态转到终止状态；其中发生在 1 和 4 两种情况下的调度称为「非抢占式调度」，2 和 3 两种情况下发生的调度称为「抢占式调度」。非抢占式的意思就是，当进程正在运行时，它就会一直运行，直到该进程完成或发生某个事件而被阻塞时，才会把 CPU 让给其他进程。而抢占式调度，顾名思义就是进程正在运行的时，可以被打断，使其把 CPU 让给其他进程。那抢占的原则一般有三种，分别是时间片原则、优先权原则、短作业优先原则。你可能会好奇为什么第 3 种情况也会发生 CPU 调度呢？假设有一个进程是处于等待状态的，但是它的优先级比较高，如果该进程等待的事件发生了，它就会转到就绪状态，一旦它转到就绪状态，如果我们的调度算法是以优先级来进行调度的，那么它就会立马抢占正在运行的进程，所以这个时候就会发生 CPU 调度。那第 2 种状态通常是时间片到的情况，因为时间片到了就会发生中断，于是就会抢占正在运行的进程，从而占用 CPU。调度算法影响的是等待时间（进程在就绪队列中等待调度的时间总和），而不能影响进程真在使用 CPU 的时间和 I/O 时间。接下来，说说常见的调度算法：先来先服务调度算法最短作业优先调度算法高响应比优先调度算法时间片轮转调度算法最高优先级调度算法多级反馈队列调度算法先来先服务调度算法最简单的一个调度算法，就是非抢占式的先来先服务（First Come First Severd, FCFS）算法了。顾名思义，先来后到，每次从就绪队列选择最先进入队列的进程，然后一直运行，直到进程退出或被阻塞，才会继续从队列中选择第一个进程接着运行。这似乎很公平，但是当一个长作业先运行了，那么后面的短作业等待的时间就会很长，不利于短作业。FCFS 对长作业有利，适用于 CPU 繁忙型作业的系统，而不适用于 I/O 繁忙型作业的系统。最短作业优先调度算法最短作业优先（Shortest Job First, SJF）调度算法同样也是顾名思义，它会优先选择运行时间最短的进程来运行，这有助于提高系统的吞吐量。这显然对长作业不利，很容易造成一种极端现象。比如，一个长作业在就绪队列等待运行，而这个就绪队列有非常多的短作业，那么就会使得长作业不断的往后推，周转时间变长，致使长作业长期不会被运行。高响应比优先调度算法前面的「先来先服务调度算法」和「最短作业优先调度算法」都没有很好的权衡短作业和长作业。那么，**高响应比优先（Highest Response Ratio Next, HRRN）调度算法**主要是权衡了短作业和长作业。每次进行进程调度时，先计算「响应比优先级」，然后把「响应比优先级」最高的进程投入运行，「响应比优先级」的计算公式：从上面的公式，可以发现：如果两个进程的「等待时间」相同时，「要求的服务时间」越短，「响应比」就越高，这样短作业的进程容易被选中运行；如果两个进程「要求的服务时间」相同时，「等待时间」越长，「响应比」就越高，这就兼顾到了长作业进程，因为进程的响应比可以随时间等待的增加而提高，当其等待时间足够长时，其响应比便可以升到很高，从而获得运行的机会；时间片轮转调度算法最古老、最简单、最公平且使用最广的算法就是时间片轮转（Round Robin, RR）调度算法。。每个进程被分配一个时间段，称为时间片（Quantum），即允许该进程在该时间段中运行。如果时间片用完，进程还在运行，那么将会把此进程从 CPU 释放出来，并把 CPU 分配另外一个进程；如果该进程在时间片结束前阻塞或结束，则 CPU 立即进行切换；另外，时间片的长度就是一个很关键的点：如果时间片设得太短会导致过多的进程上下文切换，降低了 CPU 效率；如果时间片设得太长又可能引起短作业进程的响应时间变长，不利于短作业。通常时间片设为 20ms~50ms 通常是一个比较合理的折中值。最高优先级调度算法前面的「时间片轮转算法」做了个假设，即让所有的进程同等重要，也不偏袒谁，大家的运行时间都一样。但是，对于多用户计算机系统就有不同的看法了，它们希望调度是有优先级的，即希望调度程序能从就绪队列中选择最高优先级的进程进行运行，这称为最高优先级（Highest Priority First，HPF）调度算法。进程的优先级可以分为，静态优先级或动态优先级：静态优先级：创建进程时候，就已经确定了优先级了，然后整个运行时间优先级都不会变化；动态优先级：根据进程的动态变化调整优先级，比如如果进程运行时间增加，则降低其优先级，如果进程等待时间（就绪队列的等待时间）增加，则升高其优先级，也就是随着时间的推移增加等待进程的优先级。该算法也有两种处理优先级高的方法，非抢占式和抢占式：非抢占式：当就绪队列中出现优先级高的进程，运行完当前进程，再选择优先级高的进程。抢占式：当就绪队列中出现优先级高的进程，当前进程挂起，调度优先级高的进程运行。但是依然有缺点，可能会导致低优先级的进程永远不会运行。多级反馈队列调度算法多级反馈队列（Multilevel Feedback Queue）调度算法是「时间片轮转算法」和「最高优先级算法」的综合和发展。顾名思义：「多级」表示有多个队列，每个队列优先级从高到低，同时优先级越高时间片越短。「反馈」表示如果有新的进程加入优先级高的队列时，立刻停止当前正在运行的进程，转而去运行优先级高的队列；来看看，它是如何工作的：设置了多个队列，赋予每个队列不同的优先级，每个队列优先级从高到低，同时优先级越高时间片越短；新的进程会被放入到第一级队列的末尾，按先来先服务的原则排队等待被调度，如果在第一级队列规定的时间片没运行完成，则将其转入到第二级队列的末尾，以此类推，直至完成；当较高优先级的队列为空，才调度较低优先级的队列中的进程运行。如果进程运行时，有新进程进入较高优先级的队列，则停止当前运行的进程并将其移入到原队列末尾，接着让较高优先级的进程运行；可以发现，对于短作业可能可以在第一级队列很快被处理完。对于长作业，如果在第一级队列处理不完，可以移入下次队列等待被执行，虽然等待的时间变长了，但是运行时间也会更长了，所以该算法很好的兼顾了长短作业，同时有较好的响应时间。内存页面置换算法在了解内存页面置换算法前，我们得先谈一下缺页异常（缺页中断）。当 CPU 访问的页面不在物理内存时，便会产生一个缺页中断，请求操作系统将所缺页调入到物理内存。那它与一般中断的主要区别在于：缺页中断在指令执行「期间」产生和处理中断信号，而一般中断在一条指令执行「完成」后检查和处理中断信号。缺页中断返回到该指令的开始重新执行「该指令」，而一般中断返回回到该指令的「下一个指令」执行。我们来看一下缺页中断的处理流程，如下图：在 CPU 里访问一条 Load M 指令，然后 CPU 会去找 M 所对应的页表项。如果该页表项的状态位是「有效的」，那 CPU 就可以直接去访问物理内存了，如果状态位是「无效的」，则 CPU 则会发送缺页中断请求。操作系统收到了缺页中断，则会执行缺页中断处理函数，先会查找该页面在磁盘中的页面的位置。找到磁盘中对应的页面后，需要把该页面换入到物理内存中，但是在换入前，需要在物理内存中找空闲页，如果找到空闲页，就把页面换入到物理内存中。页面从磁盘换入到物理内存完成后，则把页表项中的状态位修改为「有效的」。最后，CPU 重新执行导致缺页异常的指令。上面所说的过程，第 4 步是能在物理内存找到空闲页的情况，那如果找不到呢？找不到空闲页的话，就说明此时内存已满了，这时候，就需要「页面置换算法」选择一个物理页，如果该物理页有被修改过（脏页），则把它换出到磁盘，然后把该被置换出去的页表项的状态改成「无效的」，最后把正在访问的页面装入到这个物理页中。这里提一下，页表项通常有如下图的字段：那其中：状态位：用于表示该页是否有效，也就是说是否在物理内存中，供程序访问时参考。访问字段：用于记录该页在一段时间被访问的次数，供页面置换算法选择出页面时参考。修改位：表示该页在调入内存后是否有被修改过，由于内存中的每一页都在磁盘上保留一份副本，因此，如果没有修改，在置换该页时就不需要将该页写回到磁盘上，以减少系统的开销；如果已经被修改，则将该页重写到磁盘上，以保证磁盘中所保留的始终是最新的副本。硬盘地址：用于指出该页在硬盘上的地址，通常是物理块号，供调入该页时使用。这里我整理了虚拟内存的管理整个流程，你可以从下面这张图看到：所以，页面置换算法的功能是，当出现缺页异常，需调入新页面而内存已满时，选择被置换的物理页面，也就是说选择一个物理页面换出到磁盘，然后把需要访问的页面换入到物理页。那其算法目标则是，尽可能减少页面的换入换出的次数，常见的页面置换算法有如下几种：最佳页面置换算法（OPT）先进先出置换算法（FIFO）最近最久未使用的置换算法（LRU）时钟页面置换算法（Lock）最不常用置换算法（LFU）最佳页面置换算法最佳页面置换算法基本思路是，置换在「未来」最长时间不访问的页面。所以，该算法实现需要计算内存中每个逻辑页面的「下一次」访问时间，然后比较，选择未来最长时间不访问的页面。我们举个例子，假设一开始有 3 个空闲的物理页，然后有请求的页面序列，那它的置换过程如下图：在这个请求的页面序列中，缺页共发生了 7 次（空闲页换入 3 次 + 最优页面置换 4 次），页面置换共发生了 4 次。这很理想，但是实际系统中无法实现，因为程序访问页面时是动态的，我们是无法预知每个页面在「下一次」访问前的等待时间。所以，最佳页面置换算法作用是为了衡量你的算法的效率，你的算法效率越接近该算法的效率，那么说明你的算法是高效的。先进先出置换算法既然我们无法预知页面在下一次访问前所需的等待时间，那我们可以选择在内存驻留时间很长的页面进行中置换，这个就是「先进先出置换」算法的思想。还是以前面的请求的页面序列作为例子，假设使用先进先出置换算法，则过程如下图：在这个请求的页面序列中，缺页共发生了 10 次，页面置换共发生了 7 次，跟最佳页面置换算法比较起来，性能明显差了很多。最近最久未使用的置换算法最近最久未使用（LRU）的置换算法的基本思路是，发生缺页时，选择最长时间没有被访问的页面进行置换，也就是说，该算法假设已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。这种算法近似最优置换算法，最优置换算法是通过「未来」的使用情况来推测要淘汰的页面，而 LRU 则是通过「历史」的使用情况来推测要淘汰的页面。还是以前面的请求的页面序列作为例子，假设使用最近最久未使用的置换算法，则过程如下图：在这个请求的页面序列中，缺页共发生了 9 次，页面置换共发生了 6 次，跟先进先出置换算法比较起来，性能提高了一些。虽然 LRU 在理论上是可以实现的，但代价很高。为了完全实现 LRU，需要在内存中维护一个所有页面的链表，最近最多使用的页面在表头，最近最少使用的页面在表尾。困难的是，在每次访问内存时都必须要更新「整个链表」。在链表中找到一个页面，删除它，然后把它移动到表头是一个非常费时的操作。所以，LRU 虽然看上去不错，但是由于开销比较大，实际应用中比较少使用。时钟页面置换算法那有没有一种即能优化置换的次数，也能方便实现的算法呢？时钟页面置换算法就可以两者兼得，它跟 LRU 近似，又是对 FIFO 的一种改进。该算法的思路是，把所有的页面都保存在一个类似钟面的「环形链表」中，一个表针指向最老的页面。当发生缺页中断时，算法首先检查表针指向的页面：如果它的访问位位是 0 就淘汰该页面，并把新的页面插入这个位置，然后把表针前移一个位置；如果访问位是 1 就清除访问位，并把表针前移一个位置，重复这个过程直到找到了一个访问位为 0 的页面为止；我画了一副时钟页面置换算法的工作流程图，你可以在下方看到：了解了这个算法的工作方式，就明白为什么它被称为时钟（Clock）算法了。最不常用算法最不常用（LFU）算法，这名字听起来很调皮，但是它的意思不是指这个算法不常用，而是当发生缺页中断时，选择「访问次数」最少的那个页面，并将其淘汰。它的实现方式是，对每个页面设置一个「访问计数器」，每当一个页面被访问时，该页面的访问计数器就累加 1。在发生缺页中断时，淘汰计数器值最小的那个页面。看起来很简单，每个页面加一个计数器就可以实现了，但是在操作系统中实现的时候，我们需要考虑效率和硬件成本的。要增加一个计数器来实现，这个硬件成本是比较高的，另外如果要对这个计数器查找哪个页面访问次数最小，查找链表本身，如果链表长度很大，是非常耗时的，效率不高。但还有个问题，LFU 算法只考虑了频率问题，没考虑时间的问题，比如有些页面在过去时间里访问的频率很高，但是现在已经没有访问了，而当前频繁访问的页面由于没有这些页面访问的次数高，在发生缺页中断时，就会可能会误伤当前刚开始频繁访问，但访问次数还不高的页面。那这个问题的解决的办法还是有的，可以定期减少访问的次数，比如当发生时间中断时，把过去时间访问的页面的访问次数除以 2，也就说，随着时间的流失，以前的高访问次数的页面会慢慢减少，相当于加大了被置换的概率。磁盘调度算法我们来看看磁盘的结构，如下图：常见的机械磁盘是上图左边的样子，中间圆的部分是磁盘的盘片，一般会有多个盘片，每个盘面都有自己的磁头。右边的图就是一个盘片的结构，盘片中的每一层分为多个磁道，每个磁道分多个扇区，每个扇区是 512 字节。那么，多个具有相同编号的磁道形成一个圆柱，称之为磁盘的柱面，如上图里中间的样子。磁盘调度算法的目的很简单，就是为了提高磁盘的访问性能，一般是通过优化磁盘的访问请求顺序来做到的。寻道的时间是磁盘访问最耗时的部分，如果请求顺序优化的得当，必然可以节省一些不必要的寻道时间，从而提高磁盘的访问性能。假设有下面一个请求序列，每个数字代表磁道的位置：98，183，37，122，14，124，65，67初始磁头当前的位置是在第 53 磁道。接下来，分别对以上的序列，作为每个调度算法的例子，那常见的磁盘调度算法有：先来先服务算法最短寻道时间优先算法扫描算法算法循环扫描算法LOOK 与 C-LOOK 算法先来先服务先来先服务（First-Come，First-Served，FCFS），顾名思义，先到来的请求，先被服务。那按照这个序列的话：98，183，37，122，14，124，65，67那么，磁盘的写入顺序是从左到右，如下图：先来先服务算法总共移动了 640 个磁道的距离，这么一看这种算法，比较简单粗暴，但是如果大量进程竞争使用磁盘，请求访问的磁道可能会很分散，那先来先服务算法在性能上就会显得很差，因为寻道时间过长。最短寻道时间优先最短寻道时间优先（Shortest Seek First，SSF）算法的工作方式是，优先选择从当前磁头位置所需寻道时间最短的请求，还是以这个序列为例子：98，183，37，122，14，124，65，67那么，那么根据距离磁头（53 位置）最近的请求的算法，具体的请求则会是下列从左到右的顺序：65，67，37，14，98，122，124，183磁头移动的总距离是 236 磁道，相比先来先服务性能提高了不少。但这个算法可能存在某些请求的饥饿，因为本次例子我们是静态的序列，看不出问题，假设是一个动态的请求，如果后续来的请求都是小于 183 磁道的，那么 183 磁道可能永远不会被响应，于是就产生了饥饿现象，这里产生饥饿的原因是磁头在一小块区域来回移动。扫描算法最短寻道时间优先算法会产生饥饿的原因在于：磁头有可能再一个小区域内来回得移动。为了防止这个问题，可以规定：磁头在一个方向上移动，访问所有未完成的请求，直到磁头到达该方向上的最后的磁道，才调换方向，这就是扫描（Scan）算法。这种算法也叫做电梯算法，比如电梯保持按一个方向移动，直到在那个方向上没有请求为止，然后改变方向。还是以这个序列为例子，磁头的初始位置是 53：98，183，37，122，14，124，65，67那么，假设扫描调度算先朝磁道号减少的方向移动，具体请求则会是下列从左到右的顺序：37，14，0，65，67，98，122，124，183磁头先响应左边的请求，直到到达最左端（0 磁道）后，才开始反向移动，响应右边的请求。扫描调度算法性能较好，不会产生饥饿现象，但是存在这样的问题，中间部分的磁道会比较占便宜，中间部分相比其他部分响应的频率会比较多，也就是说每个磁道的响应频率存在差异。循环扫描算法扫描算法使得每个磁道响应的频率存在差异，那么要优化这个问题的话，可以总是按相同的方向进行扫描，使得每个磁道的响应频率基本一致。循环扫描（Circular Scan, CSCAN ）规定：只有磁头朝某个特定方向移动时，才处理磁道访问请求，而返回时直接快速移动至最靠边缘的磁道，也就是复位磁头，这个过程是很快的，并且返回中途不处理任何请求，该算法的特点，就是磁道只响应一个方向上的请求。还是以这个序列为例子，磁头的初始位置是 53：98，183，37，122，14，124，65，67那么，假设循环扫描调度算先朝磁道增加的方向移动，具体请求会是下列从左到右的顺序：65，67，98，122，124，183，199，0，14，37磁头先响应了右边的请求，直到碰到了最右端的磁道 199，就立即回到磁盘的开始处（磁道 0），但这个返回的途中是不响应任何请求的，直到到达最开始的磁道后，才继续顺序响应右边的请求。循环扫描算法相比于扫描算法，对于各个位置磁道响应频率相对比较平均。LOOK 与 C-LOOK 算法我们前面说到的扫描算法和循环扫描算法，都是磁头移动到磁盘「最始端或最末端」才开始调换方向。那这其实是可以优化的，优化的思路就是磁头在移动到「最远的请求」位置，然后立即反向移动。那针对 SCAN 算法的优化则叫 LOOK 算法，它的工作方式，磁头在每个方向上仅仅移动到最远的请求位置，然后立即反向移动，而不需要移动到磁盘的最始端或最末端，反向移动的途中会响应请求。而针对 C-SCAN 算法的优化则叫 C-LOOK，它的工作方式，磁头在每个方向上仅仅移动到最远的请求位置，然后立即反向移动，而不需要移动到磁盘的最始端或最末端，反向移动的途中不会响应请求。
AI 摘要 CopyOnWriteArrayList 通过“写时复制”实现线程安全，读无锁、写不阻塞读，仅写写互斥，适合读多写少场景；写操作需复制数组，内存与性能开销大，数据弱一致。 此摘要根据文章内容自动生成，仅用于文章内容的解释与总结title: CopyOnWriteArrayList 源码分析category: Javatag:Java集合CopyOnWriteArrayList 简介在 JDK1.5 之前，如果想要使用并发安全的 List 只能选择 Vector。而 Vector 是一种老旧的集合，已经被淘汰。Vector 对于增删改查等方法基本都加了 synchronized，这种方式虽然能够保证同步，但这相当于对整个 Vector 加上了一把大锁，使得每个方法执行的时候都要去获得锁，导致性能非常低下。JDK1.5 引入了 Java.util.concurrent（JUC）包，其中提供了很多线程安全且并发性能良好的容器，其中唯一的线程安全 List 实现就是 CopyOnWriteArrayList 。关于java.util.concurrent 包下常见并发容器的总结，可以看我写的这篇文章：Java 常见并发容器总结 。CopyOnWriteArrayList 到底有什么厉害之处？对于大部分业务场景来说，读取操作往往是远大于写入操作的。由于读取操作不会对原有数据进行修改，因此，对于每次读取都进行加锁其实是一种资源浪费。相比之下，我们应该允许多个线程同时访问 List 的内部数据，毕竟对于读取操作来说是安全的。这种思路与 ReentrantReadWriteLock 读写锁的设计思想非常类似，即读读不互斥、读写互斥、写写互斥（只有读读不互斥）。CopyOnWriteArrayList 更进一步地实现了这一思想。为了将读操作性能发挥到极致，CopyOnWriteArrayList 中的读取操作是完全无需加锁的。更加厉害的是，写入操作也不会阻塞读取操作，只有写写才会互斥。这样一来，读操作的性能就可以大幅度提升。CopyOnWriteArrayList 线程安全的核心在于其采用了 写时复制（Copy-On-Write） 的策略，从 CopyOnWriteArrayList 的名字就能看出了。Copy-On-Write 的思想是什么？CopyOnWriteArrayList名字中的“Copy-On-Write”即写时复制，简称 COW。下面是维基百科对 Copy-On-Write 的介绍，介绍的挺不错：写入时复制（英语：Copy-on-write，简称 COW）是一种计算机程序设计领域的优化策略。其核心思想是，如果有多个调用者（callers）同时请求相同资源（如内存或磁盘上的数据存储），他们会共同获取相同的指针指向相同的资源，直到某个调用者试图修改资源的内容时，系统才会真正复制一份专用副本（private copy）给该调用者，而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的。此作法主要的优点是如果调用者没有修改该资源，就不会有副本（private copy）被创建，因此多个调用者只是读取操作时可以共享同一份资源。这里再以 CopyOnWriteArrayList为例介绍：当需要修改（ add，set、remove 等操作） CopyOnWriteArrayList 的内容时，不会直接修改原数组，而是会先创建底层数组的副本，对副本数组进行修改，修改完之后再将修改后的数组赋值回去，这样就可以保证写操作不会影响读操作了。可以看出，写时复制机制非常适合读多写少的并发场景，能够极大地提高系统的并发性能。不过，写时复制机制并不是银弹，其依然存在一些缺点，下面列举几点：内存占用：每次写操作都需要复制一份原始数据，会占用额外的内存空间，在数据量比较大的情况下，可能会导致内存资源不足。写操作开销：每一次写操作都需要复制一份原始数据，然后再进行修改和替换，所以写操作的开销相对较大，在写入比较频繁的场景下，性能可能会受到影响。数据一致性问题：修改操作不会立即反映到最终结果中，还需要等待复制完成，这可能会导致一定的数据一致性问题。……CopyOnWriteArrayList 源码分析这里以 JDK1.8 为例，分析一下 CopyOnWriteArrayList 的底层核心源码。CopyOnWriteArrayList 的类定义如下：public class CopyOnWriteArrayList<E> extends Object implements List<E>, RandomAccess, Cloneable, Serializable { //... }CopyOnWriteArrayList 实现了以下接口：List : 表明它是一个列表，支持添加、删除、查找等操作，并且可以通过下标进行访问。RandomAccess ：这是一个标志接口，表明实现这个接口的 List 集合是支持 快速随机访问 的。Cloneable ：表明它具有拷贝能力，可以进行深拷贝或浅拷贝操作。Serializable : 表明它可以进行序列化操作，也就是可以将对象转换为字节流进行持久化存储或网络传输，非常方便。初始化CopyOnWriteArrayList 中有一个无参构造函数和两个有参构造函数。// 创建一个空的 CopyOnWriteArrayList public CopyOnWriteArrayList() { setArray(new Object[0]); } // 按照集合的迭代器返回的顺序创建一个包含指定集合元素的 CopyOnWriteArrayList public CopyOnWriteArrayList(Collection<? extends E> c) { Object[] elements; if (c.getClass() == CopyOnWriteArrayList.class) elements = ((CopyOnWriteArrayList<?>)c).getArray(); else { elements = c.toArray(); // c.toArray might (incorrectly) not return Object[] (see 6260652) if (elements.getClass() != Object[].class) elements = Arrays.copyOf(elements, elements.length, Object[].class); } setArray(elements); } // 创建一个包含指定数组的副本的列表 public CopyOnWriteArrayList(E[] toCopyIn) { setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class)); }插入元素CopyOnWriteArrayList 的 add()方法有三个版本：add(E e)：在 CopyOnWriteArrayList 的尾部插入元素。add(int index, E element)：在 CopyOnWriteArrayList 的指定位置插入元素。addIfAbsent(E e)：如果指定元素不存在，那么添加该元素。如果成功添加元素则返回 true。这里以add(E e)为例进行介绍：// 插入元素到 CopyOnWriteArrayList 的尾部 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; // array指向新数组 setArray(newElements); return true; } finally { // 解锁 lock.unlock(); } }从上面的源码可以看出：add方法内部用到了 ReentrantLock 加锁，保证了同步，避免了多线程写的时候会复制出多个副本出来。锁被修饰保证了锁的内存地址肯定不会被修改，并且，释放锁的逻辑放在 finally 中，可以保证锁能被释放。CopyOnWriteArrayList 通过复制底层数组的方式实现写操作，即先创建一个新的数组来容纳新添加的元素，然后在新数组中进行写操作，最后将新数组赋值给底层数组的引用，替换掉旧的数组。这也就证明了我们前面说的：CopyOnWriteArrayList 线程安全的核心在于其采用了 写时复制（Copy-On-Write） 的策略。每次写操作都需要通过 Arrays.copyOf 复制底层数组，时间复杂度是 O(n) 的，且会占用额外的内存空间。因此，CopyOnWriteArrayList 适用于读多写少的场景，在写操作不频繁且内存资源充足的情况下，可以提升系统的性能表现。CopyOnWriteArrayList 中并没有类似于 ArrayList 的 grow() 方法扩容的操作。Arrays.copyOf 方法的时间复杂度是 O(n)，其中 n 表示需要复制的数组长度。因为这个方法的实现原理是先创建一个新的数组，然后将源数组中的数据复制到新数组中，最后返回新数组。这个方法会复制整个数组，因此其时间复杂度与数组长度成正比，即 O(n)。值得注意的是，由于底层调用了系统级别的拷贝指令，因此在实际应用中这个方法的性能表现比较优秀，但是也需要注意控制复制的数据量，避免出现内存占用过高的情况。读取元素CopyOnWriteArrayList 的读取操作是基于内部数组 array 并没有发生实际的修改，因此在读取操作时不需要进行同步控制和锁操作，可以保证数据的安全性。这种机制下，多个线程可以同时读取列表中的元素。// 底层数组，只能通过getArray和setArray方法访问 private transient volatile Object[] array; public E get(int index) { return get(getArray(), index); } final Object[] getArray() { return array; } private E get(Object[] a, int index) { return (E) a[index]; }不过，get方法是弱一致性的，在某些情况下可能读到旧的元素值。get(int index)方法是分两步进行的：通过getArray()获取当前数组的引用；直接从数组中获取下标为 index 的元素。这个过程并没有加锁，所以在并发环境下可能出现如下情况：线程 1 调用get(int index)方法获取值，内部通过getArray()方法获取到了 array 属性值；线程 2 调用CopyOnWriteArrayList的add、set、remove 等修改方法时，内部通过setArray方法修改了array属性的值；线程 1 还是从旧的 array 数组中取值。获取列表中元素的个数public int size() { return getArray().length; }CopyOnWriteArrayList中的array数组每次复制都刚好能够容纳下所有元素，并不像ArrayList那样会预留一定的空间。因此，CopyOnWriteArrayList中并没有size属性CopyOnWriteArrayList的底层数组的长度就是元素个数，因此size()方法只要返回数组长度就可以了。删除元素CopyOnWriteArrayList删除元素相关的方法一共有 4 个：remove(int index)：移除此列表中指定位置上的元素。将任何后续元素向左移动（从它们的索引中减去 1）。boolean remove(Object o)：删除此列表中首次出现的指定元素，如果不存在该元素则返回 false。boolean removeAll(Collection<?> c)：从此列表中删除指定集合中包含的所有元素。void clear()：移除此列表中的所有元素。这里以remove(int index)为例进行介绍：public E remove(int index) { // 获取可重入锁 final ReentrantLock lock = this.lock; // 加锁 lock.lock(); try { //获取当前array数组 Object[] elements = getArray(); // 获取当前array长度 int len = elements.length; //获取指定索引的元素(旧值) E oldValue = get(elements, index); int numMoved = len - index - 1; // 判断删除的是否是最后一个元素 if (numMoved == 0) // 如果删除的是最后一个元素，直接复制该元素前的所有元素到新的数组 setArray(Arrays.copyOf(elements, len - 1)); else { // 分段复制，将index前的元素和index+1后的元素复制到新数组 // 新数组长度为旧数组长度-1 Object[] newElements = new Object[len - 1]; System.arraycopy(elements, 0, newElements, 0, index); System.arraycopy(elements, index + 1, newElements, index, numMoved); //将新数组赋值给array引用 setArray(newElements); } return oldValue; } finally { // 解锁 lock.unlock(); } }判断元素是否存在CopyOnWriteArrayList提供了两个用于判断指定元素是否在列表中的方法：contains(Object o)：判断是否包含指定元素。containsAll(Collection<?> c)：判断是否保证指定集合的全部元素。// 判断是否包含指定元素 public boolean contains(Object o) { //获取当前array数组 Object[] elements = getArray(); //调用index尝试查找指定元素，如果返回值大于等于0，则返回true，否则返回false return indexOf(o, elements, 0, elements.length) >= 0; } // 判断是否保证指定集合的全部元素 public boolean containsAll(Collection<?> c) { //获取当前array数组 Object[] elements = getArray(); //获取数组长度 int len = elements.length; //遍历指定集合 for (Object e : c) { //循环调用indexOf方法判断，只要有一个没有包含就直接返回false if (indexOf(e, elements, 0, len) < 0) return false; } //最后表示全部包含或者制定集合为空集合，那么返回true return true; }CopyOnWriteArrayList 常用方法测试代码：// 创建一个 CopyOnWriteArrayList 对象 CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>(); // 向列表中添加元素 list.add("Java"); list.add("Python"); list.add("C++"); System.out.println("初始列表：" + list); // 使用 get 方法获取指定位置的元素 System.out.println("列表第二个元素为：" + list.get(1)); // 使用 remove 方法删除指定元素 boolean result = list.remove("C++"); System.out.println("删除结果：" + result); System.out.println("列表删除元素后为：" + list); // 使用 set 方法更新指定位置的元素 list.set(1, "Golang"); System.out.println("列表更新后为：" + list); // 使用 add 方法在指定位置插入元素 list.add(0, "PHP"); System.out.println("列表插入元素后为：" + list); // 使用 size 方法获取列表大小 System.out.println("列表大小为：" + list.size()); // 使用 removeAll 方法删除指定集合中所有出现的元素 result = list.removeAll(List.of("Java", "Golang")); System.out.println("批量删除结果：" + result); System.out.println("列表批量删除元素后为：" + list); // 使用 clear 方法清空列表中所有元素 list.clear(); System.out.println("列表清空后为：" + list);输出：列表更新后为：[Java, Golang] 列表插入元素后为：[PHP, Java, Golang] 列表大小为：3 批量删除结果：true 列表批量删除元素后为：[PHP] 列表清空后为：[]
AI 摘要 文章用大白话梳理了JVM核心：类加载、运行时数据区、垃圾回收与调优参数。重点讲清.class如何经类加载器进入内存，堆、栈、方法区分工，双亲委派保安全，可达性分析判生死，常用GC器及-Xms/-Xmx/-Xmn等调优口诀，附代码示范。 此摘要根据文章内容自动生成，仅用于文章内容的解释与总结title: 大白话带你认识 JVMcategory: Javatag:JVM前言如果在文中用词或者理解方面出现问题，欢迎指出。此文旨在提及而不深究，但会尽量效率地把知识点都抛出来一、JVM 的基本介绍JVM 是 Java Virtual Machine 的缩写，它是一个虚构出来的计算机，一种规范。通过在实际的计算机上仿真模拟各类计算机功能实现···好，其实抛开这么专业的句子不说，就知道 JVM 其实就类似于一台小电脑运行在 windows 或者 linux 这些操作系统环境下即可。它直接和操作系统进行交互，与硬件不直接交互，而操作系统可以帮我们完成和硬件进行交互的工作。1.1 Java 文件是如何被运行的比如我们现在写了一个 HelloWorld.java 好了，那这个 HelloWorld.java 抛开所有东西不谈，那是不是就类似于一个文本文件，只是这个文本文件它写的都是英文，而且有一定的缩进而已。那我们的 JVM 是不认识文本文件的，所以它需要一个 编译 ，让其成为一个它会读二进制文件的 HelloWorld.class① 类加载器如果 JVM 想要执行这个 .class 文件，我们需要将其装进一个 类加载器 中，它就像一个搬运工一样，会把所有的 .class 文件全部搬进 JVM 里面来。② 方法区方法区 是用于存放类似于元数据信息方面的数据的，比如类信息，常量，静态变量，编译后代码···等类加载器将 .class 文件搬过来就是先丢到这一块上③ 堆堆 主要放了一些存储的数据，比如对象实例，数组···等，它和方法区都同属于 线程共享区域 。也就是说它们都是 线程不安全 的④ 栈栈 这是我们的代码运行空间。我们编写的每一个方法都会放到 栈 里面运行。我们会听说过 本地方法栈 或者 本地方法接口 这两个名词，不过我们基本不会涉及这两块的内容，它俩底层是使用 C 来进行工作的，和 Java 没有太大的关系。⑤ 程序计数器主要就是完成一个加载工作，类似于一个指针一样的，指向下一行我们需要执行的代码。和栈一样，都是 线程独享 的，就是说每一个线程都会有自己对应的一块区域而不会存在并发和多线程的问题。小总结Java 文件经过编译后变成 .class 字节码文件字节码文件通过类加载器被搬运到 JVM 虚拟机中虚拟机主要的 5 大块：方法区，堆都为线程共享区域，有线程安全问题，栈和本地方法栈和计数器都是独享区域，不存在线程安全问题，而 JVM 的调优主要就是围绕堆，栈两大块进行1.2 简单的代码例子一个简单的学生类一个 main 方法执行 main 方法的步骤如下:编译好 App.java 后得到 App.class 后，执行 App.class，系统会启动一个 JVM 进程，从 classpath 路径中找到一个名为 App.class 的二进制文件，将 App 的类信息加载到运行时数据区的方法区内，这个过程叫做 App 类的加载JVM 找到 App 的主程序入口，执行 main 方法这个 main 中的第一条语句为 Student student = new Student("tellUrDream") ，就是让 JVM 创建一个 Student 对象，但是这个时候方法区中是没有 Student 类的信息的，所以 JVM 马上加载 Student 类，把 Student 类的信息放到方法区中加载完 Student 类后，JVM 在堆中为一个新的 Student 实例分配内存，然后调用构造函数初始化 Student 实例，这个 Student 实例持有 指向方法区中的 Student 类的类型信息 的引用执行 student.sayName();时，JVM 根据 student 的引用找到 student 对象，然后根据 student 对象持有的引用定位到方法区中 student 类的类型信息的方法表，获得 sayName() 的字节码地址。执行 sayName()其实也不用管太多，只需要知道对象实例初始化时会去方法区中找类信息，完成后再到栈那里去运行方法。找方法就在方法表中找。二、类加载器的介绍之前也提到了它是负责加载.class 文件的，它们在文件开头会有特定的文件标示，将 class 文件字节码内容加载到内存中，并将这些内容转换成方法区中的运行时数据结构，并且 ClassLoader 只负责 class 文件的加载，而是否能够运行则由 Execution Engine 来决定2.1 类加载器的流程从类被加载到虚拟机内存中开始，到释放内存总共有 7 个步骤：加载，验证，准备，解析，初始化，使用，卸载。其中验证，准备，解析三个部分统称为连接2.1.1 加载将 class 文件加载到内存将静态数据结构转化成方法区中运行时的数据结构在堆中生成一个代表这个类的 java.lang.Class 对象作为数据访问的入口2.1.2 链接验证：确保加载的类符合 JVM 规范和安全，保证被校验类的方法在运行时不会做出危害虚拟机的事件，其实就是一个安全检查准备：为 static 变量在方法区中分配内存空间，设置变量的初始值，例如 static int a = 3 （注意：准备阶段只设置类中的静态变量（方法区中），不包括实例变量（堆内存中），实例变量是对象初始化时赋值的）解析：虚拟机将常量池内的符号引用替换为直接引用的过程（符号引用比如我现在 import java.util.ArrayList 这就算符号引用，直接引用就是指针或者对象地址，注意引用对象一定是在内存进行）2.1.3 初始化初始化其实就是执行类构造器方法的<clinit>()的过程，而且要保证执行前父类的<clinit>()方法执行完毕。这个方法由编译器收集，顺序执行所有类变量（static 修饰的成员变量）显式初始化和静态代码块中语句。此时准备阶段时的那个 static int a 由默认初始化的 0 变成了显式初始化的 3。 由于执行顺序缘故，初始化阶段类变量如果在静态代码块中又进行了更改，会覆盖类变量的显式初始化，最终值会为静态代码块中的赋值。注意：字节码文件中初始化方法有两种，非静态资源初始化的<init>和静态资源初始化的<clinit>，类构造器方法<clinit>()不同于类的构造器，这些方法都是字节码文件中只能给 JVM 识别的特殊方法。2.1.4 卸载GC 将无用对象从内存中卸载2.2 类加载器的加载顺序加载一个 Class 类的顺序也是有优先级的，类加载器从最底层开始往上的顺序是这样的BootStrap ClassLoader：rt.jarExtension ClassLoader: 加载扩展的 jar 包App ClassLoader：指定的 classpath 下面的 jar 包Custom ClassLoader：自定义的类加载器2.3 双亲委派机制当一个类收到了加载请求时，它是不会先自己去尝试加载的，而是委派给父类去完成，比如我现在要 new 一个 Person，这个 Person 是我们自定义的类，如果我们要加载它，就会先委派 App ClassLoader ，只有当父类加载器都反馈自己无法完成这个请求（也就是父类加载器都没有找到加载所需的 Class）时，子类加载器才会自行尝试加载。这样做的好处是，加载位于 rt.jar 包中的类时不管是哪个加载器加载，最终都会委托到 BootStrap ClassLoader 进行加载，这样保证了使用不同的类加载器得到的都是同一个结果。其实这个也是一个隔离的作用，避免了我们的代码影响了 JDK 的代码，比如我现在自己定义一个 java.lang.String：package java.lang; public class String { public static void main(String[] args) { System.out.println(); } }尝试运行当前类的 main 函数的时候，我们的代码肯定会报错。这是因为在加载的时候其实是找到了 rt.jar 中的java.lang.String，然而发现这个里面并没有 main 方法。三、运行时数据区3.1 本地方法栈和程序计数器比如说我们现在点开 Thread 类的源码，会看到它的 start0 方法带有一个 native 关键字修饰，而且不存在方法体，这种用 native 修饰的方法就是本地方法，这是使用 C 来实现的，然后一般这些方法都会放到一个叫做本地方法栈的区域。程序计数器其实就是一个指针，它指向了我们程序中下一句需要执行的指令，它也是内存区域中唯一一个不会出现 OutOfMemoryError 的区域，而且占用内存空间小到基本可以忽略不计。这个内存仅代表当前线程所执行的字节码的行号指示器，字节码解析器通过改变这个计数器的值选取下一条需要执行的字节码指令。如果执行的是 native 方法，那这个指针就不工作了。3.2 方法区方法区主要的作用是存放类的元数据信息，常量和静态变量···等。当它存储的信息过大时，会在无法满足内存分配时报错。3.3 虚拟机栈和虚拟机堆一句话便是：栈管运行，堆管存储。则虚拟机栈负责运行代码，而虚拟机堆负责存储数据。3.3.1 虚拟机栈的概念它是 Java 方法执行的内存模型。里面会对局部变量，动态链表，方法出口，栈的操作（入栈和出栈）进行存储，且线程独享。同时如果我们听到局部变量表，那也是在说虚拟机栈public class Person{ int a = 1; public void doSomething(){ int b = 2; } }3.3.2 虚拟机栈存在的异常如果线程请求的栈的深度大于虚拟机栈的最大深度，就会报 StackOverflowError （这种错误经常出现在递归中）。Java 虚拟机也可以动态扩展，但随着扩展会不断地申请内存，当无法申请足够内存时就会报错 OutOfMemoryError。3.3.3 虚拟机栈的生命周期对于栈来说，不存在垃圾回收。只要程序运行结束，栈的空间自然就会释放了。栈的生命周期和所处的线程是一致的。这里补充一句：8 种基本类型的变量+对象的引用变量+实例方法都是在栈里面分配内存。3.3.4 虚拟机栈的执行我们经常说的栈帧数据，说白了在 JVM 中叫栈帧，放到 Java 中其实就是方法，它也是存放在栈中的。栈中的数据都是以栈帧的格式存在，它是一个关于方法和运行期数据的数据集。比如我们执行一个方法 a，就会对应产生一个栈帧 A1，然后 A1 会被压入栈中。同理方法 b 会有一个 B1，方法 c 会有一个 C1，等到这个线程执行完毕后，栈会先弹出 C1，后 B1,A1。它是一个先进后出，后进先出原则。3.3.5 局部变量的复用局部变量表用于存放方法参数和方法内部所定义的局部变量。它的容量是以 Slot 为最小单位，一个 slot 可以存放 32 位以内的数据类型。虚拟机通过索引定位的方式使用局部变量表，范围为 [0,局部变量表的 slot 的数量]。方法中的参数就会按一定顺序排列在这个局部变量表中，至于怎么排的我们可以先不关心。而为了节省栈帧空间，这些 slot 是可以复用的，当方法执行位置超过了某个变量，那么这个变量的 slot 可以被其它变量复用。当然如果需要复用，那我们的垃圾回收自然就不会去动这些内存。3.3.6 虚拟机堆的概念JVM 内存会划分为堆内存和非堆内存，堆内存中也会划分为年轻代和老年代，而非堆内存则为永久代。年轻代又会分为Eden和Survivor区。Survivor 也会分为FromPlace和ToPlace，toPlace 的 survivor 区域是空的。Eden，FromPlace 和 ToPlace 的默认占比为 8:1:1。当然这个东西其实也可以通过一个 -XX:+UsePSAdaptiveSurvivorSizePolicy 参数来根据生成对象的速率动态调整堆内存中存放的是对象，垃圾收集就是收集这些对象然后交给 GC 算法进行回收。非堆内存其实我们已经说过了，就是方法区。在 1.8 中已经移除永久代，替代品是一个元空间(MetaSpace)，最大区别是 metaSpace 是不存在于 JVM 中的，它使用的是本地内存。并有两个参数MetaspaceSize：初始化元空间大小，控制发生GC MaxMetaspaceSize：限制元空间大小上限，防止占用过多物理内存。移除的原因可以大致了解一下：融合 HotSpot JVM 和 JRockit VM 而做出的改变，因为 JRockit 是没有永久代的，不过这也间接性地解决了永久代的 OOM 问题。3.3.7 Eden 年轻代的介绍当我们 new 一个对象后，会先放到 Eden 划分出来的一块作为存储空间的内存，但是我们知道对堆内存是线程共享的，所以有可能会出现两个对象共用一个内存的情况。这里 JVM 的处理是为每个线程都预先申请好一块连续的内存空间并规定了对象存放的位置，而如果空间不足会再申请多块内存空间。这个操作我们会称作 TLAB，有兴趣可以了解一下。当 Eden 空间满了之后，会触发一个叫做 Minor GC（就是一个发生在年轻代的 GC）的操作，存活下来的对象移动到 Survivor0 区。Survivor0 区满后触发 Minor GC，就会将存活对象移动到 Survivor1 区，此时还会把 from 和 to 两个指针交换，这样保证了一段时间内总有一个 survivor 区为空且 to 所指向的 survivor 区为空。经过多次的 Minor GC 后仍然存活的对象（这里的存活判断是 15 次，对应到虚拟机参数为 -XX:MaxTenuringThreshold 。为什么是 15，因为 HotSpot 会在对象头中的标记字段里记录年龄，分配到的空间仅有 4 位，所以最多只能记录到 15）会移动到老年代。🐛 修正：当 Eden 区内存空间满了的时候，就会触发 Minor GC，Survivor0 区满不会触发 Minor GC 。那 Survivor0 区 的对象什么时候垃圾回收呢？假设 Survivor0 区现在是满的，此时又触发了 Minor GC ，发现 Survivor0 区依旧是满的，存不下，此时会将 S0 区与 Eden 区的对象一起进行可达性分析，找出活跃的对象，将它复制到 S1 区并且将 S0 区域和 Eden 区的对象给清空，这样那些不可达的对象进行清除，并且将 S0 区 和 S1 区交换。老年代是存储长期存活的对象的，占满时就会触发我们最常听说的 Full GC，期间会停止所有线程等待 GC 的完成。所以对于响应要求高的应用应该尽量去减少发生 Full GC 从而避免响应超时的问题。而且当老年区执行了 full gc 之后仍然无法进行对象保存的操作，就会产生 OOM，这时候就是虚拟机中的堆内存不足，原因可能会是堆内存设置的大小过小，这个可以通过参数-Xms、-Xmx 来调整。也可能是代码中创建的对象大且多，而且它们一直在被引用从而长时间垃圾收集无法收集它们。补充说明：关于-XX:TargetSurvivorRatio 参数的问题。其实也不一定是要满足-XX:MaxTenuringThreshold 才移动到老年代。可以举个例子：如对象年龄 5 的占 30%，年龄 6 的占 36%，年龄 7 的占 34%，加入某个年龄段（如例子中的年龄 6）后，总占用超过 Survivor 空间*TargetSurvivorRatio 的时候，从该年龄段开始及大于的年龄对象就要进入老年代（即例子中的年龄 6 对象，就是年龄 6 和年龄 7 晋升到老年代），这时候无需等到 MaxTenuringThreshold 中要求的 153.3.8 如何判断一个对象需要被干掉图中程序计数器、虚拟机栈、本地方法栈，3 个区域随着线程的生存而生存的。内存分配和回收都是确定的。随着线程的结束内存自然就被回收了，因此不需要考虑垃圾回收的问题。而 Java 堆和方法区则不一样，各线程共享，内存的分配和回收都是动态的。因此垃圾收集器所关注的都是堆和方法这部分内存。在进行回收前就要判断哪些对象还存活，哪些已经死去。下面介绍两个基础的计算方法1.引用计数器计算：给对象添加一个引用计数器，每次引用这个对象时计数器加一，引用失效时减一，计数器等于 0 时就是不会再次使用的。不过这个方法有一种情况就是出现对象的循环引用时 GC 没法回收。2.可达性分析计算：这是一种类似于二叉树的实现，将一系列的 GC ROOTS 作为起始的存活对象集，从这个节点往下搜索，搜索所走过的路径成为引用链，把能被该集合引用到的对象加入到集合中。搜索当一个对象到 GC Roots 没有使用任何引用链时，则说明该对象是不可用的。主流的商用程序语言，例如 Java，C#等都是靠这招去判定对象是否存活的。（了解一下即可）在 Java 语言汇总能作为 GC Roots 的对象分为以下几种：虚拟机栈（栈帧中的本地方法表）中引用的对象（局部变量）方法区中静态变量所引用的对象（静态变量）方法区中常量引用的对象本地方法栈（即 native 修饰的方法）中 JNI 引用的对象（JNI 是 Java 虚拟机调用对应的 C 函数的方式，通过 JNI 函数也可以创建新的 Java 对象。且 JNI 对于对象的局部引用或者全局引用都会把它们指向的对象都标记为不可回收）已启动的且未终止的 Java 线程这种方法的优点是能够解决循环引用的问题，可它的实现需要耗费大量资源和时间，也需要 GC（它的分析过程引用关系不能发生变化，所以需要停止所有进程）3.3.9 如何宣告一个对象的真正死亡首先必须要提到的是一个名叫 finalize() 的方法finalize()是 Object 类的一个方法、一个对象的 finalize()方法只会被系统自动调用一次，经过 finalize()方法逃脱死亡的对象，第二次不会再调用。补充一句：并不提倡在程序中调用 finalize()来进行自救。建议忘掉 Java 程序中该方法的存在。因为它执行的时间不确定，甚至是否被执行也不确定（Java 程序的不正常退出），而且运行代价高昂，无法保证各个对象的调用顺序（甚至有不同线程中调用）。在 Java9 中已经被标记为 deprecated ，且 java.lang.ref.Cleaner（也就是强、软、弱、幻象引用的那一套）中已经逐步替换掉它，会比 finalize 来的更加的轻量及可靠。判断一个对象的死亡至少需要两次标记如果对象进行可达性分析之后没发现与 GC Roots 相连的引用链，那它将会第一次标记并且进行一次筛选。判断的条件是决定这个对象是否有必要执行 finalize()方法。如果对象有必要执行 finalize()方法，则被放入 F-Queue 队列中。GC 对 F-Queue 队列中的对象进行二次标记。如果对象在 finalize()方法中重新与引用链上的任何一个对象建立了关联，那么二次标记时则会将它移出“即将回收”集合。如果此时对象还没成功逃脱，那么只能被回收了。如果确定对象已经死亡，我们又该如何回收这些垃圾呢3.4 垃圾回收算法关于常见垃圾回收算法的详细介绍，建议阅读这篇：JVM 垃圾回收详解（重点）。3.5 （了解）各种各样的垃圾回收器HotSpot VM 中的垃圾回收器，以及适用场景到 jdk8 为止，默认的垃圾收集器是 Parallel Scavenge 和 Parallel Old从 jdk9 开始，G1 收集器成为默认的垃圾收集器目前来看，G1 回收器停顿时间最短而且没有明显缺点，非常适合 Web 应用。在 jdk8 中测试 Web 应用，堆内存 6G，新生代 4.5G 的情况下，Parallel Scavenge 回收新生代停顿长达 1.5 秒。G1 回收器回收同样大小的新生代只停顿 0.2 秒。3.6 （了解）JVM 的常用参数JVM 的参数非常之多，这里只列举比较重要的几个，通过各种各样的搜索引擎也可以得知这些信息。参数名称含义默认值说明-Xms初始堆大小物理内存的 1/64(<1GB)默认(MinHeapFreeRatio 参数可以调整)空余堆内存小于 40%时，JVM 就会增大堆直到-Xmx 的最大限制.-Xmx最大堆大小物理内存的 1/4(<1GB)默认(MaxHeapFreeRatio 参数可以调整)空余堆内存大于 70%时，JVM 会减少堆直到 -Xms 的最小限制-Xmn年轻代大小(1.4or later) 注意：此处的大小是（eden+ 2 survivor space).与 jmap -heap 中显示的 New gen 是不同的。整个堆大小=年轻代大小 + 老年代大小 + 持久代（永久代）大小.增大年轻代后,将会减小年老代大小.此值对系统性能影响较大,Sun 官方推荐配置为整个堆的 3/8-XX:NewSize设置年轻代大小(for 1.3/1.4) -XX:MaxNewSize年轻代最大值(for 1.3/1.4) -XX:PermSize设置持久代(perm gen)初始值物理内存的 1/64 -XX:MaxPermSize设置持久代最大值物理内存的 1/4 -Xss每个线程的堆栈大小 JDK5.0 以后每个线程堆栈大小为 1M,以前每个线程堆栈大小为 256K.根据应用的线程所需内存大小进行 调整.在相同物理内存下,减小这个值能生成更多的线程.但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在 3000~5000 左右一般小的应用， 如果栈不是很深， 应该是 128k 够用的 大的应用建议使用 256k。这个选项对性能影响比较大，需要严格的测试。（校长）和 threadstacksize 选项解释很类似,官方文档似乎没有解释,在论坛中有这样一句话:-Xss is translated in a VM flag named ThreadStackSize”一般设置这个值就可以了-XX:NewRatio年轻代(包括 Eden 和两个 Survivor 区)与年老代的比值(除去持久代) -XX:NewRatio=4 表示年轻代与年老代所占比值为 1:4,年轻代占整个堆栈的 1/5Xms=Xmx 并且设置了 Xmn 的情况下，该参数不需要进行设置。-XX:SurvivorRatioEden 区与 Survivor 区的大小比值 设置为 8,则两个 Survivor 区与一个 Eden 区的比值为 2:8,一个 Survivor 区占整个年轻代的 1/10-XX:+DisableExplicitGC关闭 System.gc() 这个参数需要严格的测试-XX:PretenureSizeThreshold对象超过多大是直接在旧生代分配0单位字节 新生代采用 Parallel ScavengeGC 时无效另一种直接在旧生代分配的情况是大的数组对象,且数组中无外部引用对象.-XX:ParallelGCThreads并行收集器的线程数 此值最好配置与处理器数目相等 同样适用于 CMS-XX:MaxGCPauseMillis每次年轻代垃圾回收的最长时间(最大暂停时间) 如果无法满足此时间,JVM 会自动调整年轻代大小,以满足此值.其实还有一些打印及 CMS 方面的参数，这里就不以一一列举了四、关于 JVM 调优的一些方面根据刚刚涉及的 jvm 的知识点，我们可以尝试对 JVM 进行调优，主要就是堆内存那块所有线程共享数据区大小=新生代大小 + 年老代大小 + 持久代大小。持久代一般固定大小为 64m。所以 java 堆中增大年轻代后，将会减小年老代大小（因为老年代的清理是使用 fullgc，所以老年代过小的话反而是会增多 fullgc 的）。此值对系统性能影响较大，Sun 官方推荐配置为 java 堆的 3/8。4.1 调整最大堆内存和最小堆内存-Xmx –Xms：指定 java 堆最大值（默认值是物理内存的 1/4(<1GB)）和初始 java 堆最小值（默认值是物理内存的 1/64(<1GB)）默认(MinHeapFreeRatio 参数可以调整)空余堆内存小于 40%时，JVM 就会增大堆直到-Xmx 的最大限制.，默认(MaxHeapFreeRatio 参数可以调整)空余堆内存大于 70%时，JVM 会减少堆直到 -Xms 的最小限制。简单点来说，你不停地往堆内存里面丢数据，等它剩余大小小于 40%了，JVM 就会动态申请内存空间不过会小于-Xmx，如果剩余大小大于 70%，又会动态缩小不过不会小于–Xms。就这么简单开发过程中，通常会将 -Xms 与 -Xmx 两个参数配置成相同的值，其目的是为了能够在 java 垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小而浪费资源。我们执行下面的代码System.out.println("Xmx=" + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M"); //系统的最大空间 System.out.println("free mem=" + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M"); //系统的空闲空间 System.out.println("total mem=" + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M"); //当前可用的总空间注意：此处设置的是 Java 堆大小，也就是新生代大小 + 老年代大小设置一个 VM options 的参数-Xmx20m -Xms5m -XX:+PrintGCDetails再次启动 main 方法这里 GC 弹出了一个 Allocation Failure 分配失败，这个事情发生在 PSYoungGen，也就是年轻代中这时候申请到的内存为 18M，空闲内存为 4.214195251464844M我们此时创建一个字节数组看看，执行下面的代码byte[] b = new byte[1 * 1024 * 1024]; System.out.println("分配了1M空间给数组"); System.out.println("Xmx=" + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M"); //系统的最大空间 System.out.println("free mem=" + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M"); //系统的空闲空间 System.out.println("total mem=" + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M");此时 free memory 就又缩水了，不过 total memory 是没有变化的。Java 会尽可能将 total mem 的值维持在最小堆内存大小byte[] b = new byte[10 * 1024 * 1024]; System.out.println("分配了10M空间给数组"); System.out.println("Xmx=" + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M"); //系统的最大空间 System.out.println("free mem=" + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M"); //系统的空闲空间 System.out.println("total mem=" + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M"); //当前可用的总空间这时候我们创建了一个 10M 的字节数据，这时候最小堆内存是顶不住的。我们会发现现在的 total memory 已经变成了 15M，这就是已经申请了一次内存的结果。此时我们再跑一下这个代码System.gc(); System.out.println("Xmx=" + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M"); //系统的最大空间 System.out.println("free mem=" + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M"); //系统的空闲空间 System.out.println("total mem=" + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M"); //当前可用的总空间此时我们手动执行了一次 fullgc，此时 total memory 的内存空间又变回 5.5M 了，此时又是把申请的内存释放掉的结果。4.2 调整新生代和老年代的比值-XX:NewRatio --- 新生代（eden+2\*Survivor）和老年代（不包含永久区）的比值 例如：-XX:NewRatio=4，表示新生代:老年代=1:4，即新生代占整个堆的 1/5。在 Xms=Xmx 并且设置了 Xmn 的情况下，该参数不需要进行设置。4.3 调整 Survivor 区和 Eden 区的比值-XX:SurvivorRatio（幸存代）--- 设置两个 Survivor 区和 eden 的比值 例如：8，表示两个 Survivor:eden=2:8，即一个 Survivor 占年轻代的 1/104.4 设置年轻代和老年代的大小-XX:NewSize --- 设置年轻代大小 -XX:MaxNewSize --- 设置年轻代最大值可以通过设置不同参数来测试不同的情况，反正最优解当然就是官方的 Eden 和 Survivor 的占比为 8:1:1，然后在刚刚介绍这些参数的时候都已经附带了一些说明，感兴趣的也可以看看。反正最大堆内存和最小堆内存如果数值不同会导致多次的 gc，需要注意。4.5 小总结根据实际事情调整新生代和幸存代的大小，官方推荐新生代占 java 堆的 3/8，幸存代占新生代的 1/10在 OOM 时，记得 Dump 出堆，确保可以排查现场问题，通过下面命令你可以输出一个.dump 文件，这个文件可以使用 VisualVM 或者 Java 自带的 Java VisualVM 工具。-Xmx20m -Xms5m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=你要输出的日志路径一般我们也可以通过编写脚本的方式来让 OOM 出现时给我们报个信，可以通过发送邮件或者重启程序等来解决。4.6 永久区的设置-XX:PermSize -XX:MaxPermSize初始空间（默认为物理内存的 1/64）和最大空间（默认为物理内存的 1/4）。也就是说，jvm 启动时，永久区一开始就占用了 PermSize 大小的空间，如果空间还不够，可以继续扩展，但是不能超过 MaxPermSize，否则会 OOM。tips：如果堆空间没有用完也抛出了 OOM，有可能是永久区导致的。堆空间实际占用非常少，但是永久区溢出 一样抛出 OOM。4.7 JVM 的栈参数调优4.7.1 调整每个线程栈空间的大小可以通过-Xss：调整每个线程栈空间的大小JDK5.0 以后每个线程堆栈大小为 1M，以前每个线程堆栈大小为 256K。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的，不能无限生成，经验值在 3000~5000 左右4.7.2 设置线程栈的大小-XXThreadStackSize： 设置线程栈的大小(0 means use default stack size)这些参数都是可以通过自己编写程序去简单测试的，这里碍于篇幅问题就不再提供 demo 了4.8 (可以直接跳过了)JVM 其他参数介绍形形色色的参数很多，就不会说把所有都扯个遍了，因为大家其实也不会说一定要去深究到底。4.8.1 设置内存页的大小-XXThreadStackSize： 设置内存页的大小，不可设置过大，会影响Perm的大小4.8.2 设置原始类型的快速优化-XX:+UseFastAccessorMethods： 设置原始类型的快速优化4.8.3 设置关闭手动 GC-XX:+DisableExplicitGC： 设置关闭System.gc()(这个参数需要严格的测试)4.8.4 设置垃圾最大年龄-XX:MaxTenuringThreshold 设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代.对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间,加在年轻代即被回收的概率。该参数只有在串行GC时才有效.4.8.5 加快编译速度-XX:+AggressiveOpts 加快编译速度4.8.6 改善锁机制性能-XX:+UseBiasedLocking4.8.7 禁用垃圾回收-Xnoclassgc4.8.8 设置堆空间存活时间-XX:SoftRefLRUPolicyMSPerMB 设置每兆堆空闲空间中SoftReference的存活时间，默认值是1s。4.8.9 设置对象直接分配在老年代-XX:PretenureSizeThreshold 设置对象超过多大时直接在老年代分配，默认值是0。4.8.10 设置 TLAB 占 eden 区的比例-XX:TLABWasteTargetPercent 设置TLAB占eden区的百分比，默认值是1% 。4.8.11 设置是否优先 YGC-XX:+CollectGen0First 设置FullGC时是否先YGC，默认值是false。finally真的扯了很久这东西，参考了多方的资料，有极客时间的《深入拆解虚拟机》和《Java 核心技术面试精讲》，也有百度，也有自己在学习的一些线上课程的总结。希望对你有所帮助，谢谢。
AI 摘要 文章通过短信发送示例，对比演示静态代理、JDK动态代理与CGLIB动态代理的编码步骤、优缺点及适用场景：静态代理编译期硬编码，维护困难；JDK动态代理基于接口生成运行时代理类；CGLIB用继承生成子类，可代理无接口类；总结三者灵活性、性能与框架应用差异。 此摘要根据文章内容自动生成，仅用于文章内容的解释与总结07 静态代理+JDK/CGLIB 动态代理实战代理模式代理模式是一种比较好的理解的设计模式。简单来说就是 我们使用代理对象来代替对真实对象(real object)的访问，这样就可以在不修改原目标对象的前提下，提供额外的功能操作，扩展目标对象的功能。代理模式的主要作用是扩展目标对象的功能，比如说在目标对象的某个方法执行前后你可以增加一些自定义的操作。举个例子：你找了小红来帮你问话，小红就可以看作是代理你的代理对象，代理的行为（方法）是问话。https://medium.com/@mithunsasidharan/understanding-the-proxy-design-pattern-5e63fe38052a代理模式有静态代理和动态代理两种实现方式，我们 先来看一下静态代理模式的实现。静态代理静态代理中，我们对目标对象的每个方法的增强都是手动完成的（_后面会具体演示代码_），非常不灵活（_比如接口一旦新增加方法，目标对象和代理对象都要进行修改_）且麻烦(_需要对每个目标类都单独写一个代理类_)。 实际应用场景非常非常少，日常开发几乎看不到使用静态代理的场景。上面我们是从实现和应用角度来说的静态代理，从 JVM 层面来说， 静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。静态代理实现步骤:定义一个接口及其实现类；创建一个代理类同样实现这个接口将目标对象注入进代理类，然后在代理类的对应方法调用目标类中的对应方法。这样的话，我们就可以通过代理类屏蔽对目标对象的访问，并且可以在目标方法执行前后做一些自己想做的事情。下面通过代码展示！1.定义发送短信的接口public interface SmsService { String send(String message); }2.实现发送短信的接口public class SmsServiceImpl implements SmsService { public String send(String message) { System.out.println("send message:" + message); return message; } }3.创建代理类并同样实现发送短信的接口public class SmsProxy implements SmsService { private final SmsService smsService; public SmsProxy(SmsService smsService) { this.smsService = smsService; } @Override public String send(String message) { //调用方法之前，我们可以添加自己的操作 System.out.println("before method send()"); smsService.send(message); //调用方法之后，我们同样可以添加自己的操作 System.out.println("after method send()"); return null; } }4.实际使用public class Main { public static void main(String[] args) { SmsService smsService = new SmsServiceImpl(); SmsProxy smsProxy = new SmsProxy(smsService); smsProxy.send("java"); } }运行上述代码之后，控制台打印出：before method send() send message:java after method send()可以输出结果看出，我们已经增强了 SmsServiceImpl 的send()方法。动态代理相比于静态代理来说，动态代理更加灵活。我们不需要针对每个目标类都单独创建一个代理类，并且也不需要我们必须实现接口，我们可以直接代理实现类( _CGLIB 动态代理机制_)。从 JVM 角度来说，动态代理是在运行时动态生成类字节码，并加载到 JVM 中的。说到动态代理，Spring AOP、RPC 框架应该是两个不得不的提的，它们的实现都依赖了动态代理。动态代理在我们日常开发中使用的相对较小，但是在框架中的几乎是必用的一门技术。学会了动态代理之后，对于我们理解和学习各种框架的原理也非常有帮助。就 Java 来说，动态代理的实现方式有很多种，比如 JDK 动态代理、CGLIB 动态代理等等。guide-rpc-framework 使用的是 JDK 动态代理，我们先来看看 JDK 动态代理的使用。另外，虽然 guide-rpc-framework 没有用到 CGLIB 动态代理 ，我们这里还是简单介绍一下其使用以及和JDK 动态代理的对比。JDK 动态代理机制介绍在 Java 动态代理机制中 **InvocationHandler** 接口和 **Proxy** 类是核心。Proxy 类中使用频率最高的方法是：newProxyInstance() ，这个方法主要用来生成一个代理对象。 public static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h) throws IllegalArgumentException { ...... }这个方法一共有 3 个参数：loader :类加载器，用于加载代理对象。interfaces : 被代理类实现的一些接口；h : 实现了 InvocationHandler 接口的对象；要实现动态代理的话，还必须需要实现InvocationHandler 来自定义处理逻辑。 当我们的动态代理对象调用一个方法时候，这个方法的调用就会被转发到实现InvocationHandler 接口类的 invoke 方法来调用。public interface InvocationHandler { /** * 当你使用代理对象调用方法的时候实际会调用到这个方法 */ public Object invoke(Object proxy, Method method, Object[] args) throws Throwable; }invoke() 方法有下面三个参数：proxy :动态生成的代理类method : 与代理类对象调用的方法相对应args : 当前 method 方法的参数也就是说：你通过**Proxy** 类的 **newProxyInstance()** 创建的代理对象在调用方法的时候，实际会调用到实现**InvocationHandler** 接口的类的 **invoke()**方法。 你可以在 invoke() 方法中自定义处理逻辑，比如在方法执行前后做什么事情。JDK 动态代理类使用步骤定义一个接口及其实现类；自定义 InvocationHandler 并重写invoke方法，在 invoke 方法中我们会调用原生方法（被代理类的方法）并自定义一些处理逻辑；通过 Proxy.newProxyInstance(ClassLoader loader,Class<?>[] interfaces,InvocationHandler h) 方法创建代理对象；代码示例这样说可能会有点空洞和难以理解，我上个例子，大家感受一下吧！1.定义发送短信的接口public interface SmsService { String send(String message); }2.实现发送短信的接口public class SmsServiceImpl implements SmsService { public String send(String message) { System.out.println("send message:" + message); return message; } }3.定义一个 JDK 动态代理类import java.lang.reflect.InvocationHandler; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; /** * @author shuang.kou * @createTime 2020年05月11日 11:23:00 */ public class DebugInvocationHandler implements InvocationHandler { /** * 代理类中的真实对象 */ private final Object target; public DebugInvocationHandler(Object target) { this.target = target; } public Object invoke(Object proxy, Method method, Object[] args) throws InvocationTargetException, IllegalAccessException { //调用方法之前，我们可以添加自己的操作 System.out.println("before method " + method.getName()); Object result = method.invoke(target, args); //调用方法之后，我们同样可以添加自己的操作 System.out.println("after method " + method.getName()); return result; } }invoke() 方法: 当我们的动态代理对象调用原生方法的时候，最终实际上调用到的是 invoke() 方法，然后 invoke() 方法代替我们去调用了被代理对象的原生方法。4.获取代理对象的工厂类public class JdkProxyFactory { public static Object getProxy(Object target) { return Proxy.newProxyInstance( target.getClass().getClassLoader(), // 目标类的类加载 target.getClass().getInterfaces(), // 代理需要实现的接口，可指定多个 new DebugInvocationHandler(target) // 代理对象对应的自定义 InvocationHandler ); } }getProxy() ：主要通过Proxy.newProxyInstance（）方法获取某个类的代理对象5.实际使用SmsService smsService = (SmsService) JdkProxyFactory.getProxy(new SmsServiceImpl()); smsService.send("java");运行上述代码之后，控制台打印出：before method send send message:java after method sendCGLIB 动态代理机制介绍JDK 动态代理有一个最致命的问题是其只能代理实现了接口的类。为了解决这个问题，我们可以用 CGLIB 动态代理机制来避免。CGLIB(_Code Generation Library_)是一个基于ASM的字节码生成库，它允许我们在运行时对字节码进行修改和动态生成。CGLIB 通过继承方式实现代理。很多知名的开源框架都使用到了CGLIB， 例如 Spring 中的 AOP 模块中：如果目标对象实现了接口，则默认采用 JDK 动态代理，否则采用 CGLIB 动态代理。在 CGLIB 动态代理机制中 **MethodInterceptor** 接口和 **Enhancer** 类是核心。你需要自定义 MethodInterceptor 并重写 intercept 方法，intercept 用于拦截增强被代理类的方法。public interface MethodInterceptor extends Callback{ // 拦截被代理类中的方法 public Object intercept(Object obj, java.lang.reflect.Method method, Object[] args, MethodProxy proxy) throws Throwable; }obj :被代理的对象（需要增强的对象）method :被拦截的方法（需要增强的方法）args :方法入参methodProxy :用于调用原始方法你可以通过 Enhancer类来动态获取被代理类，当代理类调用方法的时候，实际调用的是 MethodInterceptor 中的 intercept 方法。CGLIB 动态代理类使用步骤定义一个类；自定义 MethodInterceptor 并重写 intercept 方法，intercept 用于拦截增强被代理类的方法，和 JDK 动态代理中的 invoke 方法类似；通过 Enhancer 类的 create()创建代理类；代码示例不同于 JDK 动态代理不需要额外的依赖。CGLIB(_Code Generation Library_) 实际是属于一个开源项目，如果你要使用它的话，需要手动添加相关依赖。<dependency> <groupId>cglib</groupId> <artifactId>cglib</artifactId> <version>3.3.0</version> </dependency>1.实现一个使用阿里云发送短信的类package github.javaguide.dynamicProxy.cglibDynamicProxy; public class AliSmsService { public String send(String message) { System.out.println("send message:" + message); return message; } }2.自定义 **MethodInterceptor**（方法拦截器）import net.sf.cglib.proxy.MethodInterceptor; import net.sf.cglib.proxy.MethodProxy; import java.lang.reflect.Method; /** * 自定义MethodInterceptor */ public class DebugMethodInterceptor implements MethodInterceptor { /** * @param o 代理对象（增强的对象） * @param method 被拦截的方法（需要增强的方法） * @param args 方法入参 * @param methodProxy 用于调用原始方法 */ @Override public Object intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) throws Throwable { //调用方法之前，我们可以添加自己的操作 System.out.println("before method " + method.getName()); Object object = methodProxy.invokeSuper(o, args); //调用方法之后，我们同样可以添加自己的操作 System.out.println("after method " + method.getName()); return object; } }3.获取代理类import net.sf.cglib.proxy.Enhancer; public class CglibProxyFactory { public static Object getProxy(Class<?> clazz) { // 创建动态代理增强类 Enhancer enhancer = new Enhancer(); // 设置类加载器 enhancer.setClassLoader(clazz.getClassLoader()); // 设置被代理类 enhancer.setSuperclass(clazz); // 设置方法拦截器 enhancer.setCallback(new DebugMethodInterceptor()); // 创建代理类 return enhancer.create(); } }4.实际使用AliSmsService aliSmsService = (AliSmsService) CglibProxyFactory.getProxy(AliSmsService.class); aliSmsService.send("java");运行上述代码之后，控制台打印出：before method send send message:java after method sendJDK 动态代理和 CGLIB 动态代理对比JDK 动态代理只能只能代理实现了接口的类，而 CGLIB 可以代理未实现任何接口的类。 另外， CGLIB 动态代理是通过生成一个被代理类的子类来拦截被代理类的方法调用，因此不能代理声明为 final 类型的类和方法。就二者的效率来说，大部分情况都是 JDK 动态代理更优秀，随着 JDK 版本的升级，这个优势更加明显。静态代理和动态代理的对比灵活性 ：动态代理更加灵活，不需要必须实现接口，可以直接代理实现类，并且可以不需要针对每个目标类都创建一个代理类。另外，静态代理中，接口一旦新增加方法，目标对象和代理对象都要进行修改，这是非常麻烦的！JVM 层面 ：静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。而动态代理是在运行时动态生成类字节码，并加载到 JVM 中的。总结这篇文章中主要介绍了代理模式的两种实现：静态代理以及动态代理。涵盖了静态代理和动态代理实战、静态代理和动态代理的区别、JDK 动态代理和 Cglib 动态代理区别等内容。文中涉及到的所有源码，你可以在这里找到：https://github.com/Snailclimb/guide-rpc-framework-learning/tree/master/src/main/java/github/javaguide/proxy 。