当前位置:网站首页>多线程带来的的风险——线程安全
多线程带来的的风险——线程安全
2022-06-27 05:59:00 【瘦皮猴117】
目录
1. synchronized——监视器锁 monitor lock(对象锁)
1. synchronized修饰类中的成员方法,锁的对象就是当前类的对象
2. synchronized修饰类中的静态方法,锁的是当前这个类的class对象
1. volatile关键字可以保证共享变量可见性 强制线程读写主内存的变量值
线程安全
线程不安全问题
所谓的线程不安全问题,在多线程并发的场景下,实际运行结果和单线程场景下预期结果不符的问题。
1. 观察线程不安全
/** * 观察多线程场景下的线程安全问题 */ public class ThreadUnSafeDemo { private static class Counter { int count = 0; void increase() { count++; } } public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); // t1将count值 + 5w Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { counter.increase(); } }); // t2将count值 + 5w Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { counter.increase(); } }); t1.start(); t2.start(); t1.join(); t2.join(); // 主线程走到此处,说明t1和t2都已经执行结束,理想状态count = 10w System.out.println("两个子线程执行结束"); System.out.println(counter.count); } }
什么是所谓的线程安全:
线程安全指的是代码若是串行执行和并行执行,结果完全一致,就称为该代码是线程安全的;多个线程串行执行的结果和并行执行的结果不同,这就称为线程不安全。更改代码
/** * 观察多线程场景下的线程安全问题 */ public class ThreadUnSafeDemo { private static class Counter { int count = 0; void increase() { count++; } } public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); // t1将count值 + 5w Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { counter.increase(); } }); // t2将count值 + 5w Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { counter.increase(); } }); t1.start(); t1.join(); t2.start(); t2.join(); // 主线程走到此处,说明t1和t2都已经执行结束,理想状态count = 10w System.out.println("两个子线程执行结束"); System.out.println(counter.count); } }
线程不安全:串行执行和并行执行,结果不同。
2. JMM - Java的内存模型
描述多线程场景下Java的线程内存(CPU的高速缓存和寄存器和主内存的关系)
与JVM部分的JVM内存区域划分(JVM将内存划分为6大区域)不是一个概念
每个线程都有自己的工作内存,每次读取变量(共享变量,不是线程的局部变量)都是先从主内存将变量加载到自己的工作内存,之后关于此变量的所有操作都是在自己的工作内存中进行,然后写回主内存。
共享变量:类中成员变量,静态变量,常量都属于共享变量,即在堆和方法区中存储的变量。
保证线程安全的条件
一段代码要保证是线程安全的(多线程场景下和单线程场景下的运行结果保持一致),需要同时满足以下三个特性。
原子性
原子性:该操作对应CPU的一条指令,这个操作不会被中断,要么全部执行,要么都不执行,不会存在中间状态。这种操作是一个原子性操作。
eg:int a = 10 ——>直接将常量10赋值给a变量原子性,要么没赋值,要么赋值成功。eg:a+=10 ——> a = a +10 先要读取当前变量a的值,再将a +10计算,最后将计算得出的值重新赋值给a变量(对应三个原子性操作)
可见性
可见性:一个线程对共享变量的修改,能够及时的被其他线程看到,这种特性称为可见性。
(synchronized-上锁,volatile关键字,final关键字可以保证可见性)。
count值是Counter类的一个成员变量,这个属性属于共享属性,多个线程同时访问Count类的同一个对象,这个值属于线程共享变量。count值在主内存中存储(堆上)。
这个操作不是一个原子性操作,在某个线程执行count++的时候(分为三步走)其他线程大概率读不到++后的值。
不可见性产生原因:
count变量是共享变量,不同线程都有count的工作内存,工作内存修改不可见;
increase方法内部不是原子性操作;
不可见性导致+不原子性导致的。问题:为何final能保证可见性?final修饰的常量一定是可见的?
答:常量在定义时就赋值了,且赋值后无法修改。
第一种可能性
不可见性+不原子性导致的线程不安全。
1. 最开始t1和t2在线程启动时,会将主内存中的count值读取到自己的工作内存中,二者count = 0。
2. 开始执行各自的run方法,假设此时t1先执行5253次循环,此时t1.count = 5253这个值,此时t1线程将5253写回主内存。
然后t2开始执行,从主内存中加载了这个5253这个初始值运行。同时t1也继续执行,t1先执行结束,最后t1线程的值就是50000,写回主内存。
t2一直读取的自己的工作内存的值,它从5253这个初始值开始运行。在for循环的过程中,一直读取的是自己的工作内存的值(t1在写回主内存的过程中,50000值对t2是不可见的),t2在执行完自己的50000次循环后,将最终值55253写回主内存。
3. 主线程最终去主内存中加载的count值就是55253简述上述过程
第二种可能性
t1和t2并行执行,因为原子性的问题,导致的线程不安全。
t1、t2一直在并行执行
t1的工作内存中count = 0,+1操作使count从0变为1,之后将count = 1写回主内存;
t2的工作内存中count = 0,+1操作使count从0变为1,之后将count = 1写回主内存;
分别执行一次后,结果count = 1。本来计划要+2次(即count = 2)最终只是+1次。执行5w次后,count = 50000。
只要执行过程中,t1和t2稍微有一点点时间差,就会得到不是5w的值。
现实生活中的超卖现象
问题汇总
1. 为什么会有这么多内存?
其实只有一个内存——主内存(硬件中的内存条),JMM讲的工作内存实际上是CPU的高速缓存和寄存器。
2. 为什么CPU要使用缓存和寄存器,不直接读写内存?
速度:CPU的高速缓存和寄存器的读写速度基本上是内存3到4个数量级(成千上万倍)。
成本:高速缓存和寄存器价格非常昂贵,造成的结果是其空间不可能太大,所以需要内存。CPU价格最贵,速度最快;内存次之,硬盘最便宜,速度也最慢。
防止指令重排
了解即可,大部分代码的线程安全问题都是因为不满足可见性和原子性
代码的书写顺序不一定就是最终JVM或者CPU的执行顺序
编译器和CPU会对指令优化,这个优化的前提就是保证代码的逻辑没有错误。在单线程场景下指令重排没问题,但是在多线程场景下就有可能因为指令重排导致错误,一般就是对象还没初始化完成就被别的线程给用了。
关于synchronized关键字
要确保一段代码的线程安全性,需要同时满足可见性,原子性和防止指令重排。
synchronized一个关键字就能同时满足原子性和可见性。/** * 观察多线程场景下的线程安全问题 */ public class ThreadUnSafeDemo { private static class Counter { int count = 0; synchronized void increase() { count++; } } public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); // t1将count值 + 5w Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { counter.increase(); } }); // t2将count值 + 5w Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { counter.increase(); } }); t1.start(); t2.start(); t1.join(); t2.join(); // 主线程走到此处,说明t1和t2都已经执行结束,理想状态count = 10w System.out.println("两个子线程执行结束"); System.out.println(counter.count); } }
1. synchronized——监视器锁 monitor lock(对象锁)
何为锁?锁什么东西?
"互斥" mutex lock
某个线程获取到该对象的锁时,其他线程若也要获取同一个对象的锁,就会处在阻塞等待状态。
当给increase方法加上synchronized关键字,所有进入该对象的线程都需要获取当前counter对象的"锁",获取成功才能进入,获取失败,就会进入阻塞态。
1. 进入synchronized代码块就会尝试执行加锁操作。
2. 退出synchronized代码块,就会释放这个锁。正因为increase方法上锁处理,多个线程在执行increase方法时,其实是排队进入,同一时刻只可能有一个线程进入increase方法执行对count属性的操作,保证了线程安全性。
在Java内部,每个Java对象都有一块内存,描述当前对象"锁"的信息,锁的信息就表示当前对象被哪个线程持有。
a. 若锁信息没有保存线程,则说明该对象没有被任何线程持有;
b. 若锁信息保存了线程id,其他线程要获取该锁,就处在阻塞状态。
等待队列不是FIFO队列,不满足先来后到的特点。
关于互斥的理解,要理解锁的是谁(对象)
/** * 观察多线程场景下的线程安全问题 */ public class ThreadUnSafeDemo { private static class Counter { int count = 0; // 以下代码操作的属性和操作都是原子性和可见性的 synchronized void increase() { System.out.println(Thread.currentThread().getName() + "获取到锁"); count++; try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); // t1将count值 + 5w Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { counter.increase(); } }, "t1"); // t2将count值 + 5w Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { counter.increase(); } }, "t2"); Counter counter1 = new Counter(); Thread t3 = new Thread(() -> { for (int i = 0; i < 50000; i++) { counter1.increase(); } }, "t3"); t1.start(); t2.start(); t3.start(); t1.join(); t2.join(); t3.join(); // 主线程走到此处,说明t1、t2、t3都已经执行结束 System.out.println("两个子线程执行结束"); System.out.println(counter.count); } }
a. t2线程在等待t1线程持有的counter对象的锁——t1和t2处在互斥关系;b. t3线程获取的是counter1这个对象的锁。没线程和他竞争,所以每次都能成功获取,t3和t1、t2不构成互斥关系。
到底是不是互斥关系,就要看锁的是不是一个对象。
2. synchronized代码块刷新内存
线程执行synchronized代码块的流程
a. 获取对象锁
b. 从主内存拷贝变量值到工作内存
c. 执行代码
d. 将更改后的值写回主内存
e. 释放对象锁因为synchronized保证互斥,同一时刻只有一个线程获取到这个对象的锁,就保证了可见性和原子性。
从a到e只有一个线程能执行其他线程都在等待,bcd三步对于其他线程就是天然的可见性和原子性。
问题:加锁操作与单线程的区别?
只是在牵扯到变量更改的时候进行上锁操作,对于该类中其他方法,仍然是并发执行。
此时类中新增getCount方法,方法中没有变量更改操作,所以不存在互斥关系,多个线程可以同时读取。
所谓的单线程,指的是一个类的所有代码同一时刻只有一个线程在操作。
上述指的是,只有increase方法(上锁的方法)是单线程操作,其他方法仍然可以多线程并行。
3. 可重入
获取到对象锁的线程可以再次加锁,这个操作就称为可重入。
Java中的线程安全锁都是可重入的(包括java.concurrent.lock)
public class Reentrant { private class Counter { int val; // 锁的是当前Counter对象 synchronized void increase() { val++; } // 也是锁counter对象 synchronized void increase1() { // 就相当于对同一个Counter对象加了两次锁 increase(); } } }
如果不支持可重入,线程进入了increase1方法,之后进入increase需要再次获取锁,无法进入increase。线程1拿到当前Counter对象锁的线程阻塞在这里,等待线程自己释放锁之后再进入increase()——>这个程序永远不会停止。线程1一直阻塞在这里——死锁。
synchronized支持线程的可重入
Java中每个对象都有一个"对象头",其中包括:
a. 描述当前对象的锁信息,即当前对象被哪个线程持有;
b. 以及一个"计数器",即当前对象被上锁的次数。
情况Ⅰ. 若线程1需要进入当前对象的同步代码块(synchronized),此时当前对象的对象头没有锁信息,线程1是第一个获取锁的线程,进入同步代码块,对象头修改持有线程为线程1, 计数器的值由0 + 1 =1。当线程1在同步代码块中再次调用当前对象的其他同步方法可以进入,计数器的值再次+1,说明此时对象锁被线程1获取了两次。
问题:为什么加两次锁?
答:
synchronized代码块语法没问题,考虑的是多线程的安全问题。之所以在increase1上也加锁,说明increase1需要保证线程安全。
情况Ⅱ. 若线程2需要进入当前对象的同步块,此时当前对象的对象头持有线程为线程1,且计数器值不为0,线程2就会进入阻塞状态,一直等到线程1释放锁为止(直到计数器值为0,才是真正释放锁)。
1. synchronized修饰类中的成员方法,锁的对象就是当前类的对象
synchronized是对象锁,必须得有个具体的对象让他锁
问题:t1和t2是否互斥?
答:
当前t1通过counter1对象调用的increase,则锁的是counter1这个对象,t1获取到的是counter1这个对象的锁;
当前t2通过counter2这个对象调用的increase,锁的是counter2这个对象,t2获取到的是counter2这个对象的锁;
t1和t2不互斥,各回各家,各锁各门。下面的代码中t1、t2才互斥
2. synchronized修饰类中的静态方法,锁的是当前这个类的class对象
class对象全局唯一,相当于我把这个类给锁了,同一时刻只能有一个线程访问这个方法(无论是几个对象)。
public class Reentrant { public static void main(String[] args) { Counter counter1 = new Counter(); Counter counter2 = new Counter(); Counter counter3 = new Counter(); Thread t1 = new Thread(() -> { counter1.increase2(); }, "t1"); Thread t2 = new Thread(() -> { counter2.increase2(); }, "t2"); Thread t3 = new Thread(() -> { counter3.increase2(); }, "t3"); t1.start(); t2.start(); t3.start(); } private static class Counter { // 当synchronized修饰静态方法,则相当于将Counter这个类的所有对象都给锁了 // 其实锁的Counter类的class对象,全局唯一 synchronized static void increase2() { while (true) { System.out.println(Thread.currentThread().getName() + "获取到锁"); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } } } } }
这是一个静态方法,锁的Counter.class对象,全局唯一。
无论通过哪个Counter对象调用increase2(),同一时刻只能有一个线程获取到这个锁,其他线程都在等待,synchronized修饰静态方法需要谨慎。
Counter.class对象是什么?
任何一个类的.class对象都是全局唯一的,当JVM加载这个类的时候产生,描述该类的核心信息(具备哪些属性和方法),这个对象是反射的核心对象。
3. synchronized代码块,明确锁的是哪个对象
锁的粒度更细,只有在需要同步的若干代码才加上synchronized关键字。
public class Reentrant { public static void main(String[] args) { Counter counter1 = new Counter(); Thread t1 = new Thread(() -> { counter1.increase4(); }, "t1"); Thread t2 = new Thread(() -> { counter1.increase4(); }, "t2"); Thread t3 = new Thread(() -> { counter1.increase4(); }, "t3"); t1.start(); t2.start(); t3.start(); } private static class Counter { int val; // 方法上没加synchronized关键字,因此所有线程都可以并发进入increase4方法 void increase4() { System.out.println(val); System.out.println("准备进入同步代码块"); // 同步代码块,进入同步代码块,必须获取到指定的锁 // this表示当前对象引用,锁的就是当前对象counter1 synchronized (this) { while (true) { System.out.println(Thread.currentThread().getName() + "获取到当前对象的锁"); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } } } } } }
public class Reentrant { public static void main(String[] args) { Counter counter1 = new Counter(); Counter counter2 = new Counter(); Counter counter3 = new Counter(); Thread t1 = new Thread(() -> { counter1.increase4(); }, "t1"); Thread t2 = new Thread(() -> { counter2.increase4(); }, "t2"); Thread t3 = new Thread(() -> { counter3.increase4(); }, "t3"); t1.start(); t2.start(); t3.start(); } private static class Counter { int val; // 方法上没加synchronized关键字,因此所有线程都可以并发进入increase4方法 void increase4() { System.out.println(val); System.out.println("准备进入同步代码块"); // 同步代码块,进入同步代码块,必须获取到指定的锁 // this表示当前对象引用,锁的就是当前对象 synchronized (this) { while (true) { System.out.println(Thread.currentThread().getName() + "获取到当前对象的锁"); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } } } } } }
public class Reentrant { public static void main(String[] args) { Counter counter1 = new Counter(); Counter counter2 = new Counter(); Counter counter3 = new Counter(); Thread t1 = new Thread(() -> { counter1.increase4(); }, "t1"); Thread t2 = new Thread(() -> { counter2.increase4(); }, "t2"); Thread t3 = new Thread(() -> { counter3.increase4(); }, "t3"); t1.start(); t2.start(); t3.start(); } private static class Counter { int val; // 方法上没加synchronized关键字,因此所有线程都可以并发进入increase4方法 void increase4() { System.out.println(val); System.out.println("准备进入同步代码块"); // 同步代码块,进入同步代码块,必须获取到指定的锁 // 若锁的是class对象,全局唯一 synchronized (Reentrant.class) { while (true) { System.out.println(Thread.currentThread().getName() + "获取到当前对象的锁"); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } } } } // 当synchronized修饰静态方法,则相当于将Counter这个类的所有对象都给锁了 // 其实锁的Counter类的class对象,全局唯一 synchronized static void increase2() { while (true) { System.out.println(Thread.currentThread().getName() + "获取到锁"); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } } } // 锁的是当前Counter对象 synchronized void increase() { val++; } // 也是锁counter对象 synchronized void increase1() { // 就相当于对同一个Counter对象加了两次锁 increase(); } } }
练习
到底互斥与否,就看多个线程锁的是什么?只有锁的是同一个对象才互斥,不同对象就不互斥。
public class LockNormal { public static void main(String[] args) { Object lock = new Object(); Counter c1 = new Counter(); c1.lock = lock; Counter c2 = new Counter(); c2.lock = lock; Counter c3 = new Counter(); c3.lock = new Object(); Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { c1.increase(); } }, "t1"); Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { c2.increase(); } }, "t2"); Thread t3 = new Thread(() -> { for (int i = 0; i < 50000; i++) { c3.increase(); } }, "t3"); t1.start(); t2.start(); t3.start(); } private static class Counter { int val; Object lock; void increase() { // 不需要同步的代码 // synchronized锁任意对象,传什么锁什么 synchronized (lock) { while (true) { System.out.println(Thread.currentThread().getName() + "获取了锁"); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } } } } } }
问题:这三个线程谁和谁互斥?
A. 都是互斥的t1,t2,t3互斥
B. 都是并发的
C. t1和t2互斥,和t3并发D. t2和t3互斥,和t1并发
E. t1和t3互斥,和t2并发
答:Ct3和t1,t2不构成互斥,t3的lock是一个新的对象。
两次运行结果如下
Java标准库中的线程安全类
线程不安全:多线程发修改同一个集合的内容,就有数据安全问题。
只要多线程访问同一个vector对象,都是互斥的。
volatile关键字:可见性,内存屏障
1. volatile关键字可以保证共享变量可见性 强制线程读写主内存的变量值
相较于普通的共享变量,使用volatile关键字可以保证共享变量的可见性
a. 当线程读取的是volatile关键字时,线程直接从主内存中读取该值到工作内存中(无论当前工作内存中是否已经有该值)。
b. 当线程写的是volatle关键字的变量,将当前修改后的变量值(工作内存中)立即刷新到主内存中,且其他正在读此变量的线程会等待(不是阻塞),直到写回主内存操作完成,保证读的一定是刷新后的主内存值。对于同一个volatile变量,他的写操作一定发生在他的读操作之前,保证读到的数据一定是主内存中刷新后的数据。
未添加volatile关键字
import java.util.Scanner; public class NonVolatile { private static class Counter { int flag = 0; // 未添加volatile关键字 } public static void main(String[] args) { Counter counter = new Counter(); Thread t1 = new Thread(() -> { // t1将flag加载到工作内存后一直读取的是当前工作内存的值 // t2的修改对t1是不可见的 while (counter.flag == 0) { // 一直循环 } System.out.println(counter.flag + "退出循环"); }); t1.start(); Thread t2 = new Thread(() -> { Scanner scanner = new Scanner(System.in); System.out.println("请改变flag的值"); counter.flag = scanner.nextInt(); }); t2.start(); } }
t1将flag加载到工作内存后一直读取的是当前工作内存的值,t2的修改对t1是不可见的。
添加了volatile关键字
import java.util.Scanner; public class NonVolatile { private static class Counter { volatile int flag = 0; } public static void main(String[] args) { Counter counter = new Counter(); Thread t1 = new Thread(() -> { // volatile变量每次都读写主内存 while (counter.flag == 0) { // 一直循环 } System.out.println(counter.flag + "退出循环"); }); t1.start(); Thread t2 = new Thread(() -> { Scanner scanner = new Scanner(System.in); System.out.println("请改变flag的值"); counter.flag = scanner.nextInt(); }); t2.start(); } }
使用synchronized保证可见性
import java.util.Scanner; public class Volatile { private static class Counter { int flag = 0; } public static void main(String[] args) { Counter counter = new Counter(); Thread t1 = new Thread(() -> { while (true) { synchronized (counter) { if (counter.flag == 0) { continue; }else { break; } } } System.out.println(counter.flag + "退出循环"); }); t1.start(); Thread t2 = new Thread(() -> { Scanner scanner = new Scanner(System.in); System.out.println("请改变flag的值"); counter.flag = scanner.nextInt(); }); t2.start(); } }
volatile只能保证可见性,无法保证原子性,因此若代码不是原子性操作,仍然不是线程安全的,volatile != synchronized。
/** * 观察多线程场景下的线程安全问题 */ public class ThreadUnSafeDemo { private static class Counter { // 使用关键字volatile,保证可见性 volatile int count = 0; // 以下代码操作的属性和操作只是可见性的,不保证原子性 void increase() { count++; } } public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); // t1将count值 + 5w Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { counter.increase(); } }, "t1"); // t2将count值 + 5w Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { counter.increase(); } }, "t2"); Counter counter1 = new Counter(); t1.start(); t2.start(); t1.join(); t2.join(); // 主线程走到此处,说明t1和t2都已经执行结束,理想状态count = 10w System.out.println("两个子线程执行结束"); System.out.println(counter.count); } }
2. 使用volatile修饰的变量,相当于一个内存屏障
未使用volatile时
class { int x = 1; // 1 int y = 2; // 2 boolean z = true;// 3 x = × + 1; // 4 y = y + 2; // 5 }
指令重排:CPU会在不影响结果的前提下、执行时不一定按照书写顺序执行。
a. 1 2 3 4 5
b. 1 3 4 2 5
c. 2 3 5 1 4
d. 1 4 2 5 3这四个排法最终结果都一样。
使用volatile修饰时
class { int x = 1; // 1 int y = 2; // 2 volatile boolean z = true;// 3 x = × + 1; // 4 y = y + 2; // 5 }
CPU在执行到第3行时,一定保证1和2已经执行结束(1和2的顺序可以打乱,1、2必须在3之前),且1和2的结果对后面的结果可见;
此时执行到第三行,4和5一定还没开始;
当把第三行执行结束,4和5顺序也能打乱。a. 1 2 3 4 5 √
b. 1 3 4 2 5 ×
c. 2 3 5 1 4 ×
d. 1 4 2 5 3 ×e. 2 1 3 4 5 √
f. 2 1 3 5 4 √
边栏推荐
- Using domain name forwarding mqtt protocol, pit avoidance Guide
- LeetCode 0086.分隔链表
- 多线程基础部分Part2
- 【Cocos Creator 3.5.1】input. Use of on
- 693. 交替位二进制数
- 【Cocos Creator 3.5.1】input.on的使用
- Program ape learning Tiktok short video production
- WebRTC系列-網絡傳輸之7-ICE補充之提名(nomination)與ICE_Model
- Logu p4683 [ioi2008] type printer problem solving
- 导航【机器学习】
猜你喜欢
使用 WordPress快速个人建站指南
LeetCode-515. 在每个树行中找最大值
双位置继电器XJLS-8G/220
JVM对象组成和存储
JVM的垃圾回收机制
How to check the frequency of memory and the number of memory slots in CPU-Z?
双位置继电器RXMVB2 R251 204 110DC
汇编语言-王爽 第13章 int指令-笔记
How win 10 opens the environment variables window
Senior [Software Test Engineer] learning route and necessary knowledge points
随机推荐
【Cocos Creator 3.5.1】input. Use of on
Leetcode298 weekly race record
G1和ZGC垃圾收集器
Implementation of easyexcel's function of merging cells with the same content and dynamic title
How to check the frequency of memory and the number of memory slots in CPU-Z?
双位置继电器HJWS-9440
Software testing year end summary report template
Configuring the help class iconfiguration in C # NETCORE
【Cocos Creator 3.5.1】input.on的使用
双位置继电器DLS-34A DC0.5A 220VDC
Obtenir le volume du système à travers les plateformes de l'unit é
Luogu p2939 [usaco09feb]revamping trails G
我对于测试团队建设的意见
MATLAB快速将影像的二维坐标转换为经纬度坐标
项目-h5列表跳转详情,实现后退不刷新,修改数据则刷新的功能(记录滚动条)
Thinking technology: how to solve the dilemma in work and life?
IP网络通信的单播、组播和广播
Small program of C language practice (consolidate and deepen the understanding of knowledge points)
JVM整体结构解析
Create a basic WDM driver and use MFC to call the driver