news 2026/9/15 8:00:37

Java Object类深度解析:equals、hashCode与并发机制一次讲透

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java Object类深度解析:equals、hashCode与并发机制一次讲透

1. 先搞清楚Object类在Java里到底是什么角色

很多人在初学Java时,第一次接触Object类往往是从toString()开始的。为什么呢?因为当你打印一个对象时,控制台会输出一串看不懂的地址,比如com.example.User@1b6d3586,然后老师告诉你:“这是Object类的默认toString方法,你可以重写它。”

但如果你只在面试前背一背equalshashCode的关系,会很容易漏掉一个关键认知:Object类是Java所有类的祖先,这个“祖先”的身份决定了它在语言设计层面的特殊地位。你想要真正理解Object类的核心方法,第一步不是去背11个方法的名字,而是先想清楚一件事:为什么Java要设计这样一个“万物之父”?

1.1 从继承体系最顶端的设计意图说起

Java是单继承的语言,一个类只能有一个父类。但如果我们不让所有类都默认继承一个公共基类,那么ListString之间就没有任何类型上的联系,泛型、集合框架、反射、垃圾回收这些基础设施全都玩不转。

比如,在没有泛型的旧版本Java里,ArrayList可以往里面扔任何对象,原因是它内部维护的是Object[]。这个数组能装下所有类型,正是因为所有类都直接或间接继承了Object类。换一种说法:Object类就是整个Java类型系统里最大的公共接口,它定义了一组“所有对象都应该具备的基本能力”。

这种设计不是Java首创,但它对开发者的影响是深远的。每当你写一个普通的POJO类,你实际上已经免费继承了11个方法:

方法名作用分类使用频率
toString()对象字符串表示极高
equals(Object obj)对象相等性比较极高
hashCode()哈希值计算极高
getClass()获取运行时类信息
clone()对象拷贝中(但坑很多)
wait()/wait(long)/wait(long,int)线程等待
notify()/notifyAll()线程唤醒
finalize()垃圾回收前清理已废弃,几乎没人用

你注意观察,这些方法大致可以分成三类:对象基本行为类(toString、equals、hashCode、getClass、clone),并发协作类(wait、notify、notifyAll),生命周期类(finalize)。

有意思的是,把线程通信的方法放在Object类里,这个设计决定一直被很多人忽略,也是面试官喜欢追问的点。我们后面会用专门的章节展开讨论:为什么是Object类而不是Thread类来管wait和notify。

1.2 先看一幅完整的Object方法地图

在深入每个方法之前,我建议你先在脑海里存一张地图,知道每个方法解决什么问题。

我一直觉得,Object类的方法不需要死记硬背,你可以从“一个Java对象在运行时会被谁使用”这个角度来推演:

  • 对象要被打印、拼接字符串、打日志,就会调用toString()
  • 对象要被放进HashMap、HashSet,就会调用hashCode()equals()
  • 对象要被比较内容是否相等,就会调用equals()
  • 对象要被拷贝复制,就会调用clone()
  • 对象要被序列化、反射、类型判断,就会调用getClass()
  • 对象背后关联了线程锁,多线程协作时就会调用wait()notify()
  • 对象即将被垃圾回收器回收,多年前JVM会调用finalize()

你看,这张地图本质上就是“对象的一生”:创建、使用、比较、拷贝、并发协作、销毁。

这篇文章我会沿着这个顺序,把每个核心方法背后的原理、使用场景、面试考察点和实际开发中的坑一次讲清楚。有些方法你每天都在用,但未必知道它们为什么这样设计;有些方法你可能一辈子都用不到,但面试官就爱拿它来试探你对JVM和对象生命周期的理解深度。

2. equals与hashCode:一对捆绑出现的方法,拆开就出事

先说一个我每次带新人都会问的问题:你重写过equals()吗?大概三分之一的人会说“写过”,当问到“那你同时重写hashCode()了吗”,基本都沉默了。

这是Java面试最高频的题目之一,也是实际开发中引发线上Bug最常见的原因之一。原因很简单:equals()hashCode()在Java集合框架里是一对“契约绑定”的关系,只实现其中一个,程序就会在某些场景下产生难以发现的逻辑错误。

2.1 为什么要重写equals,默认的equals不够用吗

默认的equals()实现是Object类提供的,它的逻辑非常简单粗暴:

public boolean equals(Object obj) { return (this == obj); }

==在比较引用类型时比较的是内存地址,也就是说,默认的equals()等价于“两个引用是否指向同一个对象”。

但问题来了。业务上我们经常需要基于“内容”来判断两个对象是否相等。举个最典型的例子,用户登录功能:

User user1 = new User("admin", "123456"); User user2 = new User("admin", "123456"); // 业务上,我们希望user1和user2是同一个用户 // 但默认equals比较的是内存地址,结果必然是false

这时候如果你不重写equals(),那后面的一切逻辑都会跑偏:用户在A接口拿到了User对象,在B接口又查了一次数据库得到另一个User对象,你用user1.equals(user2)去判断是否同一个用户,结果返回false,权限校验直接失败。

所以,什么时候应该重写equals?当你需要“逻辑相等”而不是“引用相等”的时候。这句话收录在《Effective Java》里,也被无数面试官反复引用。

2.2 hashCode重写的必要性:从HashMap的一次查找说起

理解了equals()的重要,你也只会说“把这两个方法一起重写是规范”,但说不清楚为什么会这样。没问题,我们用一个具体的HashMap例子来演示。

假设我只重写了equals(),没有重写hashCode()

public class Student { private String studentNo; private String name; public Student(String studentNo, String name) { this.studentNo = studentNo; this.name = name; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Student student = (Student) o; return Objects.equals(studentNo, student.studentNo); } }

现在执行这样的代码:

Map<Student, String> map = new HashMap<>(); map.put(new Student("1001", "张三"), "三年级二班"); String className = map.get(new Student("1001", "张三")); System.out.println(className); // 输出什么?

你可能会想:两个Student的studentNo都是“1001”,通过equals()比较确实相等了,那map.get()应该能查到“三年级二班”吧?

答案是:输出null,查不到。

原因出在hashCode()没重写上。HashMap的工作流程是:先用key的hashCode()定位到数组的某个桶(bucket),如果桶里有多个元素,再通过equals()在链表/红黑树里逐个比较。

这一步调用的是Object类默认的hashCode(),它跟对象的内存地址有关,所以两个new Student(...)虽然业务上相等,但hashCode()的返回值完全不同,直接被分到了不同的桶里,equals()方法根本没有机会被调用。

这个过程可以用一句话总结:HashMap先找“桶”(hashCode),再在桶里“找对象”(equals)。桶都找错了,equals再正确也没有用。

2.3 重写这两个方法时必须遵守的“契约”

现在你应该理解了为什么equals()hashCode()必须成对重写。接下来是重写时绝对不能违反的三个硬性规则,它们来自Java官方文档,面试时能背出这几条会显得你基础非常扎实:

规则一:equals相等,hashCode必须相等

如果a.equals(b)返回true,那么a.hashCode()必须等于b.hashCode()。这是最核心的一条,不满足它,HashMap、HashSet全都会乱套。

规则二:equals不相等,hashCode可以相等,但应尽量不同

如果a.equals(b)返回false,a.hashCode()b.hashCode()是否相等是可选的。如果相等,就叫“哈希冲突”,它们会落到同一个桶里,只要桶内用equals()能区分就没问题,只是性能会下降。如果频繁冲突,哈希表的查找就从O(1)退化成O(n)。

规则三:equals使用的字段,hashCode计算时必须使用相同的字段

如果你用name来判断相等,但hashCode()只算id,那就会出现两个对象equals()为true,hashCode()却不同的情况,直接违反规则一。

2.4 如何用IDE和Objects工具类正确重写

大多数情况下我不建议手写这两个方法,现在的IDEA直接Command + N(Windows是Alt + Insert),选择equals() and hashCode(),勾选关键字段就能生成。

Java 7以后,官方工具类Objects也简化了写法:

@Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Student student = (Student) o; return Objects.equals(studentNo, student.studentNo) && Objects.equals(name, student.name); } @Override public int hashCode() { return Objects.hash(studentNo, name); }

顺便说一个容易忽略的细节:getClass() != o.getClass()o instanceof Student其实是两种不同的判等策略。

  • getClass() != o.getClass():严格校验类型,子类对象和父类对象直接判为不相等。
  • o instanceof Student:允许子类实例参与比较,只要类型兼容就继续比较字段。

两者没有绝对的对错,取决于你的继承设计。但如果类会被继承,且子类可能影响相等性,绝大多数框架和规范推荐使用getClass()严格判断,避免子类对象和父类对象“相等”引发隐蔽问题。

2.5 实际开发中的一个高频踩坑场景

最后再补充一个实战中非常常见的坑:用HashSet去重时,你必须同时正确重写equals和hashCode,否则“去重”完全失效。

我印象最深的一次是处理一批Excel导入的用户数据,需求是“按身份证号去重”。同事在User类里只重写了equals()(比较身份证号),没有重写hashCode(),结果用Set<User>去重时,重复数据全都没被过滤掉。排查了很久才发现,HashSet底层就是HashMap,每个元素都是key,它先计算元素的hashCode()定位桶,重复元素的哈希值不同,直接被放进了不同的桶里,“去重”自然失效。

这类问题不会让程序报错,只会让结果悄悄出错,属于最难排查的那类Bug。所以你以后写实体类,要么一个都别重写,要重写就两个一起重写,这个习惯比背任何面试题都管用。

3. toString与getClass:日常高频方法里的学问

说完了最“重”的equals和hashCode,接下来看两个轻量级方法,它们平时不起眼,但在日志排查、类型判断中起着非常关键的作用。

3.1 默认toString输出的是什么,为什么要重写

每个Java开发者都很熟悉这个流程:代码里打了一个System.out.println(object),控制台输出了一长串看起来像乱码的内容。

这串内容的格式是:

类全限定名@十六进制的无符号哈希码

比如:

com.example.entity.User@6d03e736

这里的6d03e736其实是identityHashCode()的十六进制表示,也就是对象默认哈希码(与内存地址相关,但并不是实际内存地址)。这个输出对调试几乎没有帮助,因为你根本看不出对象内部是什么状态。

所以实践中的规范很明确:所有用于业务传输、持久化的实体类,都应该重写toString()。

一个好的toString应该包含类名和关键字段,比如:

@Override public String toString() { return "User{" + "userId=" + userId + ", userName='" + userName + '\'' + ", status=" + status + '}'; }

重写之后,打日志时会输出:

User{userId=1001, userName='张三', status=1}

一眼就能看出对象内容,排查问题效率翻倍。

3.2 从字符串拼接看toString被隐式调用的情况

还有一个细节很多人没意识到:当对象参与字符串拼接时,toString会被隐式调用。

String message = "当前登录用户是:" + user;

这行代码中,编译器会在拼接时调用user.toString()。如果User类没重写toString,你得到的日志就是:

当前登录用户是:com.example.entity.User@6d03e736

这有什么用?完全没用。

所以我还是建议:实体类、DTO、VO这些承载数据的类,务必重写toString()。不仅是为了规范,更是在关键时候帮你省去大量排查日志的心力。

顺带提一个Lombok用户的常见误区。很多人会直接加@Data注解,它确实会生成toString,但默认会输出所有字段。如果对象里有byte[]内容(比如文件流),日志输出会非常长;如果有循环引用,toString还可能引发栈溢出。碰到这种情况,应该用@ToString.Exclude排除无关字段,或者直接手写toString。

3.3 getClass()的返回值是什么,为什么说“动态”才是关键

getClass()方法返回的是Class类型的对象,它代表当前对象运行时的实际类。

这里需要注意“运行时”三个字。看这段代码:

class Animal {} class Dog extends Animal {} Animal animal = new Dog(); Class<?> clazz = animal.getClass(); System.out.println(clazz.getName()); // 输出 com.example.Dog

虽然变量的静态类型是Animal,但getClass()返回的是Dog,因为JVM在运行时知道这个对象实际上是Dog的实例。这正是反射机制的基础:你可以在程序运行时,动态获取对象的类信息、字段、方法、注解。

3.4 getClass()和instanceof:一比较就分高下

这是一道高频面试题:判断对象类型时,应该用getClass()还是instanceof

它们的核心区别在于:

判断方式判断依据典型使用场景
obj instanceof Dog判断对象是否是Dog类或其子类的实例多态场景、方法入参校验
obj.getClass() == Dog.class精准判断对象运行时类是否就是Dog本身equals方法、严格类型过滤

看一个直观示例:

class Animal {} class Dog extends Animal {} class Puppy extends Dog {} Animal a = new Puppy(); System.out.println(a instanceof Dog); // true,因为Puppy是Dog的子类 System.out.println(a.getClass() == Dog.class); // false,因为运行时类是Puppy

实战中的选择规则很简单:

  • 你需要支持多态,比如一个方法要接收Animal及其所有子类,用instanceof
  • 你需要精确匹配类型,不允许子类“冒充”,用getClass()

回到我们前面说的equals()实现,重写equals方法时getClass() != o.getClass()就属于第二种情况,目的是保证只有类型完全相同的对象才能进行比较,避免子类对象和父类对象相互比较时字段不完整导致误判。

4. clone方法与深浅拷贝:我能不用就不用

clone()可能是Object类里最坑的方法,没有之一。它声明为protected,不重写你根本没法在类外部调用。就算你重写了,还有浅拷贝的坑藏在后面。我在工作中经常跟团队说一句话:除非你真的知道自己在做什么,否则尽量别用clone(),更稳妥的替代方案有很多。

4.1 protected修饰符带来的第一个“坑”

打开JDK源码看clone()的声明:

protected native Object clone() throws CloneNotSupportedException;

它有两个特征对这一节很重要:首先它是protected的,意味着外部类无法直接调用别人的clone();其次它是native的,底层由JVM实现,不是普通的Java代码。

所以你要在一个普通类里使用clone(),必须重写它,并把访问修饰符改为public

重写时还要注意:类必须实现Cloneable接口,否则调用clone()会抛出CloneNotSupportedException

这就是Cloneable接口最大的特点:它是一个标记接口,里面没有任何抽象方法,纯粹是告诉JVM“这个类允许被克隆”。这种设计在Java中不算常见,但也不算罕见,后来JDK还推出了Serializable等类似接口。

4.2 浅拷贝导致的两个经典问题

就算你按规范实现了Cloneable,重写了clone(),深水区才刚刚开始。

默认情况下,Object.clone()执行的是浅拷贝(shallow copy)。什么意思?对于基本类型字段,它会直接复制一份值;但对于引用类型字段,它只是把引用复制了一份,两个对象仍然指向同一个底层对象。

看这个例子:

public class Order implements Cloneable { private Long id; private List<String> itemNames; // 引用类型字段 @Override protected Object clone() throws CloneNotSupportedException { return super.clone(); } }

执行克隆后,两个Order对象的itemNames指向的是同一个List。你往克隆对象的itemNames里加一个元素,原对象的itemNames也会多一个元素。

这一下就会引出两类问题:

第一类是数据被意外篡改。比如你把一个Order克隆一份用来做订单修改操作,修改关联列表时,原订单同步被改了,等发现问题时数据已经错了。

第二类是多线程并发下的数据共享。如果多个线程各自持有一个“看起来独立”的克隆对象,实际上它们的引用字段共享同一个底层对象,会出现并发修改冲突,而且排查时非常隐蔽,因为从代码逻辑上看每个线程操作的都是自己的对象。

4.3 深拷贝的三种替代方案

那如果你确实需要深拷贝该怎么办?我的建议是按优先级选:

方案一:不使用clone(),自己实现“拷贝构造方法”或“静态工厂方法”。

public class Order { private List<String> itemNames; // 拷贝构造方法 public Order(Order source) { this.id = source.id; this.itemNames = new ArrayList<>(source.itemNames); } }

这种做法最直观,代码的可读性最好,任何看过代码的人都能立刻明白:新对象和原对象的引用字段是独立的。

方案二:使用序列化实现深拷贝。对象实现Serializable后,可以通过字节流序列化再反序列化得到一个深拷贝副本。但注意,序列化有性能开销,对象里的statictransient字段不会被拷贝,使用场景有限。

方案三:使用JSON序列化。这个在互联网项目里非常常见。用JacksonGson把原对象转成JSON字符串,再转回目标类,也能实现深拷贝。注意被转换的类需要有默认构造方法和对应的getter/setter,否则会报错或丢失字段。

我的日常工作里,80%的场景用拷贝构造方法就足够了,10%用JSON序列化,不到万不得已不用原生clone()。记住,拷贝这件事要的是明确和可控,而不是花哨的API。

5. finalize与wait/notify:从对象生命周期到并发协作

还剩下一批方法与JVM和并发紧密相关。这批方法在Java面试中出镜率也很高,尤其是wait/notify的设计原理,属于“答得上方法论、答不上设计论”的差异化考点。

5.1 从被官方标记废弃的finalize谈起

finalize()在JDK 9就被标记为废弃(deprecated)了,到JDK 18里已经不推荐使用甚至不能主动调用了。为什么会走到这一步?

先看它的设计初衷。开发者可以在finalize()中编写资源释放、清理逻辑,JVM垃圾回收器在回收对象之前会调用这个方法。

听起来很合理,对不对?但在实际运行中,finalize有三个严重问题:

第一个问题:调用时机不确定。垃圾回收的发生时机本身就是不确定的,一个对象可能在JVM即将耗尽内存时才被回收,也可能长时间存活,你永远不知道finalize什么时候会被执行。这对“必须及时释放”的资源来说是不可接受的。

第二个问题:性能开销巨大。包含finalize方法的对象在回收时会被JVM单独处理,需要放入一个队列,再由专门的线程逐一执行finalize逻辑,这会让垃圾回收的吞吐量大幅下降。

第三个问题:finalize方法可能“复活”对象。在finalize里把这个对象的引用重新赋值给一个静态变量,这个对象就“复活”了,不会被回收。这种写法会严重干扰内存管理。

所以我的观点很明确:别用finalize,永远别用。释放文件流、数据库连接、网络连接等资源,使用try-with-resources(AutoCloseable)是唯一正确的方式。

5.2 wait/notify为什么放在Object而不是Thread中

这可能是Object类里最值得深挖的设计问题了。但先说一个很多初学者容易混淆的点:Object类里的wait和notify不是用来控制线程状态的,而是用于线程之间的协作通信

为了理解这个问题,我们需要先理解Java对象头背后的锁概念。每个Java对象都可以成为一把锁——synchronized关键字可以实现这一点。当线程进入一个synchronized代码块时,它就是获取了“这个对象的锁”。

wait和notify的调用是有前提的:必须在持有该对象锁的前提下才能调用,否则会抛出IllegalMonitorStateException

synchronized (lockObject) { // 释放lockObject锁,并进入等待状态 lockObject.wait(); } synchronized (lockObject) { // 唤醒一个正在等待lockObject锁的线程 lockObject.notify(); }

现在回到那个面试题:为什么wait/notify要放在Object类里,而不是Thread类里?

我的理解是这样的:wait和notify本质上是在“对象的monitor(监视器锁)”上等待或唤醒,而不是在“线程”上操作。谁拥有锁,谁就是通信的媒介。Java设计者希望线程之间通过共享的对象来进行通信,而不是让某个线程直接控制另一个线程。

举个例子。在生产者消费者模型中,多个生产者线程和消费者线程共享同一个队列对象:

  • 生产者发现队列满了,就调用queue.wait(),让自己停住,等待消费者消费。
  • 消费者消费完,调用queue.notifyAll(),唤醒所有等待这个队列的生产者。

这里的关键是:线程们是通过“共享的queue对象”达成协作的。如果把wait/notify放在Thread类里,变成thread.wait()thread.notify(),那语义就成了“线程之间互相控制”,这不但会引入复杂的线程间依赖,也不符合Java设计中“尽量避免直接操作线程”的理念。

不过,我这里想多说一句务实的建议:Java并发工具类的出现,已经让wait/notify近乎“过时”了。现在的实际开发中,我强烈建议使用java.util.concurrent包下的高级工具,比如BlockingQueue(阻塞队列)、Semaphore(信号量)、CountDownLatch(计数器),这些工具把wait/notify的复杂性封装了起来,能够避开大量手动控制锁导致的bug。

但是,理解wait/notify的原理依然是值得的。因为并发包的底层实现正是基于这些机制,而且这依然是面试官判断你对Java并发理解深度的试金石。

6. 从一道面试题的变体看Object类知识的考察逻辑

聊完了核心方法,我们最后从面试角度来收个尾。文章开头列出的热词里,有大量关于“Java面试题”“Java八股文”的搜索,而Object类几乎是Java面试中第一轮必考题。所以这一章我整理了一套高频题和考察逻辑,帮读者检验所学。

6.1 一个考题的四种问法,背后的考察点完全不同

第1个变体:“说说Object类中有哪些方法,分别有什么作用?

这是最入门的一道,考察的是知识广度。你至少需要说出11个方法,并对每个方法进行分类。背出方法名不稀奇,能按“对象基本行为、并发协作、生命周期”三个维度讲出来的候选人,说明是真的有结构感。

第2个变体:“重写equals为什么一定要重写hashCode?

这道题考察的是知其所以然。如果你能画出HashMap的查找逻辑:先通过hashCode定位桶,再通过equals比较桶内元素,面试官会立刻觉得你拥有实际经验,而不只是背过八股文。

第3个变体:“有两个对象,它们的equals返回true,但hashCode不同,把这两个对象放入HashMap会发生什么?

这算进阶变体了。答案是可以放入,但HashMap会认为它们是两个不同的key,导致数据冗余和get时的逻辑错误。这道题考的是对HashSet/HashMap底层机制的理解深度,而不是简单记住“要同时重写”。

第4个变体:“wait方法到底能不能被中断?wait和sleep有什么区别?

这道题不仅考概念,还考细节:

区别维度waitsleep
所属类ObjectThread
是否释放锁释放对象锁不释放锁
是否需要持锁必须在synchronized块中无需持有锁
唤醒方式依赖notify/notifyAll时间到自动唤醒
是否抛异常InterruptedExceptionInterruptedException

注意,wait会释放锁,sleep不会释放锁。这一点是面试官最爱延伸追问的地方,也是理解“wait为什么能让出CPU空等资源给其他线程”的核心。

6.2 关于Object类学习的个人建议

写了这么多,最后给出几点实际建议。

如果你是在准备Java面试,不要把Object类当成一个孤立的知识点去背。更好的学习姿势是:以Object类为圆心,向四周辐射——从hashCode辐射到HashMap底层原理,从wait/notify辐射到线程生命周期,从finalize辐射到垃圾回收算法,从getClass辐射到反射机制,从clone辐射到深浅拷贝。这样你的知识才是成体系的,而不是零散的八股。

如果你是在实际开发中想把这些方法用好,我核心总结三条经验:实体类合理重写equals/hashCode/toString;判断类型时根据场景选instanceof或getClass;保护性拷贝优先用构造方法或序列化,不要依赖clone方法。

最后再分享一个小技巧:阅读JDK源码时,可以优先关注java.util.Objects这个工具类,它里面封装了很多对Object方法的增强实现(equals/hashCode/requireNonNull/isNull)。这个类能让你的代码更现代、更安全,并且可以真正替代日常开发中大量手写的判空和比较逻辑。

Object类是Java的基础,但把基础讲透彻并不容易。希望在读完这篇文章后,你能从“背了11个方法名”升级到“理解了这11个方法在Java生态中的位置”——这个转变,才是真正基础扎实的标志。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 7:59:56

SolidWorks曲线工具全攻略:齿轮渐开线、螺旋弹簧与样条曲线实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/15 7:59:14

正则表达式实战:批量提取网页图片链接并下载

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/15 7:59:02

纯真CZDB vs GeoLite2:Python离线IP归属地查询库对比与实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/15 7:54:32

Java入门到进阶:从环境搭建到项目实战的完整学习路线

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/15 7:54:28

角膜塑形镜TOP10使用注意事项:安全佩戴与护理全攻略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/15 7:53:09

Go语言range深度解析:从底层原理到性能优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华