线程本地存储 · 语雀 (yuque.com)

线程本地存储提供了线程内存储变量的能力,这些变量是线程私有的。

线程本地存储一般用在跨类、跨方法的传递一些值。

线程本地存储也是解决特定场景下线程安全问题的思路之一(每个线程都访问本线程自己的变量)。

Java 语言提供了线程本地存储,ThreadLocal 类。

ThreadLocal 的使用及注意事项

public class TestClass {
public static ThreadLocal<Integer> threadLocal = new ThreadLocal<>(); public static void main(String[] args) {
// 设置值
threadLocal.set(1);
test();
} private static void test() {
// 获取值,返回 1
threadLocal.get();
// 防止内存泄漏
threadLocal.remove();
}
}

static 修饰的变量是在类在加载时就分配地址了,在类卸载才会被回收,因此使用 static 的 ThreadLocal,延长了 ThreadLocal 的生命周期,可能会导致内存泄漏。

分配使用了 ThreadLocal,又不调用 get()、set()、remove() 方法,并且当前线程迟迟不结束的话,那么就会导致内存泄漏。

ThreadLocal 的 set() 过程

每一个 Thread 实例对象中,都会有一个 ThreadLocalMap 实例对象;

ThreadLocalMap 是一个 Map 类型,底层数据结构是 Entry 数组;

一个 Entry 对象中又包含一个 key 和 一个 value

  • key 是 ThreadLocal 实例对象的弱引用
  • value 就是通过 ThreadLocal#set() 方法实际存储的值
static class Entry extends WeakReference<ThreadLocal<?>> {
/**
* The value associated with this ThreadLocal.
*/
Object value; Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}

下面我们通过源码分析 ThreadLocal#set() 的过程。

  • 获取当前线程
  • 获取当前线程的 ThreadLocalMap
  • 将存储的值设置到 ThreadLocalMap
public void set(T value) {
// 获取当前线程
Thread t = Thread.currentThread();
// 获取当前线程的 ThreadLocalMap
ThreadLocal.ThreadLocalMap map = getMap(t);
if (map != null) {
// 将存储的值设置到 ThreadLocalMap
map.set(this, value);
} else {
// 首次设置存储的值,需要创建 ThreadLocalMap
createMap(t, value);
}
}

ThreadLocalMap 的内存泄露

介绍内存泄漏

内存泄漏(Memory leak)

本质上,内存泄漏可以定义为:当进程不再需要某些内存的时候,这些不再被需要的内存依然没有被进程回收。

造成内存泄漏的原因:不再需要(没有作用)的实例对象依然存在着强引用关系,无法被垃圾收集器回收

内存泄露的原因分析

ThreadLocalMap 是一个 Map 类型,底层数据结构是 Entry 数组;

一个 Entry 对象的 key 是 ThreadLocal 实例对象的弱引用。

一个对象如果只剩下弱引用,则该对象在垃圾收集时就会被回收

ThreadLocalMap 使用 ThreadLocal 实例对象的弱引用作为 key 时,如果一个 ThreadLocal 实例对象没有强引用引用它,比如手动将 ThreadLocal A 这个对象赋值为 null,那么系统垃圾收集时,这个 ThreadLocal A 势必会被回收,这样一来 ThreadLocalMap 中就出现了 key 为 null 的 Entry,Java 程序没有办法访问这些 key 为 null 的 Entry,故没有办法删除 Entry 对 value 的强引用,则这个 value 无法被回收,直到线程的生命周期结束。

  • 如果当前线程迟迟不结束的话(比如使用了线程池,或者当前线程还在执行其他耗时的任务)那么这些 key 为 null 的 Entry 的 value 就会一直存在一条强引用链,导致 value 无法被回收。
  • 只有当前线程结束以后,ThreadRef 就不存在于栈中了,强引用断开,Thread 对象、ThreadLocalMap 对象、Entry 数组、Entry 对象、value 依次回收。

造成内存泄漏的原因是:由于 ThreadLocalMap 的生命周期跟 Thread 一样长,当 Thread 的生命周期过长时,导致 value 无法回收,而不是因为弱引用。

  • Entry 对象的 key 是 ThreadLocal 实例对象的弱引用,造成 value 无法被回收。实际是 ThreadLocalMap 的设计中,已经考虑到了这种情况,也加上了一些防护措施,我们在下面内存泄漏的解决办法中介绍。
  • 如果 Entry 对象的 key 是 ThreadLocal 实例对象的强引用的话,那么会造成 key 和 value 都无法被回收。

强引用链如下图红线所示:

强引用链的表述如下:

ThreadRef 引用 Thread,Thread 引用 ThreadLocalMap,ThreadLocalMap 引用 Entry,Entry 引用 value

内存泄露的解决办法

Entry 对象的 key 是 ThreadLocal 实例对象的弱引用,造成 value 无法被回收。

实际是 ThreadLocalMap 的设计中,已经考虑到了这种情况,也加上了一些防护措施。

在调用 ThreadLocal 的 get()、set() 方法操作数据,从指定位置开始遍历 Entry 时,会找到 Entry 不为 null,但 key 为 null 的 Entry,并删除 key 为 null 的 Entry 的 value 和对应的 Entry。


但是,如果 ThreadLocal 实例对象的强引用被删除后,线程长时间存活,又没有再对该线程的 ThreadLocalMap 实例对象进行操作,也就是没有再调用 get()、set() 方法,那么依然会存在内存泄漏。

所以,避免内存泄漏最好的做法是:主动调用 ThreadLocal 对象的 remove() 方法,将设置的线程本地变量的值删除。

/**
* Remove the entry for key.
*/
private void remove(ThreadLocal<?> key) {
ThreadLocal.ThreadLocalMap.Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (ThreadLocal.ThreadLocalMap.Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}

get()、set()、remove() 实际都会调用 ThreadLocalMap#expungeStaleEntry() 方法来进行删除 Entry,下面我们来看一下代码实现。

// 入参 staleSlot 是当前被删除对象在 Entry 数组中的位置
private int expungeStaleEntry(int staleSlot) {
ThreadLocal.ThreadLocalMap.Entry[] tab = table;
int len = tab.length; // 删除 staleSlot 位置的 value,key 已经在进入该方法前删除了 / 已经被回收
// expunge entry at staleSlot
tab[staleSlot].value = null;
// 将 Entry 对象赋值为 null,断开 Entry 实例对象的强引用
tab[staleSlot] = null;
// Entry 数组大小 - 1
size--; // Rehash until we encounter null
ThreadLocal.ThreadLocalMap.Entry e;
int i;
// for 循环的作用是从当前位置开始向后循环处理 Entry 中的 ThreadLocal 对象
// 将从指定位置开始,遇到 null 之前的所有 ThreadLocal 对象 rehash
for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
// 获取 ThreadLocal 的虚引用引用的实例对象
ThreadLocal<?> k = e.get();
if (k == null) {
// 虚引用引用的实例对象为 null,说明 ThreadLocal 已经被回收了
// 则删除 value 和 Entry,让虚拟机能够回收
e.value = null;
tab[i] = null;
size--;
} else {
// rehash
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;
// 从当前 h 的位置向后找,找到一个 null 的位置将 e 填入
// 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;
}

ThreadLocalMap 的哈希冲突

ThreadLocalMap 里处理 hash 冲突的机制不是像 HashMap 一样使用链表(拉链法)。

它采用的是另一种经典的处理方式,沿着冲突的索引向后查找空闲的位置(开放寻址法中的线性探测法)。

下面我们通过 ThreadLocal 的 set()、get() 方法源码,分析 ThreadLocalMap 的哈希冲突解决方案。

// set() 的关键方法,被 set(Object value) 调用
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. ThreadLocal.ThreadLocalMap.Entry[] tab = table;
int len = tab.length;
// 计算 key 在数组中的下标,其实就是 ThreadLocal 的 hashCode 和 数组大小-1 取余
int i = key.threadLocalHashCode & (len - 1); // 整体策略:查看 i 索引位置有没有值,有值的话,索引位置 + 1,直到找到没有值的位置
// 这种解决 hash 冲突的策略,也导致了其在 get 时查找策略有所不同,体现在 getEntryAfterMis
// nextIndex() 就是让在不超过数组长度的基础上,把数组的索引位置 + 1
for (ThreadLocal.ThreadLocalMap.Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get(); // 找到内存地址一样的 ThreadLocal,直接替换
// 即,修改线程本地变量
if (k == key) {
e.value = value;
return;
} // 当前 key 是 null,说明 ThreadLocal 被清理了,直接替换掉并返回
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
} // 当前 i 位置是无值的,可以被当前 thradLocal 使用
tab[i] = new ThreadLocal.ThreadLocalMap.Entry(key, value);
int sz = ++size;
// 当数组大小大于等于扩容阈值(数组大小的三分之二)时,进行扩容
if (!cleanSomeSlots(i, sz) && sz >= threshold) {
rehash();
}
}

上面源码我们注意几点:

  1. 是通过递增的 AtomicInteger 作为 ThreadLocal 的 hashCode 的;
  2. 计算数组索引位置的公式是:hashCode 取模 数组大小-1,由于 hashCode 不断自增,所以不同的 hashCode 大概率上会计算到同一个数组的索引位置(但这个不用担心,在实际项目中,ThreadLocal 都很少,基本上不会冲突);
  3. 通过 hashCode 计算的索引位置 i 处如果已经有值了,会从 i 开始,通过 +1 不断的往后寻找,直到找到索引位置为空的地方,把当前 ThreadLocal 作为 key 放进去。

// get 的关键方法,被 get() 方法调用

// 得到当前 thradLocal 对应的值,值的类型是由 thradLocal 的泛型决定的
// 首先尝试根据 hashcode 取模 数组大小-1 = 索引位置 i 寻找,找不到的话,自旋把 i+1,直到找到
private ThreadLocal.ThreadLocalMap.Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
ThreadLocal.ThreadLocalMap.Entry e = table[i];
// e 不为空,并且 e 的 ThreadLocal 的内存地址和 key 相同,直接返回,否则就是没有找到,继续寻找
if (e != null && e.get() == key) {
return e;
} else {
// 这个取数据的逻辑,是因为 set 时数组索引位置冲突造成的
return getEntryAfterMiss(key, i, e);
}
} // 自旋 i+1,直到找到为止
private ThreadLocal.ThreadLocalMap.Entry getEntryAfterMiss(ThreadLocal<?> key, int i, ThreadLocal.ThreadLocalMap.Entry e) {
ThreadLocal.ThreadLocalMap.Entry[] tab = table;
int len = tab.length; while (e != null) {
ThreadLocal<?> k = e.get();
// 内存地址一样,表示找到了
if (k == key) {
return e;
}
// 删除不再使用的 Entry,避免内存泄漏
if (k == null) {
expungeStaleEntry(i);
} else {
// 继续使索引位置 + 1
i = nextIndex(i, len);
}
e = tab[i];
}
return null;
}

ThreadLocalMap 的扩容策略

// set() 的部分源码
if (!cleanSomeSlots(i, sz) && sz >= threshold){
rehash();
} // 称为启发式清理,从指定下标开始遍历
private boolean cleanSomeSlots(int i, int n) {
boolean removed = false;
ThreadLocal.ThreadLocalMap.Entry[] tab = table;
int len = tab.length;
do {
i = nextIndex(i, len);
ThreadLocal.ThreadLocalMap.Entry e = tab[i];
if (e != null && e.get() == null) {
n = len;
removed = true;
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0);
return removed;
} private void rehash() {
// 探测式清理,从数组的下标为 0 处开始遍历,清理所有无用的 Entry
expungeStaleEntries(); // 扩容使用较低的阈值,以避免迟滞
// Use lower threshold for doubling to avoid hysteresis
if (size >= threshold - threshold / 4)
resize();
}

由上面源码我们可以看出,ThreadLocalMap 扩容的时机是,ThreadLocalMap 中的 ThreadLocal 的个数超过阈值,并且 cleanSomeSlots() 返回 false(启发式清理),然后尝试清理所有 key 为 null 的 Entry,清理完之后 ThreadLocal 的个数仍然大于阈值的四分之三,ThreadLocalMap 就要开始扩容了, 我们一起来看下扩容的逻辑:

// 扩容
private void resize() {
// 拿出旧的数组
ThreadLocal.ThreadLocalMap.Entry[] oldTab = table;
int oldLen = oldTab.length;
// 新数组的大小为老数组的两倍
int newLen = oldLen * 2;
// 初始化新数组
ThreadLocal.ThreadLocalMap.Entry[] newTab = new ThreadLocal.ThreadLocalMap.Entry[newLen];
int count = 0;
// 老数组的值拷贝到新数组上
for (int j = 0; j < oldLen; ++j) {
ThreadLocal.ThreadLocalMap.Entry e = oldTab[j];
if (e != null) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null; // Help the GC
} else {
// 计算 ThreadLocal 在新数组中的位置
int h = k.threadLocalHashCode & (newLen - 1);
// 如果索引 h 的位置值不为空,往后+1,直到找到值为空的索引位置
while (newTab[h] != null)
h = nextIndex(h, newLen);
// 给新数组赋值
newTab[h] = e;
count++;
}
}
}
// 给新数组初始化下次扩容阈值,为数组长度的三分之二
setThreshold(newLen);
size = count;
table = newTab;
}

源码注解也比较清晰,我们注意两点:

  1. 扩容后数组大小是原来数组的两倍,下一次的扩容阈值为数组长度的三分之二;
  2. 扩容时是没有线程安全问题的,因为 ThreadLocalMap 是线程的一个属性,一个线程同一时刻只能对 ThreadLocalMap 进行操作,因为同一个线程执行业务逻辑必然是串行的,那么操作 ThreadLocalMap 必然也是串行的。

ThreadLocalMap 扩容策略的语言描述:

在 ThreadLocalMap.set() 方法的最后,如果执行完启发式清理工作后,未清理到任何 Entry,且当前数组中 Entry 的数量已经达到了扩容阈值(数组长度的三分之二),就开始执行 rehash() 逻辑。

rehash() 首先是会进行探测式清理工作,从数组的起始位置开始遍历,查找 key 为 null 的 Entry 并清理。清理完成之后如果 ThreadLocal 的个数仍然大于等于扩容阈值的四分之三,那么就进行扩容操作,扩容为原来数组长度的两倍,并且设置下一次的扩容阈值为新数组长度的三分之二。

InheritableThreadLocal 与继承性

通过 ThreadLocal 创建的线程变量,其子线程是无法继承的。

也就是说你在线程中通过 ThreadLocal 创建了线程变量 V,而后该线程创建了子线程,你在子线程中是无法通过 ThreadLocal 来访问父线程的线程变量 V 的。

public class TestClass {
public static ThreadLocal<Integer> threadLocal = new ThreadLocal<>(); public static void main(String[] args) {
threadLocal.set(1);
// 返回 1
threadLocal.get(); new Thread(new Runnable() {
@Override
public void run() {
// 返回 null
threadLocal.get();
}
}).start();
}
}

如果你需要子线程继承父线程的线程变量,那该怎么办呢?

JDK 的 InheritableThreadLocal 类可以完成父线程到子线程的值传递。

InheritableThreadLocal 是 ThreadLocal 子类,所以用法和 ThreadLocal 相同。

使用时,改为 ThreadLocal<Integer> threadLocal = new InheritableThreadLocal<>(); 即可。

InheritableThreadLocal 在创建子线程的时候(初始化线程时),在 Thread#init() 方法中拷贝父线程中本地变量的值到子线程的本地变量中,子线程就拥有了和父线程一样的本地变量。

下面是 Thread#init() 中,和 ThreadLocal 相关的代码,我们一起来看下这个功能是怎么实现的

public class Thread implements Runnable {
// 如果是使用 ThreadLocal 进行 set(),则使用该变量保存
ThreadLocal.ThreadLocalMap threadLocals = null;
// 如果是使用 InheritableThreadLocal 进行 set(),则使用该变量保存
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null; private void init(ThreadGroup g, Runnable target, String name, long stackSize, AccessControlContext acc) {
// ...
Thread parent = currentThread();
// ...
if (parent.inheritableThreadLocals != null) {
// 根据 parent.inheritableThreadLocals 重新 new 一个 ThreadLocalMap 对象
this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
}
// ...
}
}

不过,完全不建议你在线程池中使用 InheritableThreadLocal,不仅仅是因为它具有 ThreadLocal 相同的缺点:可能导致内存泄露,更重要的原因是:线程池中线程的创建是动态的,很容易导致继承关系错乱,如果你的业务逻辑依赖 InheritableThreadLocal,那么很可能导致业务逻辑计算错误,而这个错误往往比内存泄露更要命。

同时,如果父线程的本地变量是引用数据类型的话,父子线程共享相同的数据,存在线程安全问题,甚至导致业务逻辑计算错误。要想做到父子线程的本地变量互不影响,则需要继承 InheritableThreadLocal 并重写 childValue() 方法实现对象的深拷贝 。

并且对于使用线程池等会池化复用线程的执行组件的情况,线程由线程池创建好,并且线程是池化起来反复使用的;这时父子线程关系的 ThreadLocal 值传递已经没有意义,应用需要的实际上是把任务提交给线程池时的ThreadLocal 值传递到任务执行时。阿里开源的 TransmittableThreadLocal 类继承并加强 InheritableThreadLocal 类,解决上述的问题。

TransmittableThreadLocal

TransmittableThreadLocal 的 GitHub:https://github.com/alibaba/transmittable-thread-local

TransmittableThreadLocal 的 API 文档:https://alibaba.github.io/transmittable-thread-local

TransmittableThreadLocal 是阿里开源的一个增强 InheritableThreadLocal 的库。

TransmittableThreadLocal 的功能:在使用线程池等会池化复用线程的执行组件情况下,提供 ThreadLocal 值的传递功能,解决异步执行时上下文传递的问题。

TTL 的使用及注意事项

TTL 的 User Guide:https://github.com/alibaba/transmittable-thread-local#-user-guide

TransmittableThreadLocal 有三种使用方式(具体使用见 GitHub 的 README):

  • 修饰 Runnable 或 Callable
  • 修饰线程池
  • 使用 Java Agent 来修饰 JDK 线程池实现类

注意事项:

使用 TtlRunnable 和 TtlCallable 来修饰传入线程池的 Runnable 和 Callable 时,即使是同一个 Runnable 任务多次提交到线程池时,每次提交时都需要通过修饰操作(即TtlRunnable.get(task))以抓取这次提交时的 TransmittableThreadLocal 上下文的值;即如果同一个任务下一次提交时不执行修饰而仍然使用上一次的 TtlRunnable,则提交的任务运行时会是之前修饰操作所抓取的上下文。

修饰线程池其实本质上也是修饰 Runnable,只是将这个逻辑移到了 ExecutorServiceTtlWrapper.submit() 方法内,对所有提交的 Runnable 进行修饰。


public class Main {
static int val = 0; public static void main(String[] args) {
TransmittableThreadLocal<String> ttl = new TransmittableThreadLocal(); ExecutorService executorService = Executors.newFixedThreadPool(1);
Runnable task = new Runnable() {
@Override
public void run() {
System.out.println("child thread get " + ttl.get());
}
};
for (int i = 0; i < 5; i++) {
val++;
ttl.set("value-set-in-parent " + val);
executorService.execute(TtlRunnable.get(task));
}
executorService.shutdown();
}
}

TTL 的原理

TTL 做的是,使用装饰器模式装饰 Runnable 等任务,将原本与 Thread 绑定的线程变量,缓存一份到 TtlRunnable 对象中,每次调用任务的 run() 前后进行 set() 和还原数据。

TTL 的需求场景

需求场景说明

总结

使用 ThreadLocal 库友好地解决了线程本地存储的问题,但是它还存在父子线程值传递丢失的问题,于是 JDK 又引入了 InheritableThreadLocal 对象。

InheritableThreadLocal 的出现又引出了下一个问题,那就是涉及到线程池等复用线程场景时,还是会存在变量复制混乱的缺陷。阿里巴巴提供了解决方案,用 TransmittableThreadLocal 来增强 InheritableThreadLocal 对象。

参考资料

30 | 线程本地存储模式:没有共享,就没有伤害-极客时间 (geekbang.org)

ThreadLocal原理分析及内存泄漏演示-极客时间 (geekbang.org)

ThreadLocal如何在父子线程及线程池中传递?-极客时间 (geekbang.org)

https://github.com/alibaba/transmittable-thread-local

线程本地存储 ThreadLocal的更多相关文章

  1. Atitit usrqbg1821 Tls 线程本地存储(ThreadLocal Storage 规范标准化草案解决方案ThreadStatic

    Atitit usrqbg1821 Tls 线程本地存储(ThreadLocal Storage 规范标准化草案解决方案ThreadStatic 1.1. ThreadLocal 设计模式1 1.2. ...

  2. Java线程本地存储ThreadLocal

    前言 ThreadLocal 是一种 无同步 的线程安全实现 体现了 Thread-Specific Storage 模式:即使只有一个入口,内部也会为每个线程分配特有的存储空间,线程间 没有共享资源 ...

  3. ThreadLocal(线程本地存储)

    1. ThreadLocal,即线程本地变量或线程本地存储. threadlocal的作用是提供线程内的局部变量,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或组件之间一些公共变量传递的 ...

  4. 线程本地存储TLS(Thread Local Storage)的原理和实现——分类和原理

    原文链接地址:http://www.cppblog.com/Tim/archive/2012/07/04/181018.html 本文为线程本地存储TLS系列之分类和原理. 一.TLS简述和分类 我们 ...

  5. 线程本地存储(Thread Local Storage, TLS)简单分析与使用

    在多线程编程中, 同一个变量, 如果要让多个线程共享访问, 那么这个变量可以使用关键字volatile进行声明; 那么如果一个变量不想使多个线程共享访问, 那么该怎么办呢? 呵呵, 这个办法就是TLS ...

  6. .NET:线程本地存储、调用上下文、逻辑调用上下文

    .NET:线程本地存储.调用上下文.逻辑调用上下文 目录 背景线程本地存储调用上下文逻辑调用上下文备注 背景返回目录 在多线程环境,如果需要将实例的生命周期控制在某个操作的执行期间,该如何设计?经典的 ...

  7. C# 线程本地存储 调用上下文 逻辑调用上下文

    线程本地存储 using System; using System.Threading; using System.Threading.Tasks; namespace ConsoleAppTest ...

  8. 线程本地存储TLS(Thread Local Storage)的原理和实现——分类和原理

    本文为线程本地存储TLS系列之分类和原理. 一.TLS简述和分类 我们知道在一个进程中,所有线程是共享同一个地址空间的.所以,如果一个变量是全局的或者是静态的,那么所有线程访问的是同一份,如果某一个线 ...

  9. 线程本地存储(动态TLS和静态TLS)

    线程本地存储(TLS) 对于多线程应用程序,如果线程过于依赖全局变量和静态局部变量就会产生线程安全问题.也就是一个线程的使用全局变量可能会影响到其他也使用此全局变量的线程,有可能会造成一定的错误,这可 ...

随机推荐

  1. 一些GIT操作的技巧

    一.git stash 我们有时会遇到这样的情况,正在分支a上开发一半,然后分支b上发现Bug,需要马上处理.这时候分支a上的修改怎么办呢,git add 是不行的,有的git客户端版本会提示还有ad ...

  2. 147_Power BI Report Server demo演示

    焦棚子的文章目录 服务器地址:http://pbirs.jiaopengzi.com/reports 用户名:pbirs 密码:pbirs 分别用pc网页.pc桌面power bi软件以及手机端pow ...

  3. Net6 Xunit 集成测试

    对于单元测试.集成测试大部分开发的朋友都懒得去写,因为这要耗费精力去设计去开发,做完项目模块直接postman 调用测试(当然这是一个选择,开发也中经常用到),但是如果测试需要多样化数据,各种场景模拟 ...

  4. MySQL之事务和redo日志

    事务 事务的四个ACID特性. Atomicity 原子性 Consistency 一致性 Isolation 隔离性 Durability 持久性 原子性 原子性即这个事务的任务要么全做了,要么全部 ...

  5. MySQLDocker 主从复制搭建

    MySQLDocker 主从复制搭建 MySQLDocker 的搭建 docker search mysql docker pull mysql/mysql-server:8.0.26 docker ...

  6. Fiddler对安卓高版本进行抓包解决方案以及分析 进阶二

    今天是2021年的最后一天了,多分享一些干货吧!看过上一章节教程后会有同学疑惑,我也一步一个脚印的,跟着流程走也设置了代理以及安装了证书,有的同学会发现 为什么手机不能够连接网络了呢?细心一点的同学会 ...

  7. CabloyJS一站式助力微信、企业微信、钉钉开发 - 企业微信篇

    前言 现在软件开发不仅要面对前端碎片化,还要面对后端碎片化.针对前端碎片化,CabloyJS提供了pc=mobile+pad的跨端自适应方案,参见:自适应布局:pc = mobile + pad 在这 ...

  8. DevStream 成为 CNCF Sandbox 项目啦!- 锣鼓喧天、鞭炮齐鸣、红旗招展、忘词了。

    开局两张图,内容全靠"编" 来,有图有真相! DevStream ️ CNCF DevStream joins CNCF Sandbox CNCF Cloud Native Int ...

  9. Java 将HTML转为XML

    本文介绍如何通过Java后端程序代码来展示如何将html转为XML.此功能通过采用Word API- Free Spire.Doc for Java 提供的Document.saveToFile()方 ...

  10. SAP创建XML 文件

    TYPES: BEGIN OF xml_line_type, data(256) TYPE x, END OF xml_line_type, xml_tab_type TYPE TABLE OF xm ...