当前位置:网站首页>面试必备——synchronized底层原理的基础知识
面试必备——synchronized底层原理的基础知识
2022-06-10 19:53:00 【Android_la】

文章目录
1 问题背景
利用下班时间花了半个月研究完
volatile关键字,详情见面试必备——说说你对volatile关键字的理解,因为其不保证原子性,可以用synchronized保证。因此来研究synchronized的底层原理。
2 前言
本篇博文参考自b站的子路老师,他讲的视频都挺好(无论是spring源码、nacos服务注册、还是并发),与网上普罗大众互相抄袭的讲解思路不一样,如果有时间推荐大家看看视频,本篇博文大多是基于视频复述出来。文章有很多知识可能需要某些基础知识,这种情况需要大家自行去百度谷歌搜索。需要反复阅读,细细品味。
synchronized底层锁相关的知识与jdk版本有关系,因此在此处先声明本篇博客都是基于 jdk1.8 即Java8研究的。
3 研究synchronized底层原理为什么要了解Java对象头?
网上的csdn博客、博客园、知乎、简书,只要搜索synchronized底层原理,都会讲解
monitorenter、monitorexit指令,然后就直接讲解Java对象头!!!。笔者就非常地疑惑非常地纠结,我研究的是synchronized,你无理由地直接讲解Java对象头干嘛?脑子里思考了很久,为什么涉及到Java对象头。很多人就在博客里面直接讲述Java对象头里面存储了锁表记,00、01、10、11,偏向锁标志位等等。给我的一种感觉就很突兀,我很纠结于为什么,研究原理不应该是直接灌输知识,而是从其背景、问题、缺点等循序渐进引入文中慢慢讲解,这样会有一条很清晰的思路把整个研究过程串起来。
基于上述的原因,笔者引入本小节阐述为什么要了解就Java对象头。
首先看下面的一个synchronized的小例子,伪代码如下:
public class L {
}
public class A {
private int num = 0;
public void incr() {
L l = new L();
synchronized(l) {
int tmp = num;
tmp = tmp + 1;
num = tmp;
}
}
}
如上伪代码所示,synchronized作用在了一个代码块。加上synchronized关键字就是加锁了。我们想点进去看synchronized关键字源码都点不了。synchronized到底是怎么加锁的?它把锁加在哪里了?或者说synchronized锁住的是哪里?
网上的博客都会说到synchronized关键字加在静态方法上是锁住类对象、加在普通方法上是锁住实例对象、synchronized同步块锁住的是括号中的对象(括号中如果是this是锁住实例对象)。从前面这么多情况看出,都说是锁住对象,并没有出现锁住代码。那么我们先认为synchronized是对对象加锁。
锁的本质是一个对象,怎么理解锁?怎么理解加锁?
《深入理解Java虚拟机》中提到 Java中任何对象都可以充当锁。举生活中上厕所的一个例子,我要上厕所,进入里面锁上门。此时厕所外面的人不能进来使用厕所。我用完厕所后,解锁打开门,别人可以用厕所了。别人进去厕所后,也是锁上门。对对象加锁的这一个动作,可以理解成锁上厕所门这个动作。如下图所示:

上图就是厕所门加锁解锁的图。加锁时,我把它顺时针扭动到图中红色的状态,从竖着变成横着。解锁时,我逆时针扭动到图中绿色的状态,从横着变成竖着。那么加锁解锁可以理解成把某个东西的某种属性改变了。比如上图中就是把图中的按钮的位置改变了,本来是竖着的,加锁后变成横着了。
类比到Java中的锁。既然Java中任何对象都能充当锁,那么加锁就是把Java对象的某些东西改变了,解锁也是把Java对象的某些东西改变了。那改变的到底是什么东西呢?因此我们就要研究Java对象。
Java对象是由对象头、实例数据、填充值组成的,详情可见Java的对象组成简介。上面伪代码中的L实例对象并没有属性却仍能作为锁,那么可以说明加锁并不是改变实例数据。当Java对象的内存占用是8byte的倍数时,也不需填充值,那么剩下的就只有对象头了。说明加锁是改变了Java对象头里面的某些东西。因此我们要研究Java对象头。
4 打印Java对象的组成布局
上面完成了一些列的推敲让我们知道为什么要研究就Java对象头,因此我们将Java对象的组成布局打印出来来研究对象头。
4.1 引入依赖
要将Java对象的组成布局打印出来,得使用下面的依赖提供的方法
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.8</version>
</dependency>
4.2 例子
/** * 充当锁 */
public class L {
/** * booelan类型占用1个byte */
boolean i;
}
public class Layout1 {
static L l = new L();
public static void main(String[] args) {
System.out.println("start");
// 打印Java对象的组成布局
System.out.println(ClassLayout.parseInstance(l).toPrintable());
/* * synchronized锁住的是对象不是代码 */
synchronized(l) {
System.out.println("locking");
}
System.out.println("end");
}
}
4.3 运行结果
解释见下图中的文字注解:

5 研究Java对象头的组成
5.1 如何找最官方的介绍
网上很多博客都有如下的介绍:对象头由
mark word、Klass Pointer组成,甚至有些博客还给出了mark word、Klass Pointer各占了多少位。如果直接这样给出来就很突兀,并且并不能知道是否正确,毕竟网上很多东西都是互抄的,或者很多资料对于新时代都过时了。笔者都很想从最原始的官方文档看是否真如此。
基于以上的疑惑,笔者去官方文档找简介。
JVM规范与JVM实现的关系(必读):首先要了解要知道一回事,JVM规范里面定义了Java对象头由什么组成。而JVM规范是一套定义Java的标准,只是做了定义,具体实现交由各个厂商去做。(这一点与spring cloud定义服务注册接口很想,具体实现交由厂商决定,阿里的nacos就是实现了它定义的服务注册接口。) JVM的实现有很多,有Java原生团队做的HotSpot(有一个开源的jdk叫openjdk),淘宝的淘宝VM,JRocket等。这些商用JVM是由具体的代码实现JVM规范定下的标准。因此我们可以去开源的openjdk官网上找hotspot的文档。
访问openjdk官网,选择HotSpot:

找到
glossary of terms,如下图所示:

搜索
mark word,如下图所示:

5.2 官方介绍
mark word:
The first word of every object header. Usually a set of bitfields including synchronization state and identity hash code. May also be a pointer (with characteristic low bit encoding) to synchronization related information. During GC, may contain GC state bits.
笔者翻译: mark word是每个对象头的第一个word,word指的是操作系统的一个字(操作系统的一个专业术语),可以理解为是一个属性。即mark word是每个对象头的一个属性。mark word通常包含有 同步状态、唯一标识hash code。也会有一个指向同步相关信息的指针。当GC时,也会包含GC状态位。
总结:
- mark word根据对象处于的状态 (这个状态有无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,后面小节会详细阐述),会包含一些信息,比如有同步状态、唯一标识hash code、指向同步相关信息的指针、GC状态。
- 可以看到 mark word里面包含了同步状态,再次证明
synchronized是修改了对象头里面 mark word 的某些值,从而实现加锁的。
klass pointer:
The second word of every object header. Points to another object (a metaobject) which describes the layout and behavior of the original object. For Java objects, the “klass” contains a C++ style “vtable”.
笔者翻译: Klass pointer是每个对象头的第二个属性。它指向另外一个对象(元数据对象),这个元数据对象描述了原始对象的布局和行为。对于Java对象,“klass”包含了一个C++风格的“vtable”。
总结: Klass pointer指向类对象
注意:以上两个概念的官方介绍仅仅是给出定义,具体实现仍然要根据具体的商用虚拟机是怎么做的。因此接下来找HotSpot虚拟机的源码看看。
5.3 找关于 mark word 的HotSpot源码
前往 openjdk github,找jdk8版本的源码,如下所示,选中右击在新标签页打开:

根据下面的路径找,如下图所示:

在页面ctrl + f搜索
mark,找到markOop.hpp,如下所示:

找到
64bit,如下图所示:

5.4 官方源码注释
上面5.3节讲述了如何找对象头的官方源码,下面贴出官方源码对对象头的注释:
// 64 bits:
// --------
// unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
// PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
// size:64 ----------------------------------------------------->| (CMS free block)
6 Java对象有多少种状态
Java对象的状态,其实并不算是很官方的说法,但为了方便理解概念,我们先了解大家普遍认可的Java对象的各种状态:
- 无锁状态
- 偏向锁状态
- 轻量级锁状态
- 重量级锁状态
- GC状态
以上5种状态,但在hotspot源码的注释这里只有4行,为什么?,如下所示:
// 64 bits:
// --------
// unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
// PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
// size:64 ----------------------------------------------------->| (CMS free block)
从上面对各个bit介绍,笔者理解是JVM用了几个bit位来表示5种状态。有 biased_lock占用了1bit,lock占用了2bit。
7 无锁状态的对象头
7.1 指针压缩
// 64 bits:
// --------
// unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
// PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
// size:64 ----------------------------------------------------->| (CMS free block)
64bit表示 mark word占用了64bit,具体的bit上存储了什么都给出了解释。
前面第4.3小节打印出的结果是对象头整个长度有 12byte = 12 * 8bit = 96bit,对象头由 mark word 和 klass pointer 组成,mark word 长度有 64bit,那么 klass pointer 的长度有 96bit - 64bit = 32bit。
但实际上 klass pointer的长度是64bit,只不过jjvm开启了
指针压缩,压缩了对象头的长度,未开启指针压缩,对象头的长度是16byte = 16 * 8bit = 128bit,那么 klass pointer的长度有128bit - 64bit = 64bit。
也就是说不开启指针压缩,klass pointer 指针用64bit来存储,开启指针压缩后,就只用32bit 来存储。
为什么要指针压缩?详情可以看JVM的指针压缩
可以简单的理解为压缩后可以 节省存储空间,在 内存 硬件的费用上 节约成本。
7.2 GC分代年龄

age 字段占用4bit,是记录GC的分代年龄。
此处引入GC 年轻代垃圾回收的次数为什么第16次就进入老年代?(GC年轻代垃圾回收,存活的对象在
from区和to区辗转生存,第16次进入老年代区域)
解答:4bit的二进制位能记录的最大值是1111,转换成十进制数是15,即age字段能记录GC分代年龄的最大值是15,存活的对象在 from区和to区辗转生存,每生存下来都会用age记录一下,即age加1,加到最大值15,age加1后无法再存储了,那么第16次会进入老年代。(当然这可能是仅仅其中一方面导致第16次进入老年代,它可以用3位存储age,那么为什么用4位,肯定还有有其他原因,此处不做深究)
7.3 HashCode
网上很多博客都说在无锁状态下,Java头的HashCode是占56bit,那么为什么是占56bit,我们总不可能背下来,面试官问到也答不上来啊,看下面:

如上图所示,hash实际上是占用了 31bit = 3byte + 7bit 而已,但是计算机拿值是以字节为单位的,它拿不了31bit,要么它就拿 32bit = 4byte,因此hash向unused借1bit,形成 32bit = 4byte。而unused又是未被使用的,可以借给hash。因此市面上常说的 56bit就是 25bit的 unused + 31bit的hash。
下面记录一些hashcode的扩展,仅当了解即可,不必背诵。
既然是56bit存储hashcode,那么打印出来为什么是下图这些呢?看起来hashcode应该会有若干个1啊。

如上图所示,很多位上都是0,一个字节的位全是0,无论怎么转换进制(10进制、16进制等)得出来最终的结果都是0。
官方注释写着是56bit存储hashcode的,但是打印出来又是不太符合现实的,那是官方文档说错了?官方文档不可能说错,那么大概率就是我们在某些方面有知识盲区。
问题一:为什么大部分位上全是0?
答:可能是还没有计算过hashcode,那么这个对象就没有存储hashcode。我们计算一下hashcode,代码如下:

运行结果:

可以看到不再是大部分位上都是0了。hashcode是使用Java通过算法计算Java对象的内存所在地址得出来的一个值,存储到对象头上面。没经过计算是不存在对象头里面。
问题二:官方文档说前25bit是未被使用的,但是为什么上图中的前25bit确实有很多1,而后面的字节基本为0呢?
答:因为一般的操作系统(window)是小端存储。
什么是大小端存储?

因此对象的hashcode应该是这样读:

所以可以看到从最右边读起,连续24个bit是0表示未被使用。
我们自己计算的hashcode是否与对象头打印出来的一致,看下图:

一致的。
7.4 偏向锁标志

如上所示,使用1bit存储 偏向锁标志
7.5 锁状态

如上所示,使用2bit存储 锁状态。2bit可以表示4种状态,分别是00、01、10、11。加上偏向锁标志,总共3bit,可以表示 2 ^ 3 = 8种状态,足够表示无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态、GC状态。
7.6 总结无锁状态
前面几个小节研究了hashcode、GC分代年龄,让我们对无锁状态下的对象头有更官方更清晰的理解,下面对无锁状态的情况做一些小总结:
unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
如上所示,无锁状态下,对象头由hashcode、GC分代年龄、偏向锁标志、锁状态标志组成。
8 为什么会有偏向锁标志和锁状态位呢?
前面的小节已经阐述了对象头中会有1bit是偏向锁标志和2bit锁状态位。抛开偏向锁、轻量级锁、重量级锁,为什么给对象加锁要用这两个字段呢?用一个字段不就行了?给对象加锁就设置某位为1,解锁就设置为0。说白了就是为什么要有偏向锁、轻量级锁、重量级锁这几种锁状态,仅仅用1位是无法满足表达这几种状态的。
这就得了解锁发展的历史背景了。
8.1 jdk1.6之前
Java中的Thread对象底层实际上是调用操作系统级别的线程,可以粗略理解为是与操作系统级别的线程一一对应的。而synchronized对线程实现同步互斥是底层调用操作系统级别的互斥(linux的是用 mutex)。通过操作系统级别的互斥,性能比较低。因为互斥涉及到线程的阻塞和唤醒,需要切换状态(用户态与内核态切换)。切换的过程性能很低。
8.2 jdk1.6以后
由于操作系统级别的互斥太重,因此hotspot对锁做了优化,给出了从Java级别做互斥的解决方案。
8.2.1 只有单个线程执行——引入偏向锁
设计者们考虑各种场景的加锁解决方案,比如有下面这个方法:
public void test() {
}
很多时候我们并不能100%保证test()同一时间只会被一个线程调用,因此常常会给该方法加 synchronized,如下:
public synchronized void test() {
}
但是很多时候其实又基本不会有多个线程在同一时间执行test(),jdk1.6之前的synchronized是操作系统级别的互斥(Linux的是用mutex),这会很重。
设计者针对这种没有资源竞争的同步代码块,用 偏向锁 来实现(引入偏向锁),偏向锁就是一个线程调用。
如下代码所示:

L对象被new 出来后,是处于 匿名偏向的状态 (对象头存储了 hashcode,偏向锁的bit为1,锁状态为01,此处暂时不纠结或背诵这些标志位是0还是1,仅当陈述句阐述)。而当第一次用snychronized加锁后,对L对象加偏向锁,通过CAS把自己的线程唯一标识设置到L对象的对象头里面。L对象是处于 偏向锁状态 (对象头存储偏向的 线程唯一标识、epoch、偏向锁的bit为1,锁状态为01)。如下图所示:


如下图所示:

main线程再次对L对象加锁,通过CAS发现自己线程的唯一标识在L对象的对象头里面,证明已经拿到该锁了,不需要再次加锁。不需要执行任何操作系统级别的东西。
8.2.2 多个线程交替执行(无竞争)——引入轻量级锁
如下图所示:

当main主线程执行完synchronized,又有thread对象来锁住L对象,此时不存在线程竞争,都是交替执行。此时锁会进行升级,从偏向锁升级为轻量级锁。如下图所示:

从上图看到,升级到轻量级锁,会保留锁状态的2个bit,其余都是存储一个指针 ptr_to_lock_record,该指针指向一个叫 lock record 数据结构
8.2.3 多个线程同时执行(有竞争)
如下图所示:

从上图看,线程1加完锁后就休眠了,仍然持有锁。而线程2来竞争锁了,那么此时就升级为 重量级锁 。这就回到了jdk1.6以前的做法,调用操作系统层级的互斥来实现锁。
8.3 用3bit表示所有锁状态
上面提到jdk1.6之后引入了有偏向锁状态、轻量级锁状态、重量级锁状态,加上无锁状态和GC标记状态,总共有5种状态。仅用2bit的锁状态字段是无法表示5种状态的。2bit仅能表示00、01、10、11共4种状态。
因此使用1bit的偏向锁字段和2bit的锁状态字段一起来表达这5种状态。
9 各种锁状态对应的二进制值
上面分析了为什么要用3个bit表示所有锁状态,本小节讲述各种锁状态具体的二进制是怎么样的
官方源码的注释中有如下介绍:
// [JavaThread* | epoch | age | 1 | 01] lock is biased toward given thread
// [0 | epoch | age | 1 | 01] lock is anonymously biased
//
// - the two lock bits are used to describe three states: locked/unlocked and monitor.
//
// [ptr | 00] locked ptr points to real header on stack
// [header | 0 | 01] unlocked regular object header
// [ptr | 10] monitor inflated lock (header is wapped out)
// [ptr | 11] marked used by markSweep to mark an object
// not valid at any other time
上面出现了 unlocked、biased、locked、monitor、marked,综合分析他们分别对应的是无锁、 偏向锁 、轻量级锁、重量级锁、GC标记。
那么可以得出如下图的结论:

| 其余位 | 偏向锁位 | 锁状态位 | 锁状态 |
|---|---|---|---|
| hashcode、GC分代年龄 | 0 | 01 | 无锁状态 |
| 线程唯一标识、epoch、GC分代年龄 | 1 | 01 | 偏向锁(此时有线程占有) |
| hashcode、GC分代年龄 | 1 | 01 | 偏向锁(此时无线程占有) |
| ptr指针 | 0 | 00 | 轻量级锁 |
| ptr指针 | 0 | 10 | 重量级锁 |
| ptr指针 | 0 | 11 | GC标记 |
10 验证
10.1 无锁状态
执行以下代码,对象new出来后直接打印:

new出来后是无锁状态,如上图所示,为001
10.2 偏向锁状态
new 出来后,用synchronized尝试加锁:

如上图所示,用synchronized加锁后,是00,即已经是轻量级锁了。为什么呢?对无锁状态的对象加锁,并且没有其他线程竞争,加锁成功后应该是偏向锁101。
为什么会这样呢?是我们理论错误还是我们验证的方法错误?
因为jvm把偏向锁延迟了。为什么会延迟?因为jvm启动的时候会不单单只有main线程,还有其他很多线程。而且jvm本身自己也会有很多代码并且加了synchronized。jvm百分百确定他自己的代码肯定会被其他线程使用。如下所示:
sychronized (aa) {
}
sychronized (bb) {
}
当线程1给aa加锁,加锁成功,则aa称为偏向锁状态。线程1执行完同步代码块后,线程2给aa加锁。这个过程是首先aa从偏向锁状态撤销,而这个撤销过程是很复杂的过程,会耗费很大的性能。撤销后再升级为轻量级锁。这个过程很复杂,所以jvm一上来就给aa加轻量级锁,而不是偏向锁。偏向锁是一种优化,jvm的开发者做了大量的研究发现——很多代码只有一个线程执行,所以采用偏向锁。而jvm确定他自己代码不可能只有一个线程在执行这些代码(比如GC线程、守护线程),所以他把偏向锁延迟了。
我们把延迟偏向锁关闭,再验证下:

关闭偏向锁延迟 后,synchronized加锁后的状态确实是偏向锁101,但是为什么未加synchronized之前也是101呢?
当关闭偏向锁延迟后,或者说jvm已经允许偏向锁后,为什么一个对象new出来后,就已经是偏向锁
101呢?
官方文档有说,当jvm允许偏向锁后,一个对象被new出来后,是处于一个 匿名偏向 锁的状态,但对象头并没有存储任何线程的唯一标识,即对象并没有偏向谁。而当有线程偏向时,对象头就会存储偏向的那个线程的唯一标识。如下图所示:

官方文档对于
匿名偏向状态的注释:
// [JavaThread* | epoch | age | 1 | 01] lock is biased toward given thread
// [0 | epoch | age | 1 | 01] lock is anonymously biased
当第二次加synchronized时,由于都是main线程,不存在锁竞争,所以锁仍然是偏向锁状态:

10.3 轻量级锁
两个线程交替执行,不存在竞争,则升级为轻量级锁,代码如下:
public class Layout1 {
static L l = new L();
public static void main(String[] args) {
System.out.println("start");
System.out.println();
System.out.println(ClassLayout.parseInstance(l).toPrintable());
/** * synchronized锁住的是对象,不是代码 */
synchronized (l) {
System.out.println("locking 1");
System.out.println(ClassLayout.parseInstance(l).toPrintable());
}
synchronized (l) {
System.out.println("locking 2");
System.out.println(ClassLayout.parseInstance(l).toPrintable());
}
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
test1();
}
});
thread1.start();
System.out.println("end");
}
private static void test1() {
synchronized (l) {
System.out.println("xx");
System.out.println(ClassLayout.parseInstance(l).toPrintable());
}
}
}

如上图结果所示,main线程与thread1线程交替执行,无竞争,锁升级为轻量级锁。
10.4 小结
如下图所示:

new一个对象出来是无锁状态001,用synchronized对它加锁是轻量级锁状态000。为什么不是偏向锁状态而是轻量级锁状态?因为jdk1.8版本,jvm默认开启偏向锁延迟4000ms。为什么开启?因为jvm设计者们能确定在jvm启动时,jvm里面有很多代码肯定会被多个线程执行,存在竞争时,处于偏向锁状态的对象需要经历偏向锁的撤销过程并升级为轻量级锁。而这个过程是很复杂很耗性能的。jvm启动,当处于未开启偏向锁的阶段,用synchronized对无锁状态的对象加锁则会直接升级为轻量级锁,减少了偏向锁的撤销过程和锁升级过程的性能消耗。
当jvm开启了偏向锁后,new出来的对象是匿名偏向状态101(对象头的线程ID为空)。如果没有线程交替执行且没有竞争,给这个对象加锁,对象会进入偏向锁状态101(线程ID不为空)。如果有线程交替执行且没有竞争,给这个对象加锁,则这个对象会进入是轻量级锁状态000。多个线程非交替执行且存在竞争,给这个对象加锁,则这个对象会膨胀为重量级锁010。
引入轻量级锁是为了避免操作系统级别的重量级锁带来的开销,引入偏向锁是为了减少锁撤销和升级到轻量级锁的开销。(这句是笔者大概的理解,不存在官方权威认证)
11 总结
本篇博客重在对
synchronized有从0到1的一个超级官方超级新的整体认知,了解synchronized的基础知识。关于面试常问的锁升级过程还需要再写一篇博客总结。
关于下一篇锁升级的博客预热: synchronized锁升级的设计思想本质上是性能和安全性的一种平衡,即如何在一个不加锁的情况下保证线程的安全性。这种思想在编程领域是非常常见的,比如说MySQL里面的MVCC,使用版本链的方式来解决多个并行事务的竞争问题。
边栏推荐
- 移动端渲染原理浅析
- Why do some web page style attributes need a colon ":" and some do not?
- redis设置密码命令(临时密码)
- Magic tower game implementation source code and level generation
- Redis set password command (temporary password)
- Canvas advanced functions (medium)
- pdf. Js----- JS parse PDF file to realize preview, and obtain the contents in PDF file (in array form)
- 观点丨Play and Earn 会让加密游戏误入歧途
- Kcon 2022 topic public selection is hot! Don't miss the topic of "favorite"
- LeetCode:497. Random points in non overlapping rectangles -- medium
猜你喜欢

vulnhub-The Planets: Earth
![[enter textbook latex record]](/img/f0/5ca60f0894d4ae544e7399d18a3a42.png)
[enter textbook latex record]

【技术碎片】重名文件 加后缀重命名过滤实现

牛客网:数组中出现次数超过一半的数字

堆叠条形图鼠标移入tooltip中提示过滤为0元素,实现自定义气泡

电子招标采购商城系统:优化传统采购业务,提速企业数字化升级

pdf. Js----- JS parse PDF file to realize preview, and obtain the contents in PDF file (in array form)

玩艺术也得学数学?

【生成对抗网络学习 其一】经典GAN与其存在的问题和相关改进

在阿里云国际上使用 OSS 和 CDN 部署静态网站
随机推荐
redis设置密码命令(临时密码)
72. 编辑距离 ●●●
JD released ted-q, a large-scale and distributed quantum machine learning platform based on tensor network acceleration
电子招标采购商城系统:优化传统采购业务,提速企业数字化升级
获取列表中最大最小值的前n个数值的位置索引的四种方法
在阿里云国际上使用 OSS 和 CDN 部署静态网站
Handwritten code bind
利用阿里云国际购买的服务器搭建个人网站步骤
MySQL Basics
知识图谱/关系可视化
pdf. Js----- JS parse PDF file to realize preview, and obtain the contents in PDF file (in array form)
Steps to build a personal website using servers purchased by Alibaba cloud international
Why do some web page style attributes need a colon ":" and some do not?
The new audio infinix machine appears in the Google product library, and Tecno CaMon 19 is pre installed with Android 13
服务管理与通信,基础原理分析
canvas 高级功能(中)
CVPR 2022 Tsinghua University proposed unsupervised domain generalization (UDG)
Elastic-Job的快速入门,三分钟带你体验分布式定时任务
「Bug」问题分析 RuntimeError:which is output 0 of ReluBackward0
轻便型FDW框架 for pb