Java Reference核心原理分析

createh52周前 (05-12)技术教程4

带着问题,看源码针对性会更强一点、印象会更深刻、并且效果也会更好。所以我先卖个关子,提两个问题(没准下次跳槽时就被问到)。

  • 我们可以用ByteBuffer的allocateDirect方法,申请一块堆外内存创建一个DirectByteBuffer对象,然后利用它去操作堆外内存。这些申请完的堆外内存,我们可以回收吗?可以的话是通过什么样的机制回收的?
  • 大家应该都知道WeakHashMap可以用来实现内存相对敏感的本地缓存,为什么WeakHashMap合适这种业务场景,其内部实现会做什么特殊处理呢?

GC可到达性与JDK中Reference类型

上面提到的两个问题,其答案都在JDK的Reference里面。JDK早期版本中并没有Reference相关的类,这导致对象被GC回收后如果想做一些额外的清理工作(比如socket、堆外内存等)是无法实现的,同样如果想要根据堆内存的实际使用情况决定要不要去清理一些内存敏感的对象也是法实现的。为此JDK1.2中引入的Reference相关的类,即今天要介绍的Reference、SoftReference、WeakReference、PhantomReference,还有与之相关的Cleaner、ReferenceQueue、ReferenceHandler等。与Reference相关核心类基本都在java.lang.ref包下面。其类关系如下


其中,SoftReference代表软引用对象,垃圾回收器会根据内存需求酌情回收软引用指向的对象。普通的GC并不会回收软引用,只有在即将OOM的时候(也就是最后一次Full GC)如果被引用的对象只有SoftReference指向的引用,才会回收。WeakReference代表弱引用对象,当发生GC时,如果被引用的对象只有WeakReference指向的引用,就会被回收。PhantomReference代表虚引用对象(也有叫幻象引用的,个人认为还是虚引用更加贴切),其是一种特殊的引用类型,不能通过虚引用获取到其关联的对象,但当GC时如果其引用的对象被回收,这个事件程序可以感知,这样我们可以做相应的处理。最后就是最常见强引用对象,也就是通常我们new出来的对象。在继续介绍Reference相关类的源码前,先来简单的看一下GC如何决定一个对象是否可被回收。其基本思路是从GC Root开始向下搜索,如果对象与GC Root之间存在引用链,则对象是可达的,GC会根据是否可到达与可到达性决定对象是否可以被回收。而对象的可达性与引用类型密切相关,对象的可到达性可分为5种。

  • 强可到达,如果从GC Root搜索后,发现对象与GC Root之间存在强引用链则为强可到达。强引用链即有强引用对象,引用了该对象。
  • 软可到达,如果从GC Root搜索后,发现对象与GC Root之间不存在强引用链,但存在软引用链,则为软可到达。软引用链即有软引用对象,引用了该对象。
  • 弱可到达,如果从GC Root搜索后,发现对象与GC Root之间不存在强引用链与软引用链,但有弱引用链,则为弱可到达。弱引用链即有弱引用对象,引用了该对象。
  • 虚可到达,如果从GC Root搜索后,发现对象与GC Root之间只存在虚引用链则为虚可到达。虚引用链即有虚引用对象,引用了该对象。
  • 不可达,如果从GC Root搜索后,找不到对象与GC Root之间的引用链,则为不可到达。

看一个简单的列子:


ObjectA为强可到达,ObjectB也为强可到达,虽然ObjectB对象被SoftReference ObjcetE 引用但由于其还被ObjectA引用所以为强可到达;而ObjectC和ObjectD为弱引用达到,虽然ObjectD对象被PhantomReference ObjcetG引用但由于其还被ObjectC引用,而ObjectC又为弱引用达到,所以ObjectD为弱引用达到;而ObjectH与ObjectI是不可到达。引用链的强弱有关系依次是 强引用 > 软引用 > 弱引用 > 虚引用,如果有更强的引用关系存在,那么引用链到达性,将由更强的引用有关系决定。

Reference核心处理流程

JVM在GC时如果当前对象只被Reference对象引用,JVM会根据Reference具体类型与堆内存的使用情况决定是否把对应的Reference对象加入到一个由Reference构成的pending链表上,如果能加入pending链表JVM同时会通知ReferenceHandler线程进行处理。ReferenceHandler线程是在Reference类被初始化时调用的,其是一个守护进程并且拥有最高的优先级。Reference类静态初始化块代码如下:

static {

   //省略部分代码...

   Thread handler = new ReferenceHandler(tg, "Reference Handler");

   handler.setPriority(Thread.MAX_PRIORITY);

   handler.setDaemon(true);

   handler.start();

   //省略部分代码...

}

而ReferenceHandler线程内部的run方法会不断地从Reference构成的pending链表上获取Reference对象,如果能获取则根据Reference的具体类型进行不同的处理,不能则调用wait方法等待GC回收对象处理pending链表的通知。ReferenceHandler线程run方法源码:

public void run() {

 //死循环,线程启动后会一直运行

      while (true) {

          tryHandlePending(true);

      }

  }

run内部调用的tryHandlePending源码:

static boolean tryHandlePending(boolean waitForNotify) {

    Reference<Object> r;

    Cleaner c;

    try {

        synchronized (lock) {

            if (pending != null) {

                r = pending;

                //instanceof 可能会抛出OOME,所以在将r从pending链上断开前,做这个处理

                c = r instanceof Cleaner ? (Cleaner) r : null;

                //将将r从pending链上断开

                pending = r.discovered;

                r.discovered = null;

            } else {

                //等待CG后的通知

                if (waitForNotify) {

                    lock.wait();

                }

                  //重试

                return waitForNotify;

            }

        }

    } catch (OutOfMemoryError x) {

        //当抛出OOME时,放弃CPU的运行时间,这样有希望收回一些存活的引用并且GC能回收部分空间。同时能避免频繁地自旋重试,导致连续的OOME异常

        Thread.yield();

        //重试

        return true;

    } catch (InterruptedException x) {

        //重试

        return true;

    }

    //如果是Cleaner类型的Reference调用其clean方法并退出。

    if (c != null) {

        c.clean();

        return true;

    }

    ReferenceQueue<? super Object> q = r.queue;

    //如果Reference有注册ReferenceQueue,则处理pending指向的Reference结点将其加入ReferenceQueue中

    if (q != ReferenceQueue.NULL) q.enqueue(r);

    return true;

}

上面tryHandlePending方法中比较重要的点是c.clean()与q.enqueue(r),这个是文章最开始提到的两个问题答案的入口。Cleaner的clean方法用于完成清理工作,而ReferenceQueue是将被回收对象加入到对应的Reference列队中,等待其他线程的后继处理。更具体地关于Cleaner与ReferenceQueue后面会再详细说明。Reference的核心处理流程可总结如下:


对Reference的核心处理流程有整体了解后,再来回过头细看一下Reference类的源码。

/* Reference实例有四种内部的状态

 * Active: 新创建Reference的实例其状态为Active。当GC检测到Reference引用的referent可达到状态发生改变时,

 * 为改变Reference的状态为Pending或Inactive。这个取决于创建Reference实例时是否注册过ReferenceQueue。

 * 注册过其状态会转换为Pending,同时GC会将其加入pending-Reference链表中,否则为转换为Inactive状态。

 *

 * Pending: 代表Reference是pending-Reference链表的成员,等待ReferenceHandler线程调用Cleaner#clean

 * 或ReferenceQueue#enqueue操作。未注册过ReferenceQueue的实例不会达到这个状态

 *

 * Enqueued: Reference实例成为其被创建时注册过的ReferenceQueue的成员,代表已入队列。当其从ReferenceQueue

 * 中移除后,其状态会变为Inactive。

 *

 * Inactive: 什么也不会做,一旦处理该状态,就不可再转换。

 *

 * 不同状态时,Reference对应的queue与成员next变量值(next可理解为ReferenceQueue中的下个结点的引用)如下:

 * Active: queue为Reference实例被创建时注册的ReferenceQueue,如果没注册为Null。此时,next为null,

 * Reference实例与queue真正产生关系。

 *

 * Pending: queue为Reference实例被创建时注册的ReferenceQueue。next为当前实例本身。

 *

 * Enqueued: queue为ReferenceQueue.ENQUEUED代表当前实例已入队列。next为queue中的下一实列结点,

 * 如果是queue尾部则为当前实例本身

 *

 * Inactive: queue为ReferenceQueue.NULL,当前实例已从queue中移除与queue无关联。next为当前实例本身。

 */

public abstract class Reference<T> {

// Reference 引用的对象

private T referent;

/* Reference注册的queue用于ReferenceHandler线程入队列处理与用户线程取Reference处理。

 * 其取值会根据Reference不同状态发生改变,具体取值见上面的分析

 */

volatile ReferenceQueue<? super T> queue;

/* pending-Reference 链表头指针,GC回收referent后会将Reference加pending-Reference链表。

 * 同时ReferenceHandler线程会获取pending指针,不为空时Cleaner.clean()或入列queue。

 * pending-Reference会采用discovered引用接链表的下个节点。

 */

private static Reference<Object> pending = null;

/* 其由VM维护,取值会根据Reference不同状态发生改变,

 * 状态为active时,代表由GC维护的discovered-Reference链表的下个节点,如果是尾部则为当前实例本身

 * 状态为pending时,代表pending-Reference的下个节点的引用。

 * 否则为null

 */

transient private Reference<T> discovered;

// 可理解为注册的queue中的下一个结点的引用。其取值会根据Reference不同状态发生改变,具体取值见上面的分析

volatile Reference next;

//用于CG同步Reference成员变量值的对象。

static private class Lock { }

private static Lock lock = new Lock();

//省略部分代码...

}

上面解释了Reference中的主要成员的作用,其中比较重要是Reference内部维护的不同状态,其状态不同成员变量queue、pending、discovered、next的取值都会发生变化。Reference的主要方法如下:

//构造函数,指定引用的对象referent

 Reference(T referent) {

     this(referent, null);

 }

 //构造函数,指定引用的对象referent与注册的queue

 Reference(T referent, ReferenceQueue<? super T> queue) {

     this.referent = referent;

     this.queue = (queue == null) ? ReferenceQueue.NULL : queue;

}

//获取引用的对象referent

public T get() {

 return this.referent;

}

//将当前对象加入创建时注册的queue中

public boolean enqueue() {

    return this.queue.enqueue(this);

}

ReferenecQueue与Cleaner源码分析

先来看下ReferenceQueue的主要成员变量的含义。

//代表Reference的queue为null。Null为ReferenceQueue子类

static ReferenceQueue<Object> NULL = new Null<>();

//代表Reference已加入当前ReferenceQueue中。

static ReferenceQueue<Object> ENQUEUED = new Null<>();

//用于同步的对象

private Lock lock = new Lock();

//当前ReferenceQueue中的头节点

private volatile Reference<? extends T> head = null;

//ReferenceQueue的长度

private long queueLength = 0;

ReferenceQueue中比较重要的方法为enqueue、poll、remove方法。

//入列队enqueue方法,只被Reference类调用,也就是上面分析中ReferenceHandler线程为调用

boolean enqueue(Reference<? extends T> r) { 

    //获取同步对象lock对应的监视器对象

    synchronized (lock) {

        //获取r关联的ReferenceQueue,如果创建r时未注册ReferenceQueue则为NULL,同样如果r已从ReferenceQueue中移除其也为null

        ReferenceQueue<?> queue = r.queue;

        //判断queue是否为NULL 或者 r已加入ReferenceQueue中,是的话则入队列失败

        if ((queue == NULL) || (queue == ENQUEUED)) {

            return false;

        }

        assert queue == this;

        //设置r的queue为已入队列

        r.queue = ENQUEUED;

        //如果ReferenceQueue头节点为null则r的next节点指向当前节点,否则指向头节点

        r.next = (head == null) ? r : head;

        //更新ReferenceQueue头节点

        head = r;

        //列队长度加1

        queueLength++;

        //为FinalReference类型引用增加FinalRefCount数量

        if (r instanceof FinalReference) {

            sun.misc.VM.addFinalRefCount(1);

        }

        //通知remove操作队列有节点

        lock.notifyAll();

        return true;

    }

}

poll方法源码相对简单,其就是从ReferenceQueue的头节点获取Reference。

public Reference<? extends T> poll() {

    //头结点为null直接返回,代表Reference还没有加入ReferenceQueue中

    if (head == null)

        return null;

    //获取同步对象lock对应的监视器对象

    synchronized (lock) {

        return reallyPoll();

    }

}

//从队列中真正poll元素的方法

private Reference<? extends T> reallyPoll() {

    Reference<? extends T> r = head;

    //double check 头节点为为null

    if (r != null) {

        //保存头节点的下个节点引用

        Reference<? extends T> rn = r.next;

        //更新queue头节点引用

        head = (rn == r) ? null : rn;

        //更新Reference的queue值,代表r已从队列中移除

        r.queue = NULL;

        //更新Reference的next为其本身

        r.next = r;

        //队列长度减1

        queueLength--;

        //为FinalReference节点FinalRefCount数量减1

        if (r instanceof FinalReference) {

            sun.misc.VM.addFinalRefCount(-1);

        }

        //返回获取的节点

        return r;

    }

    return null;

}

remove方法的源码如下:

public Reference<? extends T> remove(long timeout) throws IllegalArgumentException, InterruptedException {

    if (timeout < 0) {

        throw new IllegalArgumentException("Negative timeout value");

    }

   //获取同步对象lock对应的监视器对象

    synchronized (lock) {

        //获取队列头节点指向的Reference

        Reference<? extends T> r = reallyPoll();

        //获取到返回

        if (r != null) return r;

        long start = (timeout == 0) ? 0 : System.nanoTime();

        //在timeout时间内尝试重试获取

        for (;;) {

            //等待队列上有结点通知

            lock.wait(timeout);

            //获取队列中的头节点指向的Reference

            r = reallyPoll();

            //获取到返回

            if (r != null) return r;

            if (timeout != 0) {

                long end = System.nanoTime();

                timeout -= (end - start) / 1000_000;

                //已超时但还没有获取到队列中的头节点指向的Reference返回null

                if (timeout <= 0) return null;

                start = end;

            }

        }

    }

}

简单的分析完ReferenceQueue的源码后,再来整体回顾一下Reference的核心处理流程。JVM在GC时如果当前对象只被Reference对象引用,JVM会根据Reference具体类型与堆内存的使用情况决定是否把对应的Reference对象加入到一个由Reference构成的pending链表上,如果能加入pending链表JVM同时会通知ReferenceHandler线程进行处理。ReferenceHandler线程收到通知后会调用Cleaner#clean或ReferenceQueue#enqueue方法进行处理。如果引用当前对象的Reference类型为WeakReference且堆内存不足,那么JMV就会把WeakReference加入到pending-Reference链表上,然后ReferenceHandler线程收到通知后会异步地做入队列操作。而我们的应用程序中的线程便可以不断地去拉取ReferenceQueue中的元素来感知JMV的堆内存是否出现了不足的情况,最终达到根据堆内存的情况来做一些处理的操作。实际上WeakHashMap低层便是过通上述过程实现的,只不过实现细节上有所偏差,这个后面再分析。再来看看ReferenceHandler线程收到通知后可能会调用的另外一个类Cleaner的实现。

同样先看一下Cleaner的成员变量,再看主要的方法实现。

//继承了PhantomReference类也就是虚引用,PhantomReference源码很简单只是重写了get方法返回null

public class Cleaner extends PhantomReference<Object> {

    /* 虚队列,命名很到位。之前说CG把ReferenceQueue加入pending-Reference链中后,ReferenceHandler线程在处理时

     * 是不会将对应的Reference加入列队的,而是调用Cleaner.clean方法。但如果Reference不注册ReferenceQueue,GC处理时

     * 又无法把他加入到pending-Reference链中,所以Cleaner里面有了一个dummyQueue成员变量。

     */

    private static final ReferenceQueue<Object> dummyQueue = new ReferenceQueue();

    //Cleaner链表的头结点

    private static Cleaner first = null;

    //当前Cleaner节点的后续节点

    private Cleaner next = null;

    //当前Cleaner节点的前续节点

    private Cleaner prev = null;

    //真正执行清理工作的Runnable对象,实际clean内部调用thunk.run()方法

    private final Runnable thunk;

}

从上面的成变量分析知道Cleaner实现了双向链表的结构。先看构造函数与clean方法。

//私有方法,不能直接new

private Cleaner(Object var1, Runnable var2) {

    super(var1, dummyQueue);

    this.thunk = var2;

}

//创建Cleaner对象,同时加入Cleaner链中。

public static Cleaner create(Object var0, Runnable var1) {

    return var1 == null ? null : add(new Cleaner(var0, var1));

}

//头插法将新创意的Cleaner对象加入双向链表,synchronized保证同步

private static synchronized Cleaner add(Cleaner var0) {

    if (first != null) {

        var0.next = first;

        first.prev = var0;

    }

    //更新头节点引用

    first = var0;

    return var0;

}

public void clean() {

    //从Cleaner链表中先移除当前节点

    if (remove(this)) {

        try {

            //调用thunk.run()方法执行对应清理逻辑

            this.thunk.run();

        } catch (final Throwable var2) {

            //省略部分代码..

        }

    }

}

可以看到Cleaner的实现还是比较简单,Cleaner实现为PhantomReference类型的引用。当JVM GC时如果发现当前处理的对象只被PhantomReference类型对象引用,同之前说的一样其会将该Reference加pending-Reference链中上,只是ReferenceHandler线程在处理时如果PhantomReference类型实际类型又是Cleaner的话。其就是调用Cleaner.clean方法做清理逻辑处理。Cleaner实际是DirectByteBuffer分配的堆外内存收回的实现,具体见下面的分析。

DirectByteBuffer堆外内存回收与WeakHashMap敏感内存回收

绕开了一大圈终于回到了文章最开始提到的两个问题,先来看一下分配给DirectByteBuffer堆外内存是如何回收的。在创建DirectByteBuffer时我们实际是调用ByteBuffer#allocateDirect方法,而其实现如下:

//直接new一个指定字节大小的DirectByteBuffer对象

public static ByteBuffer allocateDirect(int capacity) {

    return new DirectByteBuffer(capacity);

}

DirectByteBuffer(int cap) {

    //省略部分代码...

    try {

        //调用unsafe分配内存

        base = unsafe.allocateMemory(size);

    } catch (OutOfMemoryError x) {

       //省略部分代码...

    }

    //省略部分代码...

    //前面分析中的Cleaner对象创建,持有当前DirectByteBuffer的引用

    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));

    att = null;

}

里面和DirectByteBuffer堆外内存回收相关的代码便是Cleaner.create(this, new Deallocator(base, size, cap))这部分。还记得之前说实际的清理逻辑是里面和DirectByteBuffer堆外内存回收相关的代码便是Cleaner里面的Runnable#run方法吗?直接看Deallocator.run方法源码:

public void run() {

    if (address == 0) {

        return;

    }

    //通过unsafe.freeMemory释放创建的堆外内存

    unsafe.freeMemory(address);

    address = 0;

    Bits.unreserveMemory(size, capacity);

}

终于找到了分配给DirectByteBuffer堆外内存是如何回收的的答案。再总结一下,创建DirectByteBuffer对象时会创建一个Cleaner对象,Cleaner对象持有了DirectByteBuffer对象的引用。当JVM在GC时,如果发现DirectByteBuffer被地方法没被引用啦,JVM会将其对应的Cleaner加入到pending-reference链表中,同时通知ReferenceHandler线程处理,ReferenceHandler收到通知后,会调用Cleaner#clean方法,而对于DirectByteBuffer创建的Cleaner对象其clean方法内部会调用unsafe.freeMemory释放堆外内存。最终达到了DirectByteBuffer对象被GC回收其对应的堆外内存也被回收的目的。

再来看一下文章开始提到的另外一个问题WeakHashMap如何实现敏感内存的回收。实际WeakHashMap实现上其Entry继承了WeakReference。

//Entry继承了WeakReference, WeakReference引用的是Map的key

 private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V> {

    V value;

    final int hash;

    Entry<K,V> next;

    /**

     * 创建Entry对象,上面分析过的ReferenceQueue,这个queue实际是WeakHashMap的成员变量,

     * 创建WeakHashMap时其便被初始化 final ReferenceQueue<Object> queue = new ReferenceQueue<>()

     */

    Entry(Object key, V value,

          ReferenceQueue<Object> queue,

          int hash, Entry<K,V> next) {

        super(key, queue);

        this.value = value;

        this.hash  = hash;

        this.next  = next;

    }

    //省略部分原码...

}

往WeakHashMap添加元素时,实际都会调用Entry的构造方法,也就是会创建一个WeakReference对象,这个对象的引用的是WeakHashMap刚加入的Key,而所有的WeakReference对象关联在同一个ReferenceQueue上。我们上面说过JVM在GC时,如果发现当前对象只有被WeakReference对象引用,那么会把其对应的WeakReference对象加入到pending-reference链表上,并通知ReferenceHandler线程处理。而ReferenceHandler线程收到通知后,对于WeakReference对象会调用ReferenceQueue#enqueue方法把他加入队列里面。现在我们只要关注queue里面的元素在WeakHashMap里面是在哪里被拿出去啦做了什么样的操作,就能找到文章开始问题的答案啦。最终能定位到WeakHashMap的expungeStaleEntries方法。

private void expungeStaleEntries() {

    //不断地从ReferenceQueue中取出,那些只有被WeakReference对象引用的对象的Reference

    for (Object x; (x = queue.poll()) != null; ) {

        synchronized (queue) {

            //转为 entry 

            Entry<K,V> e = (Entry<K,V>) x;

            //计算其对应的桶的下标

            int i = indexFor(e.hash, table.length);

            //取出桶中元素

            Entry<K,V> prev = table[i];

            Entry<K,V> p = prev;

            //桶中对应位置有元素,遍历桶链表所有元素

            while (p != null) {

                Entry<K,V> next = p.next;

                //如果当前元素(也就是entry)与queue取出的一致,将entry从链表中去除 

                if (p == e) {

                    if (prev == e)

                        table[i] = next;

                    else

                        prev.next = next;

                    //清空entry对应的value

                    e.value = null; // Help GC

                    size--;

                    break;

                }

                prev = p;

                p = next;

            }

        }

    }

}

现在只看一下WeakHashMap哪些地方会调用expungeStaleEntries方法就知道什么时候WeakHashMap里面的Key变得软可达时我们就可以将其对应的Entry从WeakHashMap里面移除。直接调用有三个地方分别是getTable方法、size方法、resize方法。 getTable方法又被很多地方调用如get、containsKey、put、remove、containsValue、replaceAll。最终看下来,只要对WeakHashMap进行操作就行调用expungeStaleEntries方法。所有只要操作了WeakHashMap,没WeakHashMap里面被再用到的Key对应的Entry就会被清除。再来总结一下,为什么WeakHashMap适合作为内存敏感缓存的实现。当JVM 在GC时,如果发现WeakHashMap里面某些Key没地方在被引用啦(WeakReference除外),JVM会将其对应的WeakReference对象加入到pending-reference链表上,并通知ReferenceHandler线程处理。而ReferenceHandler线程收到通知后将对应引用Key的WeakReference对象加入到 WeakHashMap内部的ReferenceQueue中,下次再对WeakHashMap做操作时,WeakHashMap内部会清除那些没有被引用的Key对应的Entry。这样就达到了每操作WeakHashMap时,自动的检索并清量没有被引用的Key对应的Entry的目地。




























原创文章,转载请注明: 转载自并发编程网 – ifeve.com本文链接地址: Java Reference核心原理分析

相关文章

Java 的变量类型

Java 中的变量分为两种,一种是基本类型,一种是引用类型。Java 的变量定义方式和 C 语言相似,类型在前,变量名在后。比如,定义一个整型变量:int answer = 42;变量的意思是,它的值...

java父子线程,变量传递问题解析

一,ThreadLocal 存储的线程变量不能在父子线程中传递项目中,我们经常会用ThreadLocal来存储线程变量,方便后续业务操作的获取。但是如果后续方法中又重新开线程去处理业务的时候,Thre...

java.io.File中的四个静态分隔符变量

java.io.File类包含四个静态分隔符变量。在这里,我们将了解它们以及何时使用它。分别是separator、separatorChar、pathSeparator 、pathSeparatorC...

【性能篇】关于Java性能调优你了解吗

关于Java性能调优分为两方面的优化,一方面是针对Java虚拟机内存的调优,一方面是数据库DB的调优。今天我们主要讲解Java虚拟机内存的调优,在实际开发中,几乎不可能通过单纯的调优来达到消除GC的目...

配置Java环境变量:(WIN7为例)

1.JAVA_HOME变量的设置 2.Path变量的设置 3.ClassPath变量的设置二、JDK安装群文件下载好之后,进入文件夹,双击根据提示进行安装,直至安装完成。(建议默认地址,一下以默认...

jdk环境变量的配置

1.右击打次电脑属性,进入高级系统设置.选择高级 点击环境变量2.系统变量 新建 变量名上面输入JAVA_HOME 对应的变量值则找到jdk的安装目录3.找到系统变量中的path,点击编辑,建议在最前...