什么?同步代码块失效了?-- 自定义类加载器引起的问题

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了什么?同步代码块失效了?-- 自定义类加载器引起的问题相关的知识,希望对你有一定的参考价值。


一、背景

最近编码过程中遇到了一个非常奇怪的问题,基于单例对象的同步代码块似乎失效了,百思不得其姐。

下面给出模拟过程和最终的结论。

二、场景描述和模拟

2.1 现象描述

​Database​​​实现单例,在 init 方法中使用同步代码块来保证 ​​data​​不会被重复赋值,因此打印语句不应该重复打印。

public class Database 
private static final Database dbObject = new Database();

private volatile String data;

private Database()


public static Database getInstance()
return dbObject;


public void init()
synchronized (this)
if (data == null)
data = "test";
System.out.println("同步代码块中赋值。" );



在构造 ​​MyClass​​​ 的时候会自动获取 ​​Database​​​ 单例,并执行 ​​init​​ 方法。

public class MyClass 
private Database database;

public MyClass()
database = Database.getInstance();
database.init();


public Database getDatabase()
return database;

在业务代码中会自动创建 ​​MyClass​​​ 对象,因此会多次获取 ​​Database​​​ 单例并执行 ​​init​​​ 方法。
由于是单例 ​​​synchronized(this)​​就可以保证 init 中的打印语句不会多次执行,但是从日志看最终执行了两次。

2.2 场景模拟

最终发现,实际上项目中自定义了类加载器,导致的。
自定义该类加载器的目的是为了避免类冲突,保证该框架使用的某个 Jar 包固定在特定版本,又不影响用户使用其他版本。

package org.example.classloader;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;

public class MyClassLoader extends ClassLoader

@Override
public String getName()
return "MyClassLoader";


// 类文件的根目录
private String rootDir;

// 构造方法,传入类文件的根目录
public MyClassLoader(String rootDir)
this.rootDir = rootDir;



// 重写 loadClass 方法,打破双亲加载机制
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
// 自己先加载
Class<?> clazz = null;
try
clazz = findClass(name);
catch (ClassNotFoundException e)
// 自己加载器加载失败,不做处理

// 如果自己加载器加载成功,直接返回
if (clazz != null)
return clazz;

// 如果自己加载器加载失败,调用父加载器的 findClass 方法加载类
return super.loadClass(name, resolve);



// 重写 findClass 方法,实现自己的类加载逻辑
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException
// 根据类名获取类文件的路径
String classPath = rootDir + File.separator + name.replace(".", File.separator) + ".class";
// 读取类文件的字节码
byte[] classBytes = getClassBytes(classPath);
// 如果字节码为空,抛出异常
if (classBytes == null)
throw new ClassNotFoundException("Cannot find class: " + name);

// 调用 defineClass 方法将字节码转换为 Class 对象
return defineClass(name, classBytes, 0, classBytes.length);


// 读取类文件的字节码
private byte[] getClassBytes(String classPath)
// 创建文件对象
File file = new File(classPath);
// 如果文件不存在,返回空
if (!file.exists())
return null;

// 创建字节数组,长度为文件大小
byte[] bytes = new byte[(int) file.length()];
// 创建文件输入流
try (FileInputStream fis = new FileInputStream(file))
// 读取文件内容到字节数组
fis.read(bytes);
catch (IOException e)
// 发生异常,返回空
return null;

// 返回字节数组
return bytes;

模拟代码如下:

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class ClassLoaderDemo
public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, NoSuchMethodException, InvocationTargetException

// 第一次执行
MyClass myClass = new MyClass();
System.out.println("第1次加载" + myClass.getDatabase());

// 第二次执行
MyClassLoader myClassLoader = new MyClassLoader("~/IdeaProjects/test/target/classes/");
Class<?> myClazz = myClassLoader.loadClass("org.example.classloader.MyClass", false);
Object obj = myClazz.newInstance();
Method getDatabase = myClazz.getMethod("getDatabase");
System.out.println("第2次加载" + getDatabase.invoke(obj));

为了更好地排查问题,我们在打印语句中打印类加载器:

public class Database 
private static final Database dbObject = new Database();

private volatile String data;

private Database()


public static Database getInstance()
return dbObject;


public void init()
synchronized (this)
if (this.data == null)
data = "test";
System.out.println("同步代码块中赋值。类加载器" + this.getClass().getClassLoader().getName());



实际没有那么明显,比如第一个​​MyClass​​​部分在 Spring 初始化方法中自动创建。第二个 ​​MyClass​​则是在运行时从 jar 包中动态加载时自动创建的。

控制台输出:

同步代码块中赋值。类加载器app
第1次加载org.example.classloader.Database@3f99bd52
同步代码块中赋值。类加载器MyClassLoader
第2次加载org.example.classloader.Database@19469ea2

我们发现,我们实际上分别使用了两个类加载器加载同一个类,而其中一个类加载器违背了双亲加载机制,导致两个类并不相同。

什么?同步代码块失效了?--

因此,原因就找到了,我们分别使用了两个类加载器去加载同一个类,虽然采用单例的机制,实际上并非同一个对象,并不能保证同步代码块正确运行。

最终评估第 2 部分不需要让自定义类加载器来加载,将该部分逻辑从自定义类加载器的条件中移除,问题就解决了。

假如上面的例子我们修改父类优先加载:

@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
// 先委托父类加载器加载类
Class<?> clazz = null;
try
clazz = super.loadClass(name, resolve);
catch (ClassNotFoundException e)
// 父类加载器加载失败,不做处理

// 如果父类加载器加载成功,直接返回
if (clazz != null)
return clazz;

// 如果父类加载器加载失败,调用自己的 findClass 方法加载类
return findClass(name);

发现单例“生效”, init 也不会打印两次。

同步代码块中赋值。类加载器app
第1次加载org.example.classloader.Database@3f99bd52
第2次加载org.example.classloader.Database@3f99bd52

三、相关知识

3.1 类加载机制

3.1.1 双亲加载机制

Java类加载器有以下几种:

  • 引导类加载器(Bootstrap ClassLoader):它是用原生代码实现的,不继承自java.lang.ClassLoader,负责加载Java的核心库,如java.lang.*,以及jre/lib文件夹下的jar包和class文件。
  • 扩展类加载器(ExtClassLoader):它继承自java.lang.ClassLoader,负责加载Java的扩展库,如jre/lib/ext文件夹下的jar包和class文件。
  • 应用类加载器(AppClassLoader):它也继承自java.lang.ClassLoader,负责加载用户的类路径(classpath)下的jar包和class文件。
  • 自定义类加载器(User-Defined ClassLoader):它们是由开发人员自定义的类加载器,继承自java.lang.ClassLoader,可以实现一些特殊的需求,如动态加载,热部署,加密解密等。

这些类加载器之间的关系是一个父子层次结构,除了引导类加载器外,每个类加载器都有一个父类加载器。当一个类加载器收到一个类加载请求时,它会先委托给它的父类加载器,如果父类加载器无法加载,它才会尝试自己加载。这样可以保证核心类库的优先加载,避免被恶意替换。

本文所列的场景就是违背双亲加载机制的一个案例。

3.1.2 双亲类加载机制的目的

  • 可以避免类的重复加载,确保一个类的全局唯一性。因为双亲委派机制是向上委托加载的,所以当父类加载器已经加载了该类时,就没有必要子类加载器再加载一次。
  • 可以保护程序安全,防止核心API被随意篡改。因为 Java 的核心API都是通过引导类加载器进行加载的,如果别人通过定义同样路径的类比如 ​​java.lang.Integer​​​,类加载器通过向上委派,会发现引导类加载器已经加载了jdk 的​​Integer​​​类,而不会加载自定义的 ​​Integer​​类。这样就阻止了对核心API的恶意修改。

3.1.3 遵循双亲加载机制的自定义类加载器的示例

如果想自定义遵循双亲加载机制的类加载器,需要以下三个步骤:

  • 继承 ​​java.lang.ClassLoader​​类,实现一个自己的类加载器。
  • 重写 ​​findClass​​​方法,实现自己的类查找逻辑。例如,从指定的路径或者网络上加载类的字节码,然后调用 ​​defineClass​​方法将字节码转换为 Class 对象。
  • 重写​​loadClass​​方法,遵循类加载的顺序或方式。例如,优先使用父加载器加载,如果加载不到,再交使用本类加载器加载。

具体代码,参考上文中的 ​​MyClassLoader​​ loadClass 部分如下:

@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
// 先委托父类加载器加载类
Class<?> clazz = null;
try
clazz = super.loadClass(name, resolve);
catch (ClassNotFoundException e)
// 父类加载器加载失败,不做处理

// 如果父类加载器加载成功,直接返回
if (clazz != null)
return clazz;

// 如果父类加载器加载失败,调用自己的 findClass 方法加载类
return findClass(name);

3.2 违背双亲加载机制

3.2.1 违背双亲加载机制的场景

违背双亲加载机制的情况有以下几种:

  • 为了避免类冲突,每个web应用项目中都有自己的类加载器,可以加载自己的类库,而不受其他项目的影响。例如,​​Tomcat​​​中的 ​​WebAppClassLoader​​ 就会优先加载自己的类,如果加载不到,再交给父类加载器走双亲委派机制。
  • 为了实现一些特殊的需求,如动态加载,热部署,加密解密等,可以自定义类加载器,覆盖 ​​loadClass​​方法,改变类加载的顺序或方式。例如,OSGi 框架就是通过自定义类加载器,实现了模块化和动态更新的功能。
  • 为了支持一些服务提供者接口(SPI),如JDBC,JNDI等,可以使用线程上下文类加载器(Thread Context ClassLoader),让启动类加载器加载的类可以使用应用类加载器加载的类。例如,​​java.sql.DriverManager​​​类是由启动类加载器加载的,但是它需要加载不同厂商提供的 ​​java.sql.Driver​​​接口的实现类,这些实现类是由应用类加载器加载的,所以 ​​DriverManager​​类就使用了线程上下文类加载器,打破了双亲委派机制。

本文的例子的场景就是为了避免类冲突而自定义类加载器。

3.2.2 违背双亲加载机制的类加载器

如果想自定义违背双亲加载机制的类加载器,需要以下三个步骤:

  • 继承 ​​java.lang.ClassLoader​​类,实现一个自己的类加载器。
  • 重写 ​​findClass​​​方法,实现自己的类查找逻辑。例如,从指定的路径或者网络上加载类的字节码,然后调用 ​​defineClass​​方法将字节码转换为 Class 对象。
  • 重写​​loadClass​​方法,改变类加载的顺序或方式。例如,优先加载自己的类,如果加载不到,再交给父类加载器走双亲委派机制。

具体代码,参考上文中的 ​​MyClassLoader​​ loadClass 部分如下:

@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
// 自己先加载
Class<?> clazz = null;
try
clazz = findClass(name);
catch (ClassNotFoundException e)
// 自己加载器加载失败,不做处理

// 如果自己加载器加载成功,直接返回
if (clazz != null)
return clazz;

// 如果自己加载器加载失败,调用父加载器的 findClass 方法加载类
return super.loadClass(name, resolve);

四、总结

大家在维护一些存在自定义类加载器的框架时一定要特别小心。当发生一些奇奇怪怪的问题时,要主动往这个方向考虑。
另外就像我一直说过的“每一个坑都是彻底掌握某个知识的绝佳机会”,当我们日常开发中遇到一些坑的时候,一定要主动掌握相关原理,甚至总结分享。这样对某个知识点的理解和掌握就更加透彻。


创作不易,如果本文对你有帮助,欢迎点赞、收藏加关注,你的支持和鼓励,是我创作的最大动力。

什么?同步代码块失效了?--


ES自定义分词器

参考技术A es的分词器往往包括3个低级构建块包:

Standard Analyzer
标准分析仪按照Unicode文本分段算法的定义,将文本分割成单词边界的分词。它删除了大多数标点符号,小写显示分词,并支持删除stop words。

Simple Analyzer
当遇到不是字母的字符时,简单的分析器会将文本分成条目。小写显示分词。

Whitespace Analyzer
空格分析器遇到任何空格字符时都会将文本分为多个项目。不会把分词转换为小写字母。

Stop Analyzer
停止分析仪和Simple Analyzer类似,但也支持stop words的删除。

Keyword Analyzer
一个“noop”分析器,它可以接受任何给定的文本,并输出完全相同的文本作为一个单词。

Pattern Analyzer
使用正则表达式拆分分词,支持lower-casing和stop words。

Language Analyzers
Elasticsearch提供许多语言特定的分析器,如英语或法语。

Fingerprint Analyzer
一个专门的分析仪,它可以创建一个可用于重复检测的指纹。

https://www.jianshu.com/p/13112fe5eaad

对中文文本以英文逗号作为分隔符分词:

将分析器设置到索引上

获取分词结果

https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-stop-analyzer.html

es 节点层面的默认分词设置已经废弃,不支持了。就是说在 elasticsearch.yml 配置诸如:

无效,会导致es启动失败:

推荐在索引层面动态设置。
https://blog.csdn.net/yu280265067/article/details/71107658

以上是关于什么?同步代码块失效了?-- 自定义类加载器引起的问题的主要内容,如果未能解决你的问题,请参考以下文章

什么?同步代码块失效了?-- 自定义类加载器引起的问题

ES自定义分词器

自定义类加载器

java 自定义类加载器

(转)JVM——自定义类加载器

自定义一个类加载器