zoukankan      html  css  js  c++  java
  • 理解java中的ThreadLocal 专题

    ThreadLocal每一印象:

    public class IncrementWithStaticVariable{  
        private static int seqNum = 0;  
             
        public int getNextNum(){  
            seqNum ++;  
            return seqNum;  
        }  
        public static void main(String[] args) {  
            IncrementWithStaticVariablesn = new IncrementWithStaticVariable();  
            TestClient t1 = new TestClient(sn);  
            TestClient t2 = new TestClient(sn);  
            TestClient t3 = new TestClient(sn);  
            t3.start();  
            t2.start();  
            t1.start();  
        }  
          
        private static class TestClient extends Thread{  
            private IncrementWithStaticVariable sn;  
            public TestClient(IncrementWithStaticVariable sn){  
                this.sn = sn;  
    }
    public void run(){ for(int i = 0;i<3;i++){ System.out.println("thread[" + Thread.currentThread().getName() + "]sn[" +sn.getNextNum() + "]"); } } } }

    输出:

    thread[Thread-2]sn[1]  
    thread[Thread-2]sn[3]  
    thread[Thread-2]sn[4]  
    thread[Thread-1]sn[2]  
    thread[Thread-1]sn[5]  
    thread[Thread-1]sn[6]  
    thread[Thread-0]sn[7]  
    thread[Thread-0]sn[8]  
    thread[Thread-0]sn[9]  

    上述线程t1,t2,t3争抢同一个静态变量对象seqNum,并且在run()方法中会连续三次获取seqNum值,每次获取后会对seqNum值+1。Thread-2在获取到seqNum=1后,seqNum+1成为2,立马存在Thread-1访问了seqNum值,并+1使得seqNum=3,以至于Thread-2再次访问seqNum的时候获取的seqNum=3.以上模拟了多线程下资源争抢的情景。

    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    
    public class ThreadLocalDemo {
    
        static ThreadLocal<Integer> seqNum = ThreadLocal.withInitial(() -> 0);
    
        public static void main(String[] args) {
            ExecutorService executorService = Executors.newCachedThreadPool();
            for (int i = 0; i < 3; i++) {
                executorService.execute(new Task(i));
            }
            executorService.shutdown();
        }
    
    
        /**
         * 各个线程的输出结果之间是隔离的
         * 输出结果:
         * Thread[pool-1-thread-1,5,main]:0== >0
         * Thread[pool-1-thread-1,5,main]:0== >1
         * Thread[pool-1-thread-1,5,main]:0== >3
         * Thread[pool-1-thread-2,5,main]:1== >0
         * Thread[pool-1-thread-2,5,main]:1== >1
         * Thread[pool-1-thread-2,5,main]:1== >3
         * Thread[pool-1-thread-3,5,main]:2== >0
         * Thread[pool-1-thread-3,5,main]:2== >1
         * Thread[pool-1-thread-3,5,main]:2== >3
         */
        private static class Task implements Runnable {
            private int flag;
    
            Task(int flag) {
                this.flag = flag;
            }
    
            @Override
            public void run() {
                for (int i = 0; i < 3; i++) {
                    seqNum.set(seqNum.get() + i);
                    System.out.println(Thread.currentThread() + ":" + flag + "== >" + seqNum.get());
                }
            }
        }
    
    
    } 

    输出:

    Thread[pool-1-thread-1,5,main]:0== >0
    Thread[pool-1-thread-1,5,main]:0== >1
    Thread[pool-1-thread-1,5,main]:0== >3
    Thread[pool-1-thread-2,5,main]:1== >0
    Thread[pool-1-thread-2,5,main]:1== >1
    Thread[pool-1-thread-2,5,main]:1== >3
    Thread[pool-1-thread-3,5,main]:2== >0
    Thread[pool-1-thread-3,5,main]:2== >1
    Thread[pool-1-thread-3,5,main]:2== >3

    上述线程t1,t2,t3共享sn,使用同一个seqNum对象,但是线程却没有互相影响,均是各自打印自己的1,2,3.为什么呢?

    因为ThreadLocal,多线程下会复制一份自己的seqNum,各自线程之间的数据是互不影响的。所以把不安全的变量放以泛型的方式放入ThreadLocal可以解决线程不安全的问题。

    http://blog.csdn.net/weiweiai123456/article/details/40298485



    一、对ThreadLocal概述

    JDK API 写道:

    该类提供了线程局部 (thread-local) 变量。这些变量不同于它们的普通对应物,因为访问某个变量(通过其 get 或 set 方法)的每个线程都有自己的局部变量,它独立于变量的初始化副本。ThreadLocal 实例通常是类中的 private static 字段,它们希望将状态与某一个线程(例如,用户 ID 或事务 ID)相关联。

    二、结合源码理解

    可以看到ThreadLocal类中的变量只有这3个int型:

    public class ThreadLocal<T> {
        /**
         * ThreadLocals rely on per-thread linear-probe hash maps attached
         * to each thread (Thread.threadLocals and
         * inheritableThreadLocals).  The ThreadLocal objects act as keys,
         * searched via threadLocalHashCode.  This is a custom hash code
         * (useful only within ThreadLocalMaps) that eliminates collisions
         * in the common case where consecutively constructed ThreadLocals
         * are used by the same threads, while remaining well-behaved in
         * less common cases.
         */
        private final int threadLocalHashCode = nextHashCode();
    
        /**
         * The next hash code to be given out. Updated atomically. Starts at
         * zero.
         */
        private static AtomicInteger nextHashCode = 
        new AtomicInteger();
    
        /**
         * The difference between successively generated hash codes - turns
         * implicit sequential thread-local IDs into near-optimally spread
         * multiplicative hash values for power-of-two-sized tables.
         */
        private static final int HASH_INCREMENT = 0x61c88647;

    ThreadLocal实例的变量只有 threadLocalHashCode     
    ThreadLocal类的静态变量nextHashCode 和HASH_INCREMENT     
    实际上HASH_INCREMENT是一个常量,表示了连续分配的两个ThreadLocal实例的 threadLocalHashCode值的增量,而nextHashCode 的表示了即将分配的下一个ThreadLocal实例的threadLocalHashCode 的值。 
       而nextHashCode()方法就是将ThreadLocal类的下一个hashCode值即nextHashCode的值赋给实例的threadLocalHashCode,然后nextHashCode的值增加HASH_INCREMENT这个值。
     

        /**
         * Returns the next hash code.
         */
        private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT); 
        }

       ThreadLocal有一个ThreadLocalMap静态内部类,你可以简单理解为一个MAP,这个"Map"为每个线程复制一个变量的‘拷贝’存储其中。
     看一下set()方法:  获取当前线程的引用,从map中获取该线程对应的map,如果map存在更新缓存值,否则创建并存储

        /**
         * Sets the current thread's copy of this thread-local variable
         * to the specified value.  Most subclasses will have no need to 
         * override this method, relying solely on the {@link #initialValue}
         * method to set the values of thread-locals.
         *
         * @param value the value to be stored in the current thread's copy of
         *        this thread-local.
         */
        public void set(T value) {
            Thread t = Thread.currentThread();
            ThreadLocalMap map = getMap(t);
            if (map != null)
                map.set(this, value);
            else
                createMap(t, value);
        }

    再来看一下get()方法: 
    首先获取当前线程引用,以此为key去获取响应的ThreadLocalMap,如果此"Map"不存在则初始化一个,否则返回其中的变量。  
    调用get方法如果此Map不存在首先初始化,创建此map,将线程为key,初始化的vlaue存入其中,注意此处的initialValue,我们可以覆盖此方法,在首次调用时初始化一个适当的值,默认是null

        /**
         * Returns the value in the current thread's copy of this
         * thread-local variable.  If the variable has no value for the
         * current thread, it is first initialized to the value returned
         * by an invocation of the {@link #initialValue} method.
         *
         * @return the current thread's value of this thread-local
         */
        public T get() {
            Thread t = Thread.currentThread();
            ThreadLocalMap map = getMap(t);
            if (map != null) {
                ThreadLocalMap.Entry e = map.getEntry(this);
                if (e != null)
                    return (T)e.value;
            }
            return setInitialValue();
        }
        /**
         * Variant of set() to establish initialValue. Used instead
         * of set() in case user has overridden the set() method.
         *
         * @return the initial value
         */
        private T setInitialValue() {
            T value = initialValue();
            Thread t = Thread.currentThread();
            ThreadLocalMap map = getMap(t);
            if (map != null)
                map.set(this, value);
            else
                createMap(t, value);
            return value;
        }
        /**
         * Returns the current thread's "initial value" for this
         * thread-local variable.  This method will be invoked the first
         * time a thread accesses the variable with the {@link #get}
         * method, unless the thread previously invoked the {@link #set}
         * method, in which case the <tt>initialValue</tt> method will not
         * be invoked for the thread.  Normally, this method is invoked at
         * most once per thread, but it may be invoked again in case of
         * subsequent invocations of {@link #remove} followed by {@link #get}.
         *
         * <p>This implementation simply returns <tt>null</tt>; if the
         * programmer desires thread-local variables to have an initial
         * value other than <tt>null</tt>, <tt>ThreadLocal</tt> must be
         * subclassed, and this method overridden.  Typically, an
         * anonymous inner class will be used.
         *
         * @return the initial value for this thread-local
         */
        protected T initialValue() {
            return null;
        }

    我们来看下ThreadLocalMap静态内部类,在ThreadLocalMap 内部的Entry是WeakReference

        /**
         * ThreadLocalMap is a customized hash map suitable only for
         * maintaining thread local values. No operations are exported
         * outside of the ThreadLocal class. The class is package private to
         * allow declaration of fields in class Thread.  To help deal with
         * very large and long-lived usages, the hash table entries use
         * WeakReferences for keys. However, since reference queues are not
         * used, stale entries are guaranteed to be removed only when
         * the table starts running out of space.
         */
        static class ThreadLocalMap {
    
            /**
             * The entries in this hash map extend WeakReference, using
             * its main ref field as the key (which is always a
             * ThreadLocal object).  Note that null keys (i.e. entry.get()
             * == null) mean that the key is no longer referenced, so the
             * entry can be expunged from table.  Such entries are referred to
             * as "stale entries" in the code that follows.
             */
            static class Entry extends WeakReference<ThreadLocal> {
                /** The value associated with this ThreadLocal. */
                Object value;
    
                Entry(ThreadLocal k, Object v) {
                    super(k);
                    value = v;
                }
            }
    
            /**
             * The initial capacity -- MUST be a power of two.
             */
            private static final int INITIAL_CAPACITY = 16;
    
            /**
             * The table, resized as necessary.
             * table.length MUST always be a power of two.
             */
            private Entry[] table;
    
            /**
             * The number of entries in the table.
             */
            private int size = 0;
    
            /**
             * The next size value at which to resize.
             */
            private int threshold; // Default to 0
    
            /**
             * Set the resize threshold to maintain at worst a 2/3 load factor.
             */
            private void setThreshold(int len) {
                threshold = len * 2 / 3;
            }
    
            /**
             * Increment i modulo len.
             */
            private static int nextIndex(int i, int len) {
                return ((i + 1 < len) ? i + 1 : 0);
            }
    
            /**
             * Decrement i modulo len.
             */
            private static int prevIndex(int i, int len) {
                return ((i - 1 >= 0) ? i - 1 : len - 1);
            }
    
            /**
             * Construct a new map initially containing (firstKey, firstValue).
             * ThreadLocalMaps are constructed lazily, so we only create
             * one when we have at least one entry to put in it.
             */
            ThreadLocalMap(ThreadLocal firstKey, Object firstValue) {
                table = new Entry[INITIAL_CAPACITY];
                int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
                table[i] = new Entry(firstKey, firstValue);
                size = 1;
                setThreshold(INITIAL_CAPACITY);
            }
    
            /**
             * Construct a new map including all Inheritable ThreadLocals
             * from given parent map. Called only by createInheritedMap.
             *
             * @param parentMap the map associated with parent thread.
             */
            private ThreadLocalMap(ThreadLocalMap parentMap) {
                Entry[] parentTable = parentMap.table;
                int len = parentTable.length;
                setThreshold(len);
                table = new Entry[len];
    
                for (int j = 0; j < len; j++) {
                    Entry e = parentTable[j];
                    if (e != null) {
                        ThreadLocal key = e.get();
                        if (key != null) {
                            Object value = key.childValue(e.value);
                            Entry c = new Entry(key, value);
                            int h = key.threadLocalHashCode & (len - 1);
                            while (table[h] != null)
                                h = nextIndex(h, len);
                            table[h] = c;
                            size++;
                        }
                    }
                }
            }
    
            /**
             * Get the entry associated with key.  This method
             * itself handles only the fast path: a direct hit of existing
             * key. It otherwise relays to getEntryAfterMiss.  This is
             * designed to maximize performance for direct hits, in part
             * by making this method readily inlinable.
             *
             * @param  key the thread local object
             * @return the entry associated with key, or null if no such
             */
            private Entry getEntry(ThreadLocal key) {
                int i = key.threadLocalHashCode & (table.length - 1);
                Entry e = table[i];
                if (e != null && e.get() == key)
                    return e;
                else
                    return getEntryAfterMiss(key, i, e);
            }
    
            /**
             * Version of getEntry method for use when key is not found in
             * its direct hash slot.
             *
             * @param  key the thread local object
             * @param  i the table index for key's hash code
             * @param  e the entry at table[i]
             * @return the entry associated with key, or null if no such
             */
            private Entry getEntryAfterMiss(ThreadLocal key, int i, Entry e) {
                Entry[] tab = table;
                int len = tab.length;
    
                while (e != null) {
                    ThreadLocal k = e.get();
                    if (k == key)
                        return e;
                    if (k == null)
                        expungeStaleEntry(i);
                    else
                        i = nextIndex(i, len);
                    e = tab[i];
                }
                return null;
            }
    
            /**
             * Set the value associated with key.
             *
             * @param key the thread local object
             * @param value the value to be set
             */
            private void set(ThreadLocal key, Object value) {
    
                // We don't use a fast path as with get() because it is at
                // least as common to use set() to create new entries as
                // it is to replace existing ones, in which case, a fast
                // path would fail more often than not.
    
                Entry[] tab = table;
                int len = tab.length;
                int i = key.threadLocalHashCode & (len-1);
    
                for (Entry e = tab[i];
             e != null;
             e = tab[i = nextIndex(i, len)]) {
                    ThreadLocal k = e.get();
    
                    if (k == key) {
                        e.value = value;
                        return;
                    }
    
                    if (k == null) {
                        replaceStaleEntry(key, value, i);
                        return;
                    }
                }
    
                tab[i] = new Entry(key, value);
                int sz = ++size;
                if (!cleanSomeSlots(i, sz) && sz >= threshold)
                    rehash();
            }
    
            /**
             * Remove the entry for key.
             */
            private void remove(ThreadLocal key) {
                Entry[] tab = table;
                int len = tab.length;
                int i = key.threadLocalHashCode & (len-1);
                for (Entry e = tab[i];
             e != null;
             e = tab[i = nextIndex(i, len)]) {
                    if (e.get() == key) {
                        e.clear();
                        expungeStaleEntry(i);
                        return;
                    }
                }
            }
    
            /**
             * Replace a stale entry encountered during a set operation
             * with an entry for the specified key.  The value passed in
             * the value parameter is stored in the entry, whether or not
             * an entry already exists for the specified key.
             *
             * As a side effect, this method expunges all stale entries in the
             * "run" containing the stale entry.  (A run is a sequence of entries
             * between two null slots.)
             *
             * @param  key the key
             * @param  value the value to be associated with key
             * @param  staleSlot index of the first stale entry encountered while
             *         searching for key.
             */
            private void replaceStaleEntry(ThreadLocal key, Object value,
                                           int staleSlot) {
                Entry[] tab = table;
                int len = tab.length;
                Entry e;
    
                // Back up to check for prior stale entry in current run.
                // We clean out whole runs at a time to avoid continual
                // incremental rehashing due to garbage collector freeing
                // up refs in bunches (i.e., whenever the collector runs).
                int slotToExpunge = staleSlot;
                for (int i = prevIndex(staleSlot, len);
             (e = tab[i]) != null;
                     i = prevIndex(i, len))
                    if (e.get() == null)
                        slotToExpunge = i;
    
                // Find either the key or trailing null slot of run, whichever
                // occurs first
                for (int i = nextIndex(staleSlot, len);
             (e = tab[i]) != null;
                     i = nextIndex(i, len)) {
                    ThreadLocal k = e.get();
    
                    // If we find key, then we need to swap it
                    // with the stale entry to maintain hash table order.
                    // The newly stale slot, or any other stale slot
                    // encountered above it, can then be sent to expungeStaleEntry
                    // to remove or rehash all of the other entries in run.
                    if (k == key) {
                        e.value = value;
    
                        tab[i] = tab[staleSlot];
                        tab[staleSlot] = e;
    
                        // Start expunge at preceding stale entry if it exists
                        if (slotToExpunge == staleSlot)
                            slotToExpunge = i;
                        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                        return;
                    }
    
                    // If we didn't find stale entry on backward scan, the
                    // first stale entry seen while scanning for key is the
                    // first still present in the run.
                    if (k == null && slotToExpunge == staleSlot)
                        slotToExpunge = i;
                }
    
                // If key not found, put new entry in stale slot
                tab[staleSlot].value = null;   
                tab[staleSlot] = new Entry(key, value);
    
                // If there are any other stale entries in run, expunge them
                if (slotToExpunge != staleSlot)
                    cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            }
    
            /**
             * Expunge a stale entry by rehashing any possibly colliding entries
             * lying between staleSlot and the next null slot.  This also expunges
             * any other stale entries encountered before the trailing null.  See
             * Knuth, Section 6.4
             *
             * @param staleSlot index of slot known to have null key
             * @return the index of the next null slot after staleSlot
             * (all between staleSlot and this slot will have been checked
             * for expunging).
             */
            private int expungeStaleEntry(int staleSlot) {
                Entry[] tab = table;
                int len = tab.length;
    
                // expunge entry at staleSlot
                tab[staleSlot].value = null;   
                tab[staleSlot] = null;
                size--;
    
                // Rehash until we encounter null
                Entry e;
                int i;
                for (i = nextIndex(staleSlot, len);
             (e = tab[i]) != null;
                     i = nextIndex(i, len)) {
                    ThreadLocal k = e.get();
                    if (k == null) {
                        e.value = null;
                        tab[i] = null;
                        size--;
                    } else {
                        int h = k.threadLocalHashCode & (len - 1);
                        if (h != i) {
                            tab[i] = null;
    
                            // Unlike Knuth 6.4 Algorithm R, we must scan until
                            // null because multiple entries could have been stale.
                            while (tab[h] != null)
                                h = nextIndex(h, len);
                            tab[h] = e;
                        }
                    }
                }
                return i;
            }
    
            /**
             * Heuristically scan some cells looking for stale entries.
             * This is invoked when either a new element is added, or
             * another stale one has been expunged. It performs a
             * logarithmic number of scans, as a balance between no
             * scanning (fast but retains garbage) and a number of scans
             * proportional to number of elements, that would find all
             * garbage but would cause some insertions to take O(n) time.
             *
             * @param i a position known NOT to hold a stale entry. The
             * scan starts at the element after i.
    
             *
             * @param n scan control: <tt>log2(n)</tt> cells are scanned,
             * unless a stale entry is found, in which case
             * <tt>log2(table.length)-1</tt> additional cells are scanned.
             * When called from insertions, this parameter is the number
             * of elements, but when from replaceStaleEntry, it is the
             * table length. (Note: all this could be changed to be either
             * more or less aggressive by weighting n instead of just
             * using straight log n. But this version is simple, fast, and
             * seems to work well.)
             *
             * @return true if any stale entries have been removed.
             */
            private boolean cleanSomeSlots(int i, int n) {
                boolean removed = false;
                Entry[] tab = table;
                int len = tab.length;
                do {
                    i = nextIndex(i, len);
                    Entry e = tab[i];
                    if (e != null && e.get() == null) {
                        n = len;
                        removed = true;
                        i = expungeStaleEntry(i);
                    }
                } while ( (n >>>= 1) != 0);
                return removed;
            }
    
            /**
             * Re-pack and/or re-size the table. First scan the entire
             * table removing stale entries. If this doesn't sufficiently
             * shrink the size of the table, double the table size.
             */
            private void rehash() {
                expungeStaleEntries();
    
                // Use lower threshold for doubling to avoid hysteresis
                if (size >= threshold - threshold / 4)
                    resize();
            }
    
            /**
             * Double the capacity of the table.
             */
            private void resize() {
                Entry[] oldTab = table;
                int oldLen = oldTab.length;
                int newLen = oldLen * 2;
                Entry[] newTab = new Entry[newLen];
                int count = 0;
    
                for (int j = 0; j < oldLen; ++j) {
                    Entry e = oldTab[j];
                    if (e != null) {
                        ThreadLocal k = e.get();
                        if (k == null) {
                            e.value = null; // Help the GC
                        } else {
                            int h = k.threadLocalHashCode & (newLen - 1);
                            while (newTab[h] != null)
                                h = nextIndex(h, newLen);
                            newTab[h] = e;
                            count++;
                        }
                    }
                }
    
                setThreshold(newLen);
                size = count;
                table = newTab;
            }
    
            /**
             * Expunge all stale entries in the table.
             */
            private void expungeStaleEntries() {
                Entry[] tab = table;
                int len = tab.length;
                for (int j = 0; j < len; j++) {
                    Entry e = tab[j];
                    if (e != null && e.get() == null)
                        expungeStaleEntry(j);
                }
            }
        }

    ThreadLocal和多线程并发没有什么关系。
    ThreadLocal模式是为了解决单线程内的跨类跨方法调用的
    ThreadLocal不是用来解决对象共享访问问题的,而主要是提供了保持对象的方法和避免参数传递的方便的对象访问方式。
    一般情况下,通过ThreadLocal.set() 到线程中的对象是该线程自己使用的对象,其他线程是不需要访问的,也访问不到的。各个线程中访问的是不同的对象。

    三、例子

    引用Tim Cull的博文“SimpleDateFormat: Performance Pig”介绍下ThreadLocal的简单使用,同时也对SimpleDateFormat的使用有个深入的了解。

    Tim Cull 写道:

       Tim Cull碰到一个SimpleDateFormat带来的严重的性能问题,该问题主要有SimpleDateFormat引发,创建一个 SimpleDateFormat实例的开销比较昂贵,解析字符串时间时频繁创建生命周期短暂的实例导致性能低下。即使将 SimpleDateFormat定义为静态类变量,貌似能解决这个问题,但是SimpleDateFormat是非线程安全的,同样存在问题,如果用 ‘synchronized’线程同步同样面临问题,同步导致性能下降(线程之间序列化的获取SimpleDateFormat实例)。

        Tim Cull使用Threadlocal解决了此问题,对于每个线程SimpleDateFormat不存在影响他们之间协作的状态,为每个线程创建一个SimpleDateFormat变量的拷贝或者叫做副本

        private static final String DATE_FORMAT = "yyyyMMddHH24mmssS";
    
        private static ThreadLocal<DateFormat> threadLocal = new
                ThreadLocal<DateFormat>() {
                    protected synchronized DateFormat
                            initialValue() {
                        return new SimpleDateFormat(DATE_FORMAT);
                    }
                };
    
        public static String getTimeStamp() {
            return threadLocal.get().format(new Date());
        }
    
        public static Date parse(String textDate) throws ParseException {
            return threadLocal.get().parse(textDate);
        }

    创建一个ThreadLocal类变量,这里创建时用了一个匿名类,覆盖了initialValue方法,主要作用是创建时初始化实例。也可以采用下面方式创建

        private static final String DATE_FORMAT = "yyyyMMddHH24mmssS";
        // 第一次调用get将返回null
        private static ThreadLocal<DateFormat> threadLocal = new ThreadLocal<DateFormat>();
    
        // 获取线程的变量副本,如果不覆盖initialValue,第一次get返回null,
        // 故需要初始化一个SimpleDateFormat,并set到threadLocal中
        public static DateFormat getDateFormat() {
            DateFormat df = threadLocal.get();
            if (df == null) {
                df = new SimpleDateFormat(DATE_FORMAT);
                threadLocal.set(df);
            }
            return df;
        }



    http://www.blogjava.net/lsbwahaha/archive/2011/10/29/362282.html

     
     
  • 相关阅读:
    latex插入图片
    装virtualenv和flask
    GitHub Pages写博客
    用模拟退火算法进行特征选择
    用遗传算法进行特征选择
    智能垃圾桶
    蚁群算法 与 A*算法 寻找最优路径 对比 (pygame)
    pygame
    pyinstaller打包python应用程序
    内网渗透之信息收集-linux
  • 原文地址:https://www.cnblogs.com/softidea/p/3960043.html
Copyright © 2011-2022 走看看