ThreadLocal到底有没有内存泄漏?从源码角度来剖析一波

栏目: IT技术 · 发布时间: 3年前

内容简介:ThreadLocal 也是一个使用频率较高的类,在框架中也经常见到,比如 Spring。有关 ThreadLocal 源码分析的文章不少,其中有个问题常被提及:ThreadLocal 是否存在内存泄漏?不少文章对此讲述比较模糊,经常让人看完脑子还是一头雾水,我也有此困惑。因此找时间跟小伙伴讨论了一番,总算对这个问题有了一定的理解,这里记录和分享一下,希望对有同样困惑的朋友们有所帮助。当然,若有理解不当的地方也欢迎指正。

1. 前言

ThreadLocal 也是一个使用频率较高的类,在框架中也经常见到,比如 Spring。

有关 ThreadLocal 源码分析的文章不少,其中有个问题常被提及:ThreadLocal 是否存在内存泄漏?

不少文章对此讲述比较模糊,经常让人看完脑子还是一头雾水,我也有此困惑。因此找时间跟小伙伴讨论了一番,总算对这个问题有了一定的理解,这里记录和分享一下,希望对有同样困惑的朋友们有所帮助。当然,若有理解不当的地方也欢迎指正。

啰嗦就到这里,下面先从 ThreadLocal 的一个应用场景开始分析吧。

2. 应用场景

ThreadLocal 的应用场景不少,这里举个简单的栗子:单点登录拦截。

也就是在处理一个 HTTP 请求之前,判断用户是否登录:

  • 若未登录,跳转到登录页面;

  • 若已登录,获取并保存用户的登录信息。

先定义一个 UserInfoHolder 类保存用户的登录信息,其内部用 ThreadLocal 存储,示例如下:

public class UserInfoHolder {
private static final ThreadLocal<Map<String, String>> USER_INFO_THREAD_LOCAL = new ThreadLocal<>();

public static void set(Map<String, String> map) {
USER_INFO_THREAD_LOCAL.set(map);
}

public static Map<String, String> get() {
return USER_INFO_THREAD_LOCAL.get();
}

public static void clear() {
USER_INFO_THREAD_LOCAL.remove();
}

// ...
}

通过 UserInfoHolder 可以存储和获取用户的登录信息,以便在业务中使用。

Spring 项目中,如果我们想在处理一个 HTTP 请求之前或之后做些额外的处理,通常定义一个类继承 HandlerInterceptorAdapter,然后重写它的一些方法。举例如下(仅供参考,省略了一些代码):

public class LoginInterceptor extends HandlerInterceptorAdapter {

// ...

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception
{

// ...

// 请求执行前,获取用户登录信息并保存
Map<String, String> userInfoMap = getUserInfo();

UserInfoHolder.set(userInfoMap);

return true;
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 请求执行后,清理掉用户信息
UserInfoHolder.clear();
}
}

在本例中,我们在处理一个请求之前获取用户的信息,在处理完请求之后,将用户信息清空。应该有朋友在框架或者自己的项目中见过类似代码。

下面我们深入 ThreadLocal 的内部,来分析这些方法做了些什么,跟内存泄漏又是怎么扯上关系的。

3. 源码剖析

3.1 类签名

先从头开始,也就是类签名:

public class ThreadLocal<T> {
}

可见它就是一个普通的类,并没有实现任何接口、也无父类继承。

3.2 构造器

ThreadLocal 只有一个无参构造器:

public ThreadLocal() {
}

此外,JDK 1.8 引入了一个使用 lambda 表达式初始化的静态方法 withInitial,如下:

public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) {
return new SuppliedThreadLocal<>(supplier);
}

该方法也可以初始化一个对象,和构造器也比较接近。

3.3 ThreadLocalMap

3.3.1 主要代码

ThreadLocalMap 是 ThreadLocal 的一个内部嵌套类。

由于 ThreadLocal 的主要操作实际都是通过 ThreadLocalMap 的方法实现的,因此先分析 ThreadLocalMap 的主要代码:

public class ThreadLocal<T> {
// 生成 ThreadLocal 的哈希码,用于计算在 Entry 数组中的位置
private final int threadLocalHashCode = nextHashCode();

private static final int HASH_INCREMENT = 0x61c88647;

private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}

// ...

static class ThreadLocalMap {

static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}

// 初始容量,必须是 2 的次幂
private static final int INITIAL_CAPACITY = 16;

// 存储数据的数组
private Entry[] table;

// table 中的 Entry 数量
private int size = 0;

// 扩容的阈值
private int threshold; // Default to 0

// 设置扩容阈值
private void setThreshold(int len) {
threshold = len * 2 / 3;
}

// 第一次添加元素使用的构造器
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);
}

// ...
}
}

ThreadLocalMap 的内部结构其实跟 HashMap 很类似,可以对比前面「 JDK源码分析-HashMap(1) 」对 HashMap 的分析。

二者都是「键-值对」构成的数组,对哈希冲突的处理方式不同,导致了它们在结构上产生了一些区别:

  1. HashMap 处理哈希冲突使用的「链表法」。也就是当产生冲突时拉出一个链表,而且 JDK 1.8 进一步引入了红黑树进行优化。

  2. ThreadLocalMap 则使用了「开放寻址法」中的「线性探测」。即,当某个位置出现冲突时,从当前位置往后查找,直到找到一个空闲位置。

其它部分大体是类似的。

3.3.2 注意事项

  • 弱引用

有个值得注意的地方是:ThreadLocalMap 的 Entry 继承了 WeakReference 类,也就是弱引用类型。

跟进 Entry 的父类,可以看到 ThreadLocal 最终赋值给了 WeakReference 的父类 Reference 的 referent 属性。即,可以认为 Entry 持有了两个对象的引用:ThreadLocal 类型的「弱引用」和 Object 类型的「强引用」,其中 ThreadLocal 为 key,Object 为 value。如图所示:

ThreadLocal到底有没有内存泄漏?从源码角度来剖析一波

ThreadLocal 在某些情况可能产生的「内存泄漏」就跟这个「弱引用」有关,后面再展开分析。

  • 寻址

Entry 的 key 是 ThreadLocal 类型的,它是如何在数组中散列的呢?

ThreadLocal 有个 threadLocalHashCode 变量,每次创建 ThreadLocal 对象时,这个变量都会增加一个固定的值 HASH_INCREMENT ,即 0x61c88647,这个数字似乎跟黄金分割、斐波那契数有关,但这不是重点,有兴趣的朋友可以去深入研究下,这里我们知道它的目的就行了。与 HashMap 的 hash 算法的目的近似,就是为了散列的更均匀。

下面分析 ThreadLocal 的主要方法实现。

3.4 主要方法

ThreadLocal 主要有三个方法:set、get 和 remove,下面分别介绍。

3.4.1 set 方法

  • set 方法:新增/更新 Entry

public void set(T value) {
// 获取当前线程
Thread t = Thread.currentThread();
// 从 Thread 中获取 ThreadLocalMap
ThreadLocalMap map = getMap(t);

if (map != null)
map.set(this, value);
else
createMap(t, value);
}

ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}

threadLocals 是 Thread 持有的一个 ThreadLocalMap 引用,默认是 null:

public class Thread implements Runnable {
// 其他代码...
ThreadLocal.ThreadLocalMap threadLocals = null;
}
  • 执行流程

若从当前 Thread 拿到的 ThreadLocalMap 为空,表示该属性并未初始化,执行 createMap 初始化:

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

若已存在,则调用 ThreadLocalMap 的 set 方法:

private void set(ThreadLocal<?> key, Object value) {    
Entry[] tab = table;
int len = tab.length;
// 1. 计算 key 在数组中的下标 i
int i = key.threadLocalHashCode & (len-1);

// 1.1 若数组下标为 i 的位置有元素
// 判断 i 位置的 Entry 是否为空;不为空则从 i 开始向后遍历数组
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();

// 索引为 i 的元素就是要查找的元素,用新值覆盖旧值,到此返回
if (k == key) {
e.value = value;
return;
}

// 索引为 i 的元素并非要查找的元素,且该位置中 Entry 的 Key 已经是 null
// Key 为 null 表明该 Entry 已经过期了,此时用新值来替换这个位置的过期值
if (k == null) {
// 替换过期的 Entry,
replaceStaleEntry(key, value, i);
return;
}
}

// 1.2 若数组下标为 i 的位置为空,将要存储的元素放到 i 的位置
tab[i] = new Entry(key, value);
int sz = ++size;
// 若未清理过期的 Entry,且数组的大小达到阈值,执行 rehash 操作
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}

先总结下 set 方法主要流程:

首先根据 key 的 threadLocalHashCode 计算它的数组下标:

  1. 如果数组下标的 Entry 不为空,表示该位置已经有元素。由于可能存在哈希冲突,因此这个位置的元素可能并不是要找的元素,所以遍历数组去比较

    1. 如果找到等于当前 key 的 Entry,则用新值替换旧值,返回。

    2. 如果遍历过程中,遇到 Entry 不为空、但是 Entry 的 key 为空的情况,则会做一些清理工作。

  2. 如果数组下标的 Entry 为空,直接将元素放到这里,必要时进行扩容。

  • replaceStaleEntry:替换过期的值,并清理一些过期的 Entry

private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot)
{
Entry[] tab = table;
int len = tab.length;
Entry e;

// 从 staleSlot 开始向前遍历,若遇到过期的槽(Entry 的 key 为空),更新 slotToExpunge
// 直到 Entry 为空停止遍历
int slotToExpunge = staleSlot;
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len))
if (e.get() == null)
slotToExpunge = i;

// 从 staleSlot 开始向后遍历,若遇到与当前 key 相等的 Entry,更新旧值,并将二者换位置
// 目的是把它放到「应该」在的位置
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();

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 (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}

// If key not found, put new entry in stale slot
// 若未找到 key,说明 Entry 此前并不存在,新增
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);
}

replaceStaleEntry 的主要执行流程如下:

  1. 从 staleSlot 向前遍历数组,直到 Entry 为空时停止遍历。这一步的主要目的是查找 staleSlot 前面过期的 Entry 的数组下标 slotToExpunge。

  2. 从 staleSlot 向后遍历数组

    1. 若 Entry 的 key 与给定的 key 相等,将该 Entry 与 staleSlot 下标的 Entry 互换位置。目的是为了让新增的 Entry 放到它「应该」在的位置。

    2. 若找不到相等的 key,说明该 key 对应的 Entry 不在数组中,将新值放到 staleSlot 位置。该操作其实就是处理哈希冲突的「线性探测」方法:当某个位置已被占用,向后探测下一个位置。

  3. 若 staleSlot 前面存在过期的 Entry,则执行清理操作。

PS: 所谓 Entry「应该」在的位置,就是根据 key 的 threadLocalHashCode 与数组长度取余计算出来的位置,即 k.threadLocalHashCode & (len - 1) ,或者哈希冲突之后的位置,这里只是为了方便描述。

  • expungeStaleEntry:清理过期的 Entry

// staleSlot 表示过期的槽位(即 Entry 数组的下标)
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;

// 1. 将给定位置的 Entry 置为 null
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)) {
// 获取 Entry 的 key
ThreadLocal<?> k = e.get();
if (k == null) {
// 若 key 为 null,表示 Entry 过期,将 Entry 置空
e.value = null;
tab[i] = null;
size--;
} else {
// key 不为空,表示 Entry 未过期
// 计算 key 的位置,若 Entry 不在它「应该」在的位置,把它移到「应该」在的位置
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;
}

该方法主要做了哪些工作呢?

  1. 清空给定位置的 Entry

  2. 从给定位置的下一个开始向后遍历数组

    1. 若遇到 Entry 为 null,结束遍历

    2. 若遇到 key 为空的 Entry(即过期的),就将该 Entry 置空

    3. 若遇到 key 不为空的 Entry,而且经过计算,该 Entry 并不在它「应该」在的位置,则将其移动到它「应该」在的位置

  3. 返回 staleSlot 后面的、Entry 为 null 的索引下标

  • cleanSomeSlots:清理一些槽(Slot)

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];
// Entry 不为空、key 为空,即 Entry 过期
if (e != null && e.get() == null) {
n = len;
removed = true;
// 清理 i 后面连续过期的 Entry,直到 Entry 为 null,返回该 Entry 的下标
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0);
return removed;
}

该方法做了什么呢?从给定位置的下一个开始扫描数组,若遇到 key 为空的 Entry(过期的),则清理该位置及其后面过期的槽。

值得注意的是,该方法循环执行的次数为 log(n)。由于该方法是在 set 方法内部被调用的,也就是新增/更新时:

  1. 如果不扫描和清理,set 方法执行速度很快,但是会存在一些垃圾(过期的 Entry);

  2. 如果每次都扫描清理,不会存在垃圾,但是插入性能会降低到 O(n)。

因此,这个次数其实就一种平衡策略:Entry 数组较小时,就少清理几次;数组较大时,就多清理几次。

  • rehash:调整 Entry 数组

private void rehash() {
// 清理数组中过期的 Entry
expungeStaleEntries();
// Use lower threshold for doubling to avoid hysteresis
if (size >= threshold - threshold / 4)
resize();
}

// 从头开始清理整个 Entry 数组
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);
}
}

该方法主要作用:

  1. 清理数组中过期的 Entry

  2. 若清理后 Entry 的数量大于等于 threshold 的 3/4,则执行 resize 方法进行扩容

  • resize 方法:Entry 数组扩容

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

// 遍历旧的 Entry 数组,将数组中的值移到新数组中
for (int j = 0; j < oldLen; ++j) {
Entry e = oldTab[j];
if (e != null) {
ThreadLocal<?> k = e.get();
// 若 Entry 的 key 已过期,则将 Entry 清理掉
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;
}

该方法的作用是 Entry 数组扩容,主要流程:

  1. 创建一个新数组,长度为原数组的 2 倍;

  2. 从下标 0 开始遍历旧数组的所有元素

    1. 若元素已过期(key 为空),则将 value 也置空

    2. 将未过期的元素移到新数组

3.4.2 get 方法

分析完了 set 方法,再看 get 方法就相对容易了不少。

  • get 方法:获取 ThreadLocal 对应的 Entry

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

get 方法首先获取当前线程的 ThreadLocalMap 并判断:

  1. 若 Map 已存在,从 Map 中取值

  2. 若 Map 不存在,或者 Map 中获取的值为空,执行 setInitialValue 方法

  • setInitialValue 方法:获取/设置初始值

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

protected T initialValue() {
return null;
}

先取初始值,这个初始值默认为空(该方法是 protected,可以由子类初始化)。

  1. 若 Thread 的 ThreadLocalMap 已初始化,则将初始值存入 Map

  2. 否则,创建 ThreadLocalMap

  3. 返回初始值

除了初始值,其他逻辑跟 set 方法是一样的,这里不再赘述。

PS: 可以看到初始值是惰性初始化的。

  • getEntry:从 Entry 数组中获取给定 key 对应的 Entry

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

// key 未命中
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; // 是要找的 key,返回
if (k == null)
expungeStaleEntry(i); // Entry 已过期,清理 Entry
else
i = nextIndex(i, len); // 向后遍历
e = tab[i];
}
return null;
}

3.4.3 remove 方法

  • remove 方法:移除 ThreadLocal 对应的 Entry

public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}

这里调用了 ThreadLocalMap 的 remove 方法:

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

其中 e.clear 调用的是 Entry 的父类 Reference 的 clear 方法:

public void clear() {
this.referent = null;
}

其实就是将 Entry 的 key 置空。

remove 方法的主要执行流程如下:

  1. 获取当前线程的 ThreadLocalMap

  2. 以当前 ThreadLocal 做为 key,从 Map 中查找相应的 Entry,将 Entry 的 key 置空

  3. 将该 ThreadLocal 对应的 Entry 置空,并向后遍历清理 Entry 数组,也就是 expungeStaleEntry 方法的操作,前面已经分析过了,这里不再赘述。

3.4.4 主要方法小结

ThreadLocal 的主要方法 set、get 和 remove 前面已经分析过,这里简单做个小结。

set 方法

  • 以当前 ThreadLocal 为 key、新增的 Object 为 value 组成一个 Entry,放入 ThreadLocalMap,也就是 Entry 数组中。

  • 计算 Entry 的位置后

    • 若该槽为空,直接放到这里;并清理一些过期的 Entry,必要时进行扩容。

    • 当遇到散列冲突时,线性探测向后查找数组中为空的、或者已经过期的槽,用新值替换。

get 方法

  • 以当前 ThreadLocal 为 key,从 Entry 数组中查找对应 Entry 的 value

    • 若 ThreadLocalMap 未初始化,则用给定初始值将其初始化

    • 若 ThreadLocalMap 已初始化,从 Entry 数组查找 key

remove 方法:以当前 ThreadLocal 为 key,从 Entry 数组清理掉对应的 Entry,并且再清理该位置后面的、过期的 Entry

方法虽少,但是稍微有点绕,除了做本身的功能,都执行了一些额外的清理操作。

分析了这几个方法的源码之后,下面就来研究一下内存泄漏的问题。

4. 内存泄漏分析

首先说明一点,ThreadLocal 通常作为成员变量或静态变量来使用(也就是共享的),比如前面应用场景中的例子。因为局部变量已经在同一条线程内部了,没必要使用 ThreadLocal。

为便于理解,这里先给出了 Thread、ThreadLocal、ThreadLocalMap、Entry 这几个类在 JVM 的内存示意图:

ThreadLocal到底有没有内存泄漏?从源码角度来剖析一波

简单说明:

  • 当一个线程运行时,栈中存在当前 Thread 的栈帧,它持有 ThreadLocalMap 的强引用。

  • ThreadLocal 所在的类持有一个 ThreadLocal 的强引用;同时,ThreadLocalMap 中的 Entry 持有一个 ThreadLocal 的弱引用。

4.1 场景一

若方法执行完毕、线程正常消亡,则 Thread 的 ThreadLocalMap 引用将断开,如图:

ThreadLocal到底有没有内存泄漏?从源码角度来剖析一波

以后 GC 发生时,弱引用也会断开,整个 ThreadLocalMap 都会被回收掉,不存在内存泄漏。

4.2 场景二

如果是线程池中的线程呢?也就是线程一直存活。经过 GC 后 Entry 持有的 ThreadLocal 引用断开,Entry 的 key 为空,value 不为空,如图所示:

ThreadLocal到底有没有内存泄漏?从源码角度来剖析一波

此时,如果没有任何 remove 或者 get 等清理 Entry 数组的动作,那么该 Entry 的 value 持有的 Object 就不会被回收掉。这样就产生了内存泄漏。

这种情况其实也很容易避免,使用完执行 remove 方法就行了。

5. 小结

本文分析了 ThreadLocal 的主要方法实现,并分析了它可能存在内存泄漏的场景。

  1. ThreadLocal 主要用于当前线程从共享变量中保存一份「副本」,常用的一个场景就是单点登录保存用户的登录信息。

  2. ThreadLocal 将数据存储在 ThreadLocalMap 中,ThreadLocalMap 是由 Entry 构成的数组,结构有点类似 HashMap。

  3. ThreadLocal 使用不当可能会造成内存泄漏。避免内存泄漏的方法是在方法调用结束前执行 ThreadLocal 的 remove 方法。

本文内容就到这里,若有不正之处欢迎指正,觉得有所收获欢迎三连支持~


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

The Four

The Four

Scott Galloway / Portfolio / 2017-10-3 / USD 28.00

NEW YORK TIMES BESTSELLER USA TODAY BESTSELLER Amazon, Apple, Facebook, and Google are the four most influential companies on the planet. Just about everyone thinks they know how they got there.......一起来看看 《The Four》 这本书的介绍吧!

JS 压缩/解压工具
JS 压缩/解压工具

在线压缩/解压 JS 代码

SHA 加密
SHA 加密

SHA 加密工具

RGB CMYK 转换工具
RGB CMYK 转换工具

RGB CMYK 互转工具