关注“Java后端技术全栈”

回复“面试”获取全套面试材料

过程与线程

「过程」

过程的实质是一个正在执行的程序,程序运行时零碎会创立一个过程,并且「给每个过程调配独立的内存地址空间,用来保障每个过程地址不会互相烦扰」

同时,在 CPU 对过程做工夫片的切换时,保障过程切换过程中依然要从过程切换之前运行的地位处开始执行。所以过程通常还会包含程序计数器、堆栈指针。

绝对好了解点的案例:电脑上开启QQ就是开启一个过程、关上IDEA就是开启一个过程、浏关上览器就是开启一个过程.....

当咱们的电脑开启你太多的使用(QQ,微信,浏览器、PDF、word、IDEA等)后,电脑就很容易呈现卡顿,甚至死机,这里最次要起因就是CPU始终不停地切换导致的。

下图是单核CPU状况下,多过程之间的切换:

有了过程当前,能够让操作系统从宏观层面实现多利用并发。

而并发的实现是通过 CPU 工夫片不端切换执行的,对于单核 CPU来说,在任意一个时刻只会有一个过程在被CPU 调度。

线程的生命周期

既然是生命周期,那么就很有可能会有阶段性的或者状态的,比方人的毕生一样:

精子和卵子联合---> 婴儿---> 小孩--> 成年--> 中年--> 老年-->逝世

线程状态

对于线程的生命周期网上有不一样的答案,有说五种也有说六种。

Java中线程的确有6种,这是有理有据的,能够看看java.lang.Thread类中有个这么一个枚举。

public enum State {        NEW,        RUNNABLE,        BLOCKED,         WAITING,         TIMED_WAITING,         TERMINATED;}

这就是Java线程对应的状态,组合起来就是Java中一个线程的生命周期。上面是这个枚举的正文:

每种状态简略阐明:

  • NEW(初始):线程被创立后尚未启动。
  • RUNNABLE(运行):包含了操作系统线程状态中的Running和Ready,也就是处于此状态的线程可能正在运行,也可能正在期待系统资源,如期待CPU为它调配工夫片。
  • BLOCKED(阻塞):线程阻塞于锁。
  • WAITING(期待):线程须要期待其余线程做出一些特定动作(告诉或中断)。
  • TIME_WAITING(超时期待):该状态不同于WAITING,它能够在指定的工夫内自行返回。
  • TERMINATED(终止):该线程曾经执行结束。
线程生命周期

借用网上的这张图,这张图形容的很分明了,这里就不在啰嗦。

何为线程平安?

咱们常常会据说某个类是线程平安,某个类不是线程平安的。那么到底什么叫做线程平安呢?

咱们援用《Java Concurrency in Practice》外面的定义:

在不应用额定同步的状况下,多个线程拜访一个对象时,不管线程之间如何交替执行或者在调用方进行任何其它的协调操作,调用这个对象的行为都能失去正确的后果,那么这个对象是线程平安的。

也能够这么了解:

多个线程拜访同一个对象时,如果不必思考这些线程在运行时环境下的调度和交替执行,也不须要进行额定的同步,或者在调用方进行任何其余操作,调用这个对象的行为都能够取得正确的后果,那么这个对象就是线程平安的。或者说:一个类或者程序所提供的接口对于线程来说是原子操作或者多个线程之间的切换不会导致该接口的执行后果存在二义性,也就是说咱们不必思考同步的问题。

能够简略的了解为:“你轻易怎么调用,出了问题算我输”。

这个定义对于类来说是非常严格的,即便是Java API中标为线程平安的类也很难满足这个要求。

比方Vector是标记为线程平安的,但实际上并不能满足这个条件,举个例子:

public class Vector<E>  extends AbstractList<E>  implements List<E>, RandomAccess, Cloneable, java.io.Serializable{    public synchronized E get(int index) {        if (index >= elementCount)            throw new ArrayIndexOutOfBoundsException(index);        return elementData(index);    }    public synchronized void removeElementAt(int index) {        modCount++;        if (index >= elementCount) {            throw new ArrayIndexOutOfBoundsException(index + " >= " +                                                     elementCount);        }        else if (index < 0) {            throw new ArrayIndexOutOfBoundsException(index);        }        int j = elementCount - index - 1;        if (j > 0) {            System.arraycopy(elementData, index + 1, elementData, index, j);        }        elementCount--;        elementData[elementCount] = null; /* to let gc do its work */    }    //....基本上所有办法都是synchronized润饰的} 

来看上面一个案例:

判断Vector中第0个元素是不是空字符,如果是空字符就将其删除。

package com.java.tian.blog.utils;import java.util.Vector;public class SynchronizedDemo{    static Vector<String> vct = new Vector<String>();    public  void remove() {        if("".equals(vct.get(0))) {            vct.remove(0);        }    }    public static void main(String[] args) {        vct.add("");        SynchronizedDemo synchronizedDemo = new SynchronizedDemo();        new Thread(new Runnable() {            @Override            public void run() {                synchronizedDemo.remove();            }        },"线程1").start();        new Thread(new Runnable() {            @Override            public void run() {                synchronizedDemo.remove();            }        },"线程2").start();    }}

下面的逻辑看起来没有问题,实际上是有可能导致谬误的:假如第0个元素是空字符,判断的时候失去的后果是true。

两个线程同时执行下面的remove办法,(「极其的状况」)都「可能」get到的是"",而后都去删除第0个元素,这个元素有可能曾经被其它线程删除了,因而Vector不是相对线程平安的。(下面这个案例只是做演示而已,在你的业务代码外面这么写的话,线程平安真的就不能靠Vector来保障了)。

通常状况下咱们说的线程平安都是绝对线程平安,绝对线程平安只要求调用单个办法的时候不须要同步就能够失去正确的后果,但数多个办法组合调用的时候也是有可能导致多线程问题的。

如果想让下面的操作执行正确,咱们须要在调用Vector办法的时候增加额定的同步操作:

package com.java.tian.blog.utils;import java.util.Vector;public class SynchronizedDemo {    static Vector<String> vct = new Vector<String>();    public void remove() {        synchronized (vct) {        //synchronized (SynchronizedDemo.class) {            if ("".equals(vct.get(0))) {                vct.remove(0);            }        }    }    public static void main(String[] args) {        vct.add("");        SynchronizedDemo synchronizedDemo = new SynchronizedDemo();        new Thread(new Runnable() {            @Override            public void run() {                synchronizedDemo.remove();            }        }, "线程1").start();        new Thread(new Runnable() {            @Override            public void run() {                synchronizedDemo.remove();            }        }, "线程2").start();    }}

依据Vector的源代码可知:Vector的每个办法都应用了synchronized关键字润饰,因而锁对象就是这个对象自身。在下面的代码中咱们尝试获取的也是vct对象的锁,能够和vct对象的其它办法互斥,因而这样做能够保障失去正确的后果。

如果Vector外部应用的是其它锁同步的,并封装了锁对象,那么咱们无论如何都无奈正确执行这个“先判断后批改”的操作。

假如被封装的对象锁为obj,get()和remove()办法对应的锁都是obj,而整个操作过程获取的是vct的锁,一个线程调用get()办法胜利后就开释了obj的锁,这时这个线程只持有vct的锁,而其它线程能够取得obj的锁并抢先一步删除了第0个元素。

Java为开发者提供了很多弱小的工具类,这些工具类外面有的是线程平安的,有的不是线程平安的。在这里咱们列举几个面试常考的:

线程平安的类:Vector、Hashtable、StringBuffer

非线程平安的类:ArrayList、HashMap、StringBuilder

有人可能会反诘:为什么Java不把所有的类都设计成线程平安的呢?这样对于咱们开发者来说岂不是更爽吗?咱们就不必思考什么线程平安问题了。

事件都是具备两面性的,取得线程平安然而性能会有所降落,毕竟锁的开销是摆在那里的。线程不平安然而性能会有所晋升,具体场景还得看业务更偏差于哪一个。

一个问题引发的思考:

public class SynchronizedDemo {    static int count;    public void incre() {        try {            //每个线程都睡一会,模拟业务代码            Thread.sleep(100 );        } catch (InterruptedException e) {            e.printStackTrace();        }        count++;    }    public static void main(String[] args) {        SynchronizedDemo synchronizedDemo = new SynchronizedDemo();        for (int i = 0; i < 1000; i++) {            new Thread(new Runnable() {                @Override                public void run() {                    synchronizedDemo.incre();                }            }).start();        }        try {            //让主线程期待所有线程执行结束            Thread.sleep(2000L);        } catch (InterruptedException e) {            e.printStackTrace();        }        System.out.println(count);    }}

下面这段代码输入的后果是不确定的,后果是小于等于1000。

1000线程都去对count进行++操作。

对象内存布局

对象在内存中的存储能够分为 3 块区域,别离是对象头、实例数据和对齐填充。

其中,对象头包含两局部内容,一部分是对象自身的运行时数据,像 GC 分代年龄、哈希码、锁状态标识等等,官网称之为“Mark Word”,如果疏忽压缩指针的影响,这部分数据在 32 位和 64 位的虚拟机中别离占 32 位和 64 位。

然而对象须要存储的运行时数据很多,32 位或者 64 位都不肯定能存的下,思考到虚拟机的空间效率,这个 Mark Word 被设计成一个非固定的数据结构,它会依据对象的状态复用本人的存储空间,对象处于不同状态的时候,对应的 bit 示意的含意可能会不一样,见下图,以 32 位 Hot Spot 虚拟机为例:

从上图中咱们能够看出,如果对象处于未锁定状态(无锁态),那么 Mark Word 的 25 位用于存储对象的哈希码,4 位用于存储对象分代年龄,1 位固定为 0,两位用于存储锁标记位。

这个图对于了解前面提到的轻量级锁、偏差锁是十分重要的,当然咱们当初能够先着重思考对象处于重量级锁状态下的状况,也就是锁标记位为 10。同时咱们看到,无锁态和偏差锁状态下,2 位锁标记位都是“01”,留有 1 位示意是否可偏差,咱们权且叫它“偏差位”。

「注」:对象头的另一部分则是类型指针,虚拟机能够通过这个指针来确认该对象是哪个类的实例。然而咱们要留神,并不是所有的虚拟机都必须以这种形式来确定对象的元数据信息。对象的拜访定位个别有句柄和间接指针两种,如果应用句柄的话,那么对象的元数据信息能够间接蕴含在句柄中(当然也包含对象实例数据的地址信息),也就没必要将这些元数据和实例数据存储在一起了。至于实例数据和对齐填充,这里暂不做探讨。

后面咱们提到了,Java 中的每个对象都与一个 monitor 相关联,当锁标记位为 10 时,除了 2bit 的标记位,指向的就是 monitor 对象的地址(还是以 32 位虚拟机为例)。这里咱们能够翻阅一下 OpenJDK 的源码,如果咱们须要下载openJDK的源码:

找到。这里先看一下markOpp.hpp文件。该文件的相对路径为:

openjdkhotspotsrcsharevmoops

下图是文件中的正文局部:

咱们能够看到,其中形容了 32 位和 64 位下 Mark World 的存储状态。也能够看到64位下,前25位是没有应用的。

咱们也能够看到 markOop.hpp 中定义的锁状态枚举,对应咱们后面提到的无锁、偏差锁、轻量级锁、重量级锁(收缩锁)、GC 标记等:

 enum { locked_value             = 0,//00 轻量级锁         unlocked_value           = 1,//01 无锁         monitor_value            = 2,//10 重量级锁         marked_value             = 3,//11 GC标记         biased_lock_pattern      = 5 //101 偏差锁,1位偏差标记和2位状态标记(01)  };

从正文中,咱们也能够看到对其的简要形容,前面会咱们具体解释:

这里咱们的重心还是是重量级锁,所以咱们看看源码中 monitor 对象是如何定义的,对应的头文件是 objectMonitor.hpp,文件门路为:

openjdkhotspotsrcsharevmruntime

咱们来简略看一下这个 objectMonitor.hpp 的定义:

 // initialize the monitor, exception the semaphore, all other fields  // are simple integers or pointers  ObjectMonitor() {    _header       = NULL;    _count        = 0;    _waiters      = 0,//期待线程数    _recursions   = 0;//重入次数    _object       = NULL;    _owner        = NULL;//持有锁的线程(逻辑上,实际上除了THREAD,还可能是Lock Record)    _WaitSet      = NULL;//线程wait之后会进入该列表    _WaitSetLock  = 0 ;    _Responsible  = NULL ;    _succ         = NULL ;    _cxq          = NULL ;//期待获取锁的线程列表,和_EntryList配合应用    FreeNext      = NULL ;    _EntryList    = NULL ;//期待获取锁的线程列表,和_cxq配合应用    _SpinFreq     = 0 ;    _SpinClock    = 0 ;    OwnerIsThread = 0 ;//以后持有者是否为THREAD类型,如果是轻量级锁收缩而来,还没有enter的话,                       //_owner存储的可能会是Lock Record    _previous_owner_tid = 0;  }

简略的说,当多个线程竞争拜访同一段同步代码块时,如果线程获取到了 monitor,那么就会把 _owner 设置成以后线程,如果是重入的话,_recursions 会加 1,如果获取 monitor 失败,则会进入 _cxq队列。

锁被开释时,_cxq中的线程会被挪动到 _EntryList中,并且唤醒_EntryList 队首线程。当然,选取唤醒线程有几个不同的策略(Knob_QMode),还是前面联合源码解析。

「注」_cxq_EntryList实质上是ObjectWaiter 类型,它实质上其实是一个双向链表 (具备前后指针),只是在应用的时候不肯定要当做双向链表应用,比方 _cxq 是当做单向链表应用的,_EntryList是当做双向链表应用的。

什么场景会导致线程的上下文切换?

导致线程上下文切换的有两种类型:

自发性上下文切换是指线程由 Java 程序调用导致切出,在多线程编程中,执行调用上图中的办法或关键字,经常就会引发自发性上下文切换。

非自发性上下文切换指线程因为调度器的起因被迫切出。常见的有:线程被调配的工夫片用完,虚拟机垃圾回收导致或者执行优先级的问题导致。

waity /notify

留神两个队列:

  • 期待队列:notifyAll/notify唤醒的就是期待队列中的线程;
  • 同步线程:就是竞争锁的所有线程,期待队列中的线程被唤醒后进入同步队列。

sleep与wait的区别

sleep
  • 让以后线程休眠指定工夫。
  • 休眠工夫的准确性依赖于零碎时钟和CPU调度机制。
  • 不开释已获取的锁资源,如果sleep办法在同步上下文中调用,那么其余线程是无奈进入到以后同步块或者同步办法中的。
  • 可通过调用interrupt()办法来唤醒休眠线程。
  • sleep是Thread里的办法

wait
  • 让以后线程进入期待状态,当别的其余线程调用notify()或者notifyAll()办法时,以后线程进入就绪状态
  • wait办法必须在同步上下文中调用,例如:同步办法块或者同步办法中,这也就意味着如果你想要调用wait办法,前提是必须获取对象上的锁资源
  • 当wait办法调用时,以后线程将会开释已获取的对象锁资源,并进入期待队列,其余线程就能够尝试获取对象上的锁资源。
  • wait是Object中的办法

乐观锁、乐观锁、可重入锁.....

作为一个Java开发多年的人来说,必定多多少少相熟一些锁,或者听过一些锁。明天就来做一个锁相干总结。

乐观锁和乐观锁

乐观锁

顾名思义,他就是很乐观,把事件都想的最坏,是指该锁只能被一个线程锁持有,如果A线程获取到锁了,这时候线程B想获取锁只能排队期待线程A开释。

在数据库中这样操作:

select user_name,user_pwd from t_user for update;
乐观锁

顾名思义,乐观,人乐观就是什么是都想得开,船到桥头天然直。乐观锁就是我都感觉他们都没有拿到锁,只有我拿到锁了,最初再去问问这个锁真的是我获取的吗?是就把事件给干了。

典型的代表:CAS=Compare and Swap 先比拟哈,资源是不是我之前看到的那个,是那我就把他换成我的。不是就算了。

在Java中java.util.concurrent.atomic包上面的原子变量就是应用了乐观锁的一种实现形式CAS实现。

通常都是 应用version、工夫戳等来比拟是否已被其余线程批改过。

应用乐观锁还是应用乐观锁?

在乐观锁与乐观锁的抉择下面,次要看下两者的区别以及实用场景就能够了。

「响应效率」

如果须要十分高的响应速度,倡议采纳乐观锁计划,胜利就执行,不胜利就失败,不须要期待其余并发去开释锁。乐观锁并未真正加锁,效率高。一旦锁的粒度把握不好,更新失败的概率就会比拟高,容易产生业务失败。

「抵触频率」

如果抵触频率十分高,倡议采纳乐观锁,保障成功率。抵触频率大,抉择乐观锁会须要多次重试能力胜利,代价比拟大。「重试代价」

如果重试代价大,倡议采纳乐观锁。乐观锁依赖数据库锁,效率低。更新失败的概率比拟低。

乐观锁如果有人在你之前更新了,你的更新该当是被回绝的,能够让用户从新操作。乐观锁则会期待前一个更新实现。这也是区别。

偏心锁和非偏心锁

偏心锁

顾名思义,是偏心的,先来先得,FIFO;必须恪守排队规定。不能僭越。多个线程依照申请锁的程序去取得锁,线程会间接进入队列去排队,永远都是队列的第一位能力失去锁。

ReentrantLock中默认应用的非偏心锁,然而能够在构建ReentrantLock实例时候指定为偏心锁。

ReentrantLock fairSyncLock = new ReentrantLock(true);

假如线程 A 曾经持有了锁,这时候线程 B 申请该锁将会被挂起,当线程 A 开释锁后,如果以后有线程 C 也须要获取该锁,那么在偏心锁模式下,获取锁和开释锁的步骤为:

  1. 线程A获取锁--->线程A开释锁
  2. 线程B获取锁--->线程B开释锁;
  3. 线程C获取锁--->线程开释锁;

「长处」

所有的线程都能失去资源,不会饿死在队列中。

「毛病」

吞吐量会降落很多,队列外面除了第一个线程,其余的线程都会阻塞,CPU唤醒阻塞线程的开销会很大。

非偏心锁

顾名思义,老子才不论你们谁先排队的,也就是平时大家在生活中很厌恶的。生存中排队的很多,上车排队、坐电梯排队、超市结账付款排队等等。然而不是每个人都会遵守规则站着排队,这就对站着排队的人来说就不偏心了。等抢不到后再去乖乖排队。

多个线程去获取锁的时候,会间接去尝试获取,获取不到,再去进入期待队列,如果能获取到,就间接获取到锁。

下面说过在ReentrantLock中默认应用的非偏心锁,两种形式:

ReentrantLock fairSyncLock = new ReentrantLock(false);

或者:

ReentrantLock fairSyncLock = new ReentrantLock();

都能够实现非偏心锁。

「长处」

能够缩小CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不用取唤醒所有线程,会缩小唤起线程的数量。

「毛病」

大家可能也发现了,这样可能导致队列两头的线程始终获取不到锁或者长时间获取不到锁,导致饿死。

独享锁和共享锁

独享锁

独享锁也叫排他锁/互斥锁,是指该锁一次只能被一个线程锁持有。如果线程T对数据A加上排他锁后,则其余线程不能再对A加任何类型的锁。取得排他锁的线程既能读数据又能批改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。

共享锁

共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其余线程只能对A再加共享锁,不能加排他锁。取得共享锁的线程只能读数据,不能批改数据。

对于ReentrantLock而言,其是独享锁。然而对于Lock的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。

  1. 读锁的共享锁可保障并发读是十分高效的,读写,写读 ,写写的过程是互斥的。
  2. 独享锁与共享锁也是通过AQS来实现的,通过实现不同的办法,来实现独享或者共享。

可重入锁

若以后线程执行中曾经获取了锁,如果再次获取该锁时,就会获取不到被阻塞。

public class RentrantLockDemo {    public synchronized void test(){        System.out.println("test");    }    public synchronized void test1(){        System.out.println("test1");        test();    }    public static void main(String[] args) {        RentrantLockDemo rentrantLockDemo = new RentrantLockDemo();        //线程1        new Thread(() -> rentrantLockDemo.test1()).start();    }}

当一个线程执行test1()办法的时候,须要获取rentrantLockDemo的对象锁,在test1办法汇总又会调用test办法,然而test()的调用是须要获取对象锁的。

可重入锁也叫「递归锁」,指的是同一线程外层函数取得锁之后,内层递归函数依然有获取该锁的代码,但不受影响。

ThreadLocal设计原理

ThreadLocal名字中有个Thread示意线程,Local示意本地,咱们就了解为线程本地变量了。

先看看ThreadLocal的整体:

最关怀的三个私有办法:set、get、remove。

构造方法

 public ThreadLocal() { }

构造方法里没有任何逻辑解决,就是简略的创立一个实例。

set办法

源码为:

public void set(T value) {    //获取以后线程     Thread t = Thread.currentThread();    //这是什么鬼?     ThreadLocalMap map = getMap(t);            if (map != null)                    map.set(this, value);           else        createMap(t, value);}

先看看ThreadLocalMap是个什么东东:

ThreadLocalMapThreadLocal的动态外部类。

set办法整体为:

ThreadLocalMap构造方法:

//这个属性是ThreadLocal的,就是获取hashcode(这列很有学识,然而咱们的目标不是他)private final int threadLocalHashCode = nextHashCode();private Entry[] table;private static final int INITIAL_CAPACITY = 16;//Entry是一个弱援用 static class Entry extends WeakReference<ThreadLocal<?>> {    Object value;    Entry(ThreadLocal<?> k, Object v) {        super(k);        value = v;       } }ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {    //数组默认大小为16    table = new Entry[INITIAL_CAPACITY];    //len 为2的n次方,以ThreadLocal的计算的哈希值依照Entry[]取模(为了更好的散列)    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);    table[i] = new Entry(firstKey, firstValue);    size = 1;    //设置阈值(扩容阈值)    setThreshold(INITIAL_CAPACITY);  }

而后咱们看看map.set()办法中是如何解决的:

 private void set(ThreadLocal<?> key, Object value) {   Entry[] tab = table;            int len = tab.length;            //len 为2的n次方,以ThreadLocal的计算的哈希值依照Entry[]取模            int i = key.threadLocalHashCode & (len-1);            //找到ThreadLocal对应的存储的下标,如果以后槽内Entry不为空,            //即以后线程曾经有ThreadLocal曾经应用过Entry[i]            for (Entry e = tab[i];                 e != null;                 e = tab[i = nextIndex(i, len)]) {                ThreadLocal<?> k = e.get();                 // 以后占据该槽的就是以后的ThreadLocal ,更新value完结                if (k == key) {                    e.value = value;                    return;                }                //以后卡槽的弱援用可能会回收了,key:null value:xxxObject ,                //需清理Entry原来的value ,便于垃圾回收value,且将新的value 放在该槽里,完结                if (k == null) {                    replaceStaleEntry(key, value, i);                    return;                }            }           //在这之前没有ThreadLocal应用Entry[i],并进行值存储            tab[i] = new Entry(key, value);            //累计Entry所占的个数            int sz = ++size;            // 清理key 为null 的Entry ,可能须要扩容,扩容长度为原来的2倍,并须要进行从新hash            if (!cleanSomeSlots(i, sz) && sz >= threshold){                rehash();            }}

从下面这个set办法,咱们就大抵能够把这三个进行一个关联了:

ThreadThreadLocalThreadLocalMap

get办法

remove办法

expungeStaleEntry办法代码里有点大,所以这里就贴了进去。

//删除古老entry的外围办法private int expungeStaleEntry(int staleSlot) {    Entry[] tab = table;    int len = tab.length;                tab[staleSlot].value = null;//删除value    tab[staleSlot] = null;//删除entry    size--;//map的size自减    // 遍历指定删除节点,所有后续节点    Entry e;    int i;    for (i = nextIndex(staleSlot, len);         (e = tab[i]) != null;         i = nextIndex(i, len)) {        ThreadLocal<?> k = e.get();        if (k == null) {//key为null,执行删除操作            e.value = null;            tab[i] = null;            size--;        } else {//key不为null,从新计算下标            int h = k.threadLocalHashCode & (len - 1);            if (h != i) {//如果不在同一个地位                tab[i] = null;//把老地位的entry置null(删除)                // 从h开始往后遍历,始终到找到空为止,插入                 while (tab[h] != null){                    h = nextIndex(h, len);                }                tab[h] = e;               }        }    }    return i;}

举荐浏览

JVM真香系列:轻松把握JVM运行时数据区

《Maven实战》.pdf