Java-ThreadLocal(看这一篇就够了)

分享一个大牛的人工智能教程。零基础!通俗易懂!风趣幽默!希望你也加入到人工智能的队伍中来!请点击http://www.captainbed.net

1、什么是ThreadLocal

ThreadLocal class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its get or set method) has its own, independently initialized copy of the variable. ThreadLocal instances are typically private static fields in classes that wish to associate state with a thread (e.g., a user ID or Transaction ID).

Each thread holds an implicit reference to its copy of a thread-local variable as long as the thread is alive and the ThreadLocal instance is accessible; after a thread goes away, all of its copies of thread-local instances are subject to garbage collection (unless other references to these copies exist).

简单理解:ThreadLocal提供了线程的局部变量,每个线程都可以通过set()get()来对这个局部变量进行操作,但不会和其他线程的局部变量进行冲突,实现了线程的数据隔离

2、ThreadLocal有什么用

2-1、管理Connection

最典型的应用是管理数据库的Connection:比如我们在用JDBC的时候,为了方便操作可以写一个简单数据库连接池。为什么要写数据库连接池,因为频繁创建和关闭Connection是一件非常耗费资源的操作,所以我们需要数据库连接池。

那么,数据库连接池的连接怎么管理呢?我们交由ThreadLocal来进行管理。为什么交给它来管理呢?因为ThreadLocal能够实现当前线程的操作都是使用的同一个Connection,保证了事务!

代码如下:

public class DBUtil {

    // 数据库连接池
    private static BasicDataSource source;

    // 为不同线程管理连接
    private static ThreadLocal<Connection> local;

    static {
        try {
            
            // 加载配置文件
            Properties properties = new Properties();

            // 获取读取流
            InputStream stream = DBUtil.class.getClassLoader().getResourceAsStream("连接池/config.properties");

            // 从配置文件中读取数据
            properties.load(stream);

            // 关闭流
            stream.close();

            // 初始化连接池
            source = new BasicDataSource();

            // 设置驱动
            source.setDriverClassName(properties.getProperty("driver"));

            // 设置url
            source.setUrl(properties.getProperty("url"));

            // 设置用户名
            source.setUsername(properties.getProperty("user"));

            // 设置密码
            source.setPassword(properties.getProperty("pwd"));

            // 设置初始连接数量
			source.setInitialSize(Integer.parseInt(properties.getProperty("initsize")));

            // 设置最大连接数量
			source.setMaxActive(Integer.parseInt(properties.getProperty("maxactive")));

            // 设置最长等待时间
			source.setMaxWait(Integer.parseInt(properties.getProperty("maxwait")));

            // 设置最小空闲数
			source.setMinIdle(Integer.parseInt(properties.getProperty("minidle")));

            // 初始化ThreadLocal
            local = new ThreadLocal<>();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static Connection getConnection() throws SQLException {

        // 获取Connection对象
        Connection connection = source.getConnection();

        // 把Connection放进ThreadLocal里面
        local.set(connection);

        // 返回Connection对象
        return connection;
    }

    // 关闭数据库连接
    public static void closeConnection() {

        // 从线程中拿到Connection对象
        Connection connection = local.get();

        try {
            if (connection != null) {

                // 恢复连接为自动提交
                connection.setAutoCommit(true);

                // 这里不是真的把连接关了,只是将该连接归还给连接池
                connection.close();

                // 既然连接已经归还给连接池了,ThreadLocal保存的Connction对象也已经没用了
                local.remove();
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }

}

还有,Spring也是采用Threadlocal的方式,来保证单个线程中的数据库操作使用的是同一个数据库连接。同时,采用这种方式可以使业务层使用事务时不需要感知并管理Connection对象,通过传播级别,巧妙地管理多个事务配置之间的切换,挂起和恢复。

Spring框架里面就是用的ThreadLocal来实现这种隔离,主要是在TransactionSynchronizationManager这个类里面,相关代码如下:

private static final Log logger = LogFactory.getLog(TransactionSynchronizationManager.class);

private static final ThreadLocal<Map<Object, Object>> resources = new NamedThreadLocal<>("Transactional resources");

private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations = new NamedThreadLocal<>("Transaction synchronizations");

private static final ThreadLocal<String> currentTransactionName = new NamedThreadLocal<>("Current transaction name");

// ...

2-2、避免参数传递

我们在实际项目中经常会遇到在一个线程内,横跨若干方法调用需要传递的对象,也就是上下文(Context),经常就是用户身份、任务信息等,就会存在过度传参的问题。

这时,若使用到类似责任链模式,给每个方法增加一个Context参数非常麻烦,而且有些时候,如果调用链有无法修改源码的第三方库,对象参数就传不进去了。这是,我们使用ThreadLocal做一下改造,只需要在调用前在ThreadLocal中设置参数,在其他地方get一下就好了。

其实很多场景的Cookie、Session等数据隔离都是通过ThreadLocal去实现的。

在Android中,Looper类就是利用了ThreadLocal的特性,保证每个线程只存在一个Looper对象。

3、ThreadLocal的实现原理

首先,我们来看一下ThreadLocal的set()方法,因为我们一般使用都是new完对象,就往里边set对象了。

    /**
     * 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);
    }

大家可以发现set的源码很简单,主要就是ThreadLocalMap我们需要关注一下。

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;
            }
        }

        // 这个类还有很长...
}

通过上面我们可以发现的是ThreadLocalMap是ThreadLocal的一个内部类。用Entry类来进行存储。

我们的值都是存储到这个Map上的,key是当前ThreadLocal对象

如果该Map不存在,则初始化一个:

   void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }

如果该Map存在,则从Thread中获取:

    /**
     * Get the map associated with a ThreadLocal. Overridden in
     * InheritableThreadLocal.
     *
     * @param  t the current thread
     * @return the map
     */
    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }

Thread类维护了ThreadLocalMap变量:

    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;

从上面又可以看出,ThreadLocalMap是在ThreadLocal中使用内部类来编写的,但对象的引用是在Thread中

于是我们可以总结出:Thread为每个线程维护了ThreadLocalMap这么一个Map,而ThreadLocalMap的key是ThreadLocal对象本身,value则是要存储的对象。

有了上面的基础,我们再来看get()方法就不难理解了:

    /**
     * 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) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }

我们把ThreadLocal的原理小结一下:

  1. 每个Thread维护着一个ThreadLocalMap的引用
  2. ThreadLocalMap是ThreadLocal的内部类,用Entry来进行存储
  3. 调用ThreadLocal的set()方法时,实际上就是往ThreadLocalMap设置值,key是ThreadLocal对象本身,值是传递进来的对象
  4. 调用ThreadLocal的get()方法时,实际上就是从ThreadLocalMap获取值,key是ThreadLocal对象本身
  5. ThreadLocal本身并不存储值,它只是作为一个key来让线程从ThreadLocalMap获取value

正因为这个原理,所以ThreadLocal能够实现“数据隔离”,获取当前线程的局部变量值,而不受其他线程影响。

4、ThreadLocal内存模型分析

4-1、私有变量存储在哪里

在代码中,我们使用ThreadLocal实例提供的set/get方法来存储/使用value,但ThreadLocal实例其实只是一个引用,真正存储值的是一个Map,其key是ThreadLocal实例本身,value是我们设置的值,分布在堆区。这个Map的类型是ThreadLocal.ThreadLocalMap(ThreadLocalMap是ThreadLocal的内部类),其key的类型是ThreadLocal,value是Object,类定义如下:

    static class ThreadLocalMap {
        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);
        }
        static class Entry extends WeakReference<ThreadLocal> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal k, Object v) {
                super(k);
                value = v;
            }
        }
    }

那么当我们重写initialValue或调用set/get的时候,内部的逻辑是怎样的呢?按照上面的说法,应该是将value存储到了ThreadLocalMap中,或者从已有的ThreadLocalMap中获取value。我们来通过代码分析一下。

ThreadLocal.set(T value)

set的逻辑比较简单,就是获取当前线程的ThreadLocalMap,然后往map里添加KV,K是this,也就是当前ThreadLocal实例,V是我们传入的value。

    /**
     * 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);
    }

其内部实现首先需要获取关联的Map,我们看下getMap和createMap的实现

    /**
     * Get the map associated with a ThreadLocal. Overridden in
     * InheritableThreadLocal.
     *
     * @param  t the current thread
     * @return the map
     */
    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }
    /**
     * Create the map associated with a ThreadLocal. Overridden in
     * InheritableThreadLocal.
     *
     * @param t the current thread
     * @param firstValue value for the initial entry of the map
     * @param map the map to store.
     */
    void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }

可以看到,getMap就是返回了当前Thread实例的map(t.threadLocals),createMap创建了Thread的map(t.threadLocals),也就是说对于一个Thread实例,ThreadLocalMap是其内部的一个属性,在需要的时候,可以通过ThreadLocal创建或者获取,然后存放相应的值。我们看下Thread类的关键代码

public class Thread implements Runnable {
    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;
    //省略了其他代码
}

可以看到,Thread中定义了属性threadLocals,但其初始化和使用的过程,都是通过ThreadLocal这个类来执行的。

ThreadLocal.get()

get是获取当前线程的对应的私有变量,是我们之前set或者通过initialValue指定的变量,其代码如下

    /**
     * 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;
    }

可以看到,其逻辑也比较简单清晰:

  • 获取当前线程的ThreadLocalMap实例
  • 如果不为空,以当前ThreadLocal实例为key获取value
  • 如果ThreadLocalMap为空或者根据当前ThreadLocal实例获取的value为空,则执行setInitialValue()

setInitialValue()内部如下:

  • 调用我们重写的initialValue得到一个value
  • 将value放入到当前线程对应的ThreadLocalMap中
  • 如果map为空,先实例化一个map,然后赋值KV

关键设计小结

代码分析到这里,其实对于ThreadLocal的内部主要设计以及其和Thread的关系就比较清楚了:

  • 每个线程,是一个Thread实例,其内部拥有一个名为threadLocals的实例成员,其类型是ThreadLocal.ThreadLocalMap
  • 通过实例化ThreadLocal实例,我们可以对当前运行的线程设置一些线程私有的变量,通过调用ThreadLocal的set和get方法存取
  • ThreadLocal本身并不是一个容器,我们存取的value实际上存储在ThreadLocalMap中,ThreadLocal只是作为TheadLocalMap的key
  • 每个线程实例都对应一个TheadLocalMap实例,我们可以在同一个线程里实例化很多个ThreadLocal来存储很多种类型的值,这些ThreadLocal实例分别作为key,对应各自的value
  • 当调用ThreadLocal的set/get进行赋值/取值操作时,首先获取当前线程的ThreadLocalMap实例,然后就像操作一个普通的map一样,进行put和get

当然,这个ThreadLocalMap并不是一个普通的Map(比如常用的HashMap),而是一个特殊的,key为弱引用的map,这个我们后面再详谈。

4-2、ThreadLocal内存模型

通过上面的分析,我们已经很清楚ThreadLocal的相关设计了,对数据存储的具体分布也会有个比较清晰的概念。我们可以通过下面的图对ThreadLocal的存储有个更加直接的印象。

TheadLocal内存模型

我们知道Thread运行时,线程的一些局部变量和引用使用的内存属于Stack(栈)区,而普通的对象是存储在Heap(堆)区。根据上图,基本分析如下:

  • 线程运行时,我们定义的TheadLocal对象被初始化,存储在Heap,同时线程运行的栈区保存了指向该实例的引用,也就是图中的ThreadLocalRef
  • 当ThreadLocal的set/get被调用时,虚拟机会根据当前线程的引用也就是CurrentThreadRef找到其对应在堆区的实例,然后查看其对应的TheadLocalMap实例是否被创建,如果没有,则创建并初始化
  • Map实例化之后,也就拿到了该ThreadLocalMap的句柄,然后将当前ThreadLocal对象作为key,进行存取操作
  • 图中的虚线,表示key对ThreadLocal实例的引用是个弱引用

4-3、插曲:强引用/弱引用

Java中的引用分为四种,按照引用强度不同,从强到弱依次为:强引用、软引用、弱引用和虚引用。如果不是专门做JVM研究,对其概念很难清晰的定义,我们大致可以理解为,引用的强度,代表了对内存占用的能力大小,具体体现在GC的时候,会不会被回收,什么时候被回收

ThreadLocal被用作TheadLocalMap的弱引用key,这种设计也是ThreadLocal被讨论内存泄露的热点问题,因此有必要了解一下什么是弱引用。

4-3-1、强引用

强引用虽然在开发过程中并不怎么提及,但是无处不在,例如我们在一个对象中通过如下代码实例化一个StringBuffer对象

StringBuffer buffer = new StringBuffer();

我们知道StringBuffer的实例通常是被创建在堆中的,而当前对象持有该StringBuffer对象的引用,以便后续的访问,这个引用,就是一个强引用。

对GC知识比较熟悉的可以知道,HotSpot JVM目前的垃圾回收算法一般默认是可达性算法,即在每一轮GC的时候,选定一些对象作为GC ROOT,然后以它们为根发散遍历,遍历完成之后,如果一个对象不被任何GC ROOT引用,那么它就是不可达对象,则在接下来的GC过程中很可能会被回收。

强引用最重要的就是它能够让引用变得强(Strong),这就决定了它和垃圾回收器的交互。具体来说,如果一个对象通过一串强引用链接可到达(Strongly reachable),它是不会被回收的。如果你不想让你正在使用的对象被回收,这就正是你所需要的。

4-3-2、软引用

软引用是用来描述一些还有用但是并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,会把这些对象列进回收范围之后进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。JDK1.2之后提供了SoftReference来实现软引用。

相对于强引用,软引用在内存充足时可能不会被回收,在内存不够时会被回收。

4-4-3、弱引用

弱引用也是用来描述非必须的对象的,但它的强度更弱,被弱引用关联的对象只能生存到下一次GC发生之前,也就是说下一次GC就会被回收。JDK1.2之后,提供了WeakReference来实现弱引用。

4-3-4、虚引用

虚引用也成为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间造成影响,也无法通过虚引用来取得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是在这个对象被GC时收到一个系统通知。JDK1.2之后提供了PhantomReference来实现虚引用。

4-4、可能的内存泄露分析

了解了ThreadLocal的内部模型以及弱引用,接下来可以分析一下是否有内存泄露的可能以及如何避免。

4-4-1、内存泄露分析

根据上面的内存模型图我们可以知道,由于ThreadLocalMap是以弱引用的方式引用着ThreadLocal,换句话说,就是ThreadLocal是被ThreadLocalMap以弱引用的方式关联着,因此如果ThreadLocal没有被ThreadLocalMap以外的对象引用,则在下一次GC的时候,ThreadLocal实例就会被回收,那么此时ThreadLocalMap里的一组KV的K就是null了,因此在没有额外操作的情况下,此处的V便不会被外部访问到,而且只要Thread实例一直存在,Thread实例就强引用着ThreadLocalMap,因此ThreadLocalMap就不会被回收,那么这里K为null的V就一直占用着内存

综上,发生内存泄露的条件是

  • ThreadLocal实例没有被外部强引用,比如在提交到线程池的task中实例化的ThreadLocal对象,当task结束时,ThreadLocal的强引用也就结束了
  • ThreadLocal实例被回收,但是在ThreadLocalMap中的V没有被任何清理机制有效清理
  • 当前Thread实例一直存在,则会一直强引用着ThreadLocalMap,也就是说ThreadLocalMap也不会被GC

也就是说,如果Thread实例还在,但是ThreadLocal实例却不在了,则ThreadLocal实例作为key所关联的value无法被外部访问,却还被强引用着,因此出现了内存泄露。

也就是说,我们回答了文章开头的第一个问题,ThreadLocal如果使用不当,是有可能引起内存泄露的,虽然触发的场景不算很容易。

这里要额外说明一下,这里说的内存泄露,是因为对其内存模型和设计不了解,且编码时不注意导致的内存管理失联,而不是有意为之的一直强引用或者频繁申请大内存。比如如果编码时不停的人为塞一些很大的对象,而且一直持有引用最终导致OOM,不能算作ThreadLocal导致的“内存泄露”,只是代码写的不当而已!

4-4-2、TheadLocal本身的优化

进一步分析ThreadLocalMap的代码,可以发现ThreadLocalMap内部也是做了一定优化的:

        /**
         * 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();
        }

可以看到,在set值的时候,有一定的几率会执行replaceStaleEntry(key, value, i)方法,其作用就是用当前的值替换掉以前的key为null的值,重复利用了空间。

5、ThreadLocal使用建议

通过前面的分析,我们基本弄清楚了ThreadLocal相关设计和内存模型,对于是否会发生内存泄露做了分析,下面总结以下几点建议:

  • 当需要存储线程私有变量的时候,可以考虑使用ThreadLocal来实现
  • 当需要实现线程安全的变量时,可以考虑使用ThreadLocal来实现
  • 当需要减少线程资源竞争的时候,可以考虑使用ThreadLocal来实现
  • 注意Thread实例和ThreadLocal实例的生存周期,因为他们直接关联着存储数据的生命周期。如果频繁的在线程中new ThreadLocal对象,在使用结束时,最好调用ThreadLocal.remove来释放其value的引用,避免在ThreadLocal被回收时value无法被访问却又占用着内存
已标记关键词 清除标记
相关推荐
程序员的必经之路! 【限时优惠】 现在下单,还享四重好礼: 1、教学课件免费下载 2、课程案例代码免费下载 3、专属VIP学员群免费答疑 4、下单还送800元编程大礼包 【超实用课程内容】  根据《2019-2020年中国开发者调查报告》显示,超83%的开发者都在使用MySQL数据库。使用量大同时,掌握MySQL早已是运维、DBA的必备技能,甚至部分IT开发岗位也要求对数据库使用和原理有深入的了解和掌握。 学习编程,你可能会犹豫选择 C++ 还是 Java;入门数据科学,你可能会纠结于选择 Python 还是 R;但无论如何, MySQL 都是 IT 从业人员不可或缺的技能!   套餐中一共包含2门MySQL数据库必学的核心课程(共98课时)   课程1:《MySQL数据库从入门到实战应用》   课程2:《高性能MySQL实战课》   【哪些人适合学习这门课程?】  1)平时只接触了语言基础,并未学习任何数据库知识的人;  2)对MySQL掌握程度薄弱的人,课程可以让你更好发挥MySQL最佳性能; 3)想修炼更好的MySQL内功,工作中遇到高并发场景可以游刃有余; 4)被面试官打破沙锅问到底的问题问到怀疑人生的应聘者。 【课程主要讲哪些内容?】 课程一:《MySQL数据库从入门到实战应用》 主要从基础篇,SQL语言篇、MySQL进阶篇三个角度展开讲解,帮助大家更加高效的管理MySQL数据库。 课程二:《高性能MySQL实战课》主要从高可用篇、MySQL8.0新特性篇,性能优化篇,面试篇四个角度展开讲解,帮助大家发挥MySQL的最佳性能的优化方法,掌握如何处理海量业务数据和高并发请求 【你能收获到什么?】  1.基础再提高,针对MySQL核心知识点学透,用对; 2.能力再提高,日常工作中的代码换新貌,不怕问题; 3.面试再加分,巴不得面试官打破沙锅问到底,竞争力MAX。 【课程如何观看?】  1、登录CSDN学院 APP 在我的课程中进行学习; 2、移动端:CSDN 学院APP(注意不是CSDN APP哦)  本课程为录播课,课程永久有效观看时长 【资料开放】 课件、课程案例代码完全开放给你,你可以根据所学知识,自行修改、优化。  下载方式:电脑登录课程观看页面,点击右侧课件,可进行课程资料的打包下载。
©️2020 CSDN 皮肤主题: 书香水墨 设计师:CSDN官方博客 返回首页