Java 15中的隐藏类是咋回事?
Posted crazy_itman
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java 15中的隐藏类是咋回事?相关的知识,希望对你有一定的参考价值。
Java 从1.1 版就有匿名类,但是,匿名类并不是匿名的。你不需要为它们命名,它们是由 Java 编译器命名的。如果你熟悉命令javap
,则可以“反汇编”JAR 文件并查看编译器为匿名类生成的名称。
Java 15 引入了没有名称的隐藏类。它不是语言的一部分,而是 JDK 的一部分。没有用于创建隐藏类的语言元素,但 JDK 方法和类可以提供帮助。
在本文中,我们将讨论
-
什么是隐藏类,它们出现的原因是什么
-
如何使用隐藏类
-
如何使用 JDK 方法加载隐藏类
-
如何使用SourceBuddy轻松创建和加载隐藏类。
什么是隐藏类?
hidden … 不能被其他类的字节码直接使用的类,可能不容易理解。
一个隐藏类被加载到 JVM 中,当一个类是源代码或字节码格式时,它不能被“隐藏”。该术语只能指加载的类,将它们称为秘密加载的类可能更合适。
当一个类以特定方式加载时,它会隐藏起来,以便它在其他代码部分面前保持秘密。保持隐藏并不意味着其他代码不能使用这个类。只要他们“知道”这个秘密,他们就可以。最大的区别是这个类不是“广而告之”的,因为你无法使用名称找到它。
当你以创建隐藏类的隐藏方式加载类时,你就拥有对此类的引用。使用反射方法,你可以多次实例化类,然后可以调用方法、设置和获取字段。如果该类实现了一个接口或继承了一个类,你可以将实例引用强制转换为接口和类,并在不进行反射的情况下调用这些方法。
该类被隐藏有两个原因:
-
它没有其他类可以引用的名称
-
没有从类加载器到类的引用。
当在引用隐藏类的变量上调用getName()
或者getSimpleName()
时,你将获得一些字符串,这些是说人话给人看的名称,与其他类无关。当一个类引用另一个类时,它需要规范名称。getCanonicalName()
返回null
。规范名称是类的实际名称,在隐藏类的情况下不存在。
由于没有规范名称就无法通过类加载器找到该类,因此加载器没有理由保留对该类的引用。当它不能将引用提供给任何对象时,为什么要保留?保留引用只会产生一个副作用:只要类加载器处于活动状态,就会阻止 GC 卸载类。
由于没有来自类加载器的引用,GC 可以在类对象不再使用时立即将其卸载。
隐藏类有什么用?
JEP371描述了隐藏类的原因:允许框架将类定义为框架不可发现的实现细节,这样它们就不能被其他类链接,也不能通过反射发现
许多框架使用动态创建的类,在大多数情况下它们是代理类。代理类实现一个接口或继承另一个类,当被调用时,它实际会调用接口或原始类的实例。它通常还会做一些额外的事情,否则代理类和实例就没有存在的理由了。
当你的代码需要将请求 bean 注入会话 bean 时,Spring 框架就是一个例子。(或者将任何其他生命周期较短的 bean 转换为更长的 bean)多个线程可以同时服务于不同的请求,所有请求都属于同一个会话。所有这些线程都会看到相同的会话 bean,但它们会神奇地看到它们的请求 bean是一个扩展请求 bean 类的代理对象。当你调用请求 bean 上的方法时,你将调用代理实例。它检查线程及其服务的请求并将调用转发到适配的请求 bean。
另一个例子是 JPA 延迟加载。你可以有一个 SQL 表,其中每一行都引用前一行。当尝试加载最后一条记录时,它会自动加载前一条记录,这确实会加载前一条记录。它将加载整个表,除非将该字段注释为lazy。这意味着只有在需要时才必须加载数据库中的实际数据。
当加载记录时,你会得到一个代理对象。这个代理对象知道它指的是哪条记录,并且只有在调用方法时才会从数据库中加载记录。
相同的机制用于面向方面的编程和许多其他情况。
可以仅使用 JDK 反射 API 创建代理类,只要目标类实现要代理的接口即可。如果没有这样的接口,你可以使用ByteBuddy库。
当创建这样的类时,不需要为这些类起任何名字,你就获得了对类的引用和对实例的引用。框架将引用注入它需要的字段,然后代码将它们用作任何对象。它不需要知道类的名称,只需要知道它是目标类或接口的实例即可。但是,某些代码可能会发现该名称。这些类有一些反射可以发现的名称。一些“聪明”的人可能会发现它并玩一些巧妙的把戏,以后可能会遇到维护问题。如果根本没有名字会更好吗?可能是的,它会更干净。因此:隐藏类就出现了。
除此之外,还有一个隐藏类的原因。只要类有名称,就可以通过名称发现它。类加载器必须使类保持活动状态以使其可被发现。类加载器具有对加载类的引用。这意味着垃圾收集器将无法收集该类,即使它不再被使用。
如果一个类没有名字,类加载器就不需要保留对这个类的引用。类加载器不会保留对隐藏类的引用,除非明确指示它们这样做。当一个隐藏类的所有实例都被收集,并且没有对该类的引用时,垃圾收集器会将其识别为垃圾。类加载器不会将类保存在内存中。
这样,当长时间运行的代码创建大量类时,框架不会过度消耗内存。收集未使用类的更好框架不需要为这些临时类创建单独的类加载器。没有必要创建一个短暂的、一次性的类加载器来使类也是一次性的。
支持使用不可发现的类扩展访问控制嵌套。
这是JEP371目标列表中的第二个要点。JVM 可以加载隐藏类,使它们成为嵌套的成员。什么是嵌套?
曾几何时,Java 1.0 版没有内部类。然后,Java 1.1 版本引入了内部类,但没有改变 JVM 结构。JVM 对内部类一无所知。Java 编译器从内部类创建常规(几乎所有)顶级类。它发明了一些有趣的名字,比如A$B
就代表类B存在于类
A中
虽然有一些可见性的黑客攻击。内部类与顶级类具有相同的可见性。一个编译单元(文件)内的任何私有内容都是可见的。然而,可见性也是由 JVM 强制执行的。但是 JVM 看到两个顶级类。编译器在需要克服此问题的任何地方在类中生成桥接方法。它们是 JVM 的包级别,当被调用时,它们将调用传递给私有方法。
然后在 25 年后出现了 Java 11,并引入了 nest control。从 Java 11 开始,每个类都与另一个类或自身有关系,后者是该类的嵌套宿主。具有相同嵌套host的类可以看到彼此的私有成员。JVM 不再需要桥接方法。
当加载一个隐藏的类时,你可以指定它成为与创建查找对象的类相同的嵌套(具有相同的嵌套宿主)的成员。
支持主动卸载不可发现的类,以便框架可以灵活地根据需要定义任意数量的类。
这是很重要的一点。当创建一个类时,只要类加载器处于活动状态,它就会保留在内存中。类加载器保留对它们加载的所有类的引用,某些代码可能会要求类加载器按名称返回加载的类对象。应用程序逻辑可能早就忘记了类;没有人会需要它。尽管如此,垃圾收集器仍无法收集它,因为类加载器中存在引用。一个解决方案是为每个新的非隐藏动态创建的类创建一个新的类加载器,但可能会误杀。
默认情况下,加载隐藏类的类加载器不会保留对隐藏类的引用。与嵌套host一样,可以提供不同的选项。
没有名称,不可发现,但保留一个额外的引用,这样 GC 就不会丢弃它。
弃用非标准 API sun.misc.Unsafe::defineAnonymousClass,以便在未来的版本中将其删除。
通过这些,我们讨论了什么是隐藏类。你应该对它们的性质及其重要性有深入的了解。
在下文中,我将讨论如何使用 JDK API 创建隐藏类,然后使用SourceBuddy。
创建隐藏类
在本文中,我们将在运行时从文本、Java 源代码动态创建一个类,然后将生成的字节码加载为隐藏类。本文的示例项目仅包含单元测试文件。类是TestHiddenClassLoader
。我们将隐藏类的源代码存储在一个字段变量中。
1. private static final String CODE1 = """
2. package com.javax0.blog.hiddenclasses;
3.
4. public class MySpecialClass implements TestHiddenClassLoader.Hello
5.
6. @Override
7. public void hello()
8. System.out.println("Hello, from the hidden class.");
9.
10.
11. """;
接口也在同一个类中。
1. interface Hello
2. void hello();
3.
单元测试:
1. final var byteCode = Compiler.java().from(CODE1).compile().get();
2. final var lookup = MethodHandles.lookup();
3. final var classLookup = lookup.defineHiddenClass(byteCode, true);
4. final var helloClass = (Class<Hello>) classLookup.lookupClass();
5.
6. final var hello = helloClass.getConstructor().newInstance();
7. hello.hello();
我们在此代码中使用 SourceBuddy 库将 Java 源代码编译为字节代码。示例的第一行就是这样做的。我们使用 SourceBuddy 版本 2.1.0。
我们需要一个查找对象来加载编译后的字节码作为隐藏类。该对象在第二行创建。第三行和第四行使用查找对象来加载隐藏的类。第 3 行定义了将其加载到 JVM 中的类。第二个参数 ,true
,初始化类,那是static
块执行的时候。最后一行调用接口定义的方法hello()
。
现在局部变量hello
是一个对象的实例,一个隐藏类。什么是隐藏类的名称、简单名称和规范名称?我们把它打印出来。
1. System.out.println("1. " + hello.getClass());
2. System.out.println("2. " + hello.getClass().getClassLoader());
3. System.out.println("3. " + this.getClass().getClassLoader());
4. System.out.println("4. " + hello.getClass().getSimpleName());
5. System.out.println("5. " + hello.getClass().getName());
6. System.out.println("6. " + hello.getClass().getCanonicalName());
7. System.out.println("7. " + lookup.getClass());
8. System.out.println("8. " + lookup.getClass().getClassLoader());
输出:
Hello, from the hidden class.
1. class com.javax0.blog.hiddenclasses.MySpecialClass/0x00000008011b0c00
2. jdk.internal.loader.ClassLoaders$AppClassLoader@5b37e0d2
3. jdk.internal.loader.ClassLoaders$AppClassLoader@5b37e0d2
4. MySpecialClass/0x00000008011b0c00
5. com.javax0.blog.hiddenclasses.MySpecialClass/0x00000008011b0c00
6. null
7. class java.lang.invoke.MethodHandles$Lookup
8. null
我们可以看到调用hello()
的输出,然后是类对象隐式toString()
打印的名称、加载隐藏类的类加载器、简单名称、名称,最后一行是规范名称。最后一个很有趣null
,没有显示类名。它是隐藏的。
该类虽然是隐藏的,但具有对加载它的类加载器的引用。当代码执行过程中有任何需要解决的问题时,就需要它。不同之处在于类加载器没有对类的引用。从类到加载器的一个方向存在,但从加载器到类的另一个方向不存在。
类加载器与加载类调用MethodHandles.lookup()
的类加载器相同。可以看到,因为我们在测试中打印出了this
对象的类加载器。
最后,我们还打印出查找对象的类和类加载器。后者是null
,这意味着引导类加载器加载了它。
还应该注意接口hello
是包私有的。它对于动态创建的代码仍然可见,因为它在同一个包和模块中。
加载隐藏类时,它与定义接口的包在同一个包中。然而,这还不够,因为我们将在下一节中看到一个示例。同一个类加载器加载接口和隐藏类也是一个要求。这样,接口和隐藏类位于同一个模块中,在本例中为同一个未命名模块。不同的类加载器将类加载到不同的模块中;因此,当你使用不同的类加载器加载一个类时,可能看不到包字段、方法、接口等,即使它们在同一个包中。
查找对象来自同一模块并不是唯一的要求,还要求它与要加载的类来自同一个包,在这一点上很容易搞混淆。
查找对象是java.lang.invoke
包中类的一个实例。加载此类的类加载器是null
,这意味着是引导类加载器。引导类加载器是用 C/C++ 而不是 Java 实现的。没有对应的Java对象代表这个类加载器;因此不能引用它,通过从getClassloader()
返回null
来解决。有一个模块、包和类“属于”查找对象,代码的模块、包和类称为MethodHandles.lookup()
方法。
不能从一个包为另一个包创建隐藏类。如果尝试这样做,就像下面的示例代码一样:
1. try
2. final var byteCode = Compiler.java()
3. .from("package B; class A").compile().get();
4. MethodHandles.lookup().defineHiddenClass(byteCode, true);
5. catch (Throwable t)
6. System.out.println(t);
7.
还是测试类的com.javax0.blog.hiddenclasses.TestHiddenClassLoader
。要加载的类与MethodHandles.lookup()
的调用者不在同一个包中。它将导致打印输出:
java.lang.IllegalArgumentException: B.A not in same package as lookup class
轻松创建隐藏类
在上一节中,我们动态创建了一个新类,并隐藏加载了新类。加载是使用我们从MethodHandles
类中获取的查找对象完成的。在本节中,我们将了解如何通过调用 SourceBuddy 的 API 来实现同样的目的。
创建类 saying hello 的代码如下:
1. final var hello = Compiler.java()
2. .from(CODE1.replaceAll("\\\\.Hello", ".PublicHello")).hidden()
3. .compile().load().newInstance(PublicHello.class);
4. hello.hello();
在这段代码中,我们将接口从Hello
替换成了PublicHello
1. public interface PublicHello
2. void hello();
3.
与以前的界面基本相同,但public
这个过程比以前简单得多。我们指定源代码;声明它是一个隐藏类调用hidden()
,我们编译、加载并请求一个实例转换为PublicHello
。
如果我们想使用 package-private 接口,比如(不替换Hello
为PublicHello
):
1. Assertions.assertThrows(IllegalAccessError.class, () ->
2. Compiler.java().from(CODE1).hidden().compile().load().newInstance(PublicHello.class));
我们会得到如下错误。
java.lang.IllegalAccessError: class com.javax0.blog.hiddenclasses.MySpecialClass/0x00000008011b1c00 cannot access its superinterface com.javax0.blog.hiddenclasses.TestHiddenClassLoader$Hello (com.javax0.blog.hiddenclasses.MySpecialClass/0x00000008011b1c00 is in unnamed module of loader com.javax0.sourcebuddy.ByteClassLoader @4e5ed836; com.javax0.blog.hiddenclasses.TestHiddenClassLoader$Hello is in unnamed module of loader 'app')
原因在错误消息中解释得很清楚。接口和实现它的类在两个不同的模块中。两者都是未命名模块,但它们并不相同。在 Java 中,从 Java 9 开始,有了模块,当应用程序不使用模块时,它实际上创建了伪模块,将类放在那里。JDK 类仍在模块中,例如java.base
.
如上创建的隐藏类创建使用单独的类加载器来加载动态编写的 Java 类。单独的类加载器将类加载到它的模块中。不同模块中的代码无法看到来自其他模块的类,除非它们是公共的。
尽管 SourceBuddy 做了一些小技巧来加载一个隐藏的类,但它无法克服这个限制。
加载隐藏类需要查找对象。应用程序通常提供此对象。上面的调用没有指定任何查找对象,但 SourceBuddy 仍然需要,为此它创造了一个。查找对象会记住调用的类MethodHandles.lookup()
来创建一个。加载隐藏的类时,要求查找对象“属于”该类的包。查找对象已创建,从该包中的类调用它。查找对象将“属于”该类,因此属于该类的包。
要拥有来自特定包中的类的查找对象,我们需要该包中的一个类可以给我们一个。如果代码中没有,我们必须动态创建一个。SourceBuddy 正是这样做的。它为类创建 Java 源代码,编译并加载它,实例化它,并调用Supplier<MethodHandles.Lookup>
类实现的已定义的get()
方法。这是一种似乎违反了Java内置访问控制的技巧。我们似乎在没有为它准备的包中获得了一个新的隐藏类。包在 Java 中受到保护免受外部访问(微不足道)。只能从包外部使用公共和受保护的成员和类。可以使用反射从外部访问包,但只能在同一模块中访问,或者必须显式打开该模块。类似地,使用查找对象加载的对象应该在同一个包中,并且可以访问包的内部成员,如果包中的类提供了该查找,则不会。
从错误信息中我们可以看出,它似乎只是包。实际上,新的隐藏类在同名的包中,但在不同的模块中。
如果你想在同一个包中有一个隐藏的类,而不仅仅是一个同名的包,你需要一个来自该包的查找对象。
在我们的示例中,它很简单。我们的Hello
接口与测试代码在同一个包中,这样我们就可以自己创建查找对象:
1. final var hi = Compiler.java().from(CODE1).hidden(MethodHandles.lookup()).compile()
2. .load().newInstance(Hello.class);
3. hi.hello();
在实际示例中,访问查找对象可能会稍微复杂一些。当调用 SourceBuddy 的代码与生成的代码位于不同的包中时,查找对象的创建不能在 SourceBuddy 调用代码中。
在下面的例子中,我们将看到如何做到这一点。
我们在com.javax0.blog.hiddenclasses.otherpackage
包中有一个类OuterClass
。
1. package com.javax0.blog.hiddenclasses.otherpackage;
2.
3. import java.lang.invoke.MethodHandles;
4.
5. public class OuterClass
6.
14. public static MethodHandles.Lookup lookup()
15. return MethodHandles.lookup();
16.
17.
这个类有一个lookup()
方法,它创建一个查找对象并返回。如果我们从我们的代码中调用这个方法,我们将有一个合适的查找对象。请注意,此类位于不同的包中,与我们的测试代码不同。我们的测试代码在com.javax0.blog.hiddenclasses
,更深一层的封装OuterClass
。本质上是在不同的包中。
我们还有另一个类用于演示。
1. package com.javax0.blog.hiddenclasses.otherpackage;
2.
3. class MyPackagePrivateClass
4.
5. void sayHello()
6. System.out.println("Hello from package private.");
7.
8.
9.
它是一个包含包私有方法的包私有类。如果我们动态创建一个隐藏类,如下例:
1. final var hidden = Compiler.java().from("""
2. package com.javax0.blog.hiddenclasses.otherpackage;
3.
4. public class AnyName_ItWillBeDropped_Anyway
5. public void hi()
6. new MyPackagePrivateClass().sayHello();
7.
8. """).hidden(OuterClass.lookup()).compile().load().newInstance();
9. final var hi = hidden.getClass().getDeclaredMethod("hi");
10. hi.invoke(hidden);
我们还没有谈到一个话题,那就是如何创建nestmate。
当有一个二进制类文件时,你可以将它作为一个 nestmate 加载到一个提供查找对象的类中。JVM 不关心该类是如何创建的。当我们编译 Java 源代码时,我们只有一种可能,该类必须是内部类。
当使用 SourceBuddy 时,必须将源代码作为内部类提供给你希望隐藏的代码与其嵌套的代码。编译代码时已经提供了源代码和类。不可能向该源代码中插入任何新的内部类,我们必须骗过编译器。
我们提供了一个与稍后要插入内部类同名的类。编译完成后,我们还有外部类和内部类。我们告诉类加载忘记外部,只加载隐藏的内部。
这就是我们要做的。这次我们在这里显示我们用于演示的整个外部类,包括跳过的行。
1. package com.javax0.blog.hiddenclasses.otherpackage;
2.
3. import java.lang.invoke.MethodHandles;
4.
5. public class OuterClass
6.
7. // skip lines
8. private int z = 55;
9.
10. public int getZ()
11. return z;
12.
13. // end skip
14. public static MethodHandles.Lookup lookup()
15. return MethodHandles.lookup();
16.
17.
正如你将看到的,它有一个私有字段和一个 getter 来有效地测试更改的值。它也有前面提到的lookup()
方法。动态创建内部类的代码如下:
1. final var inner = Compiler.java().from("""
2. package com.javax0.blog.hiddenclasses.otherpackage;
3.
4. public class OuterClass
5.
6. private int z;
7.
8. public static class StaticInner
9. public OuterClass a()
10. final var outer = new OuterClass();
11. outer.z++;
12. return outer;
13.
14.
15.
16. """).nest(MethodHandles.Lookup.ClassOption.NESTMATE).compile().load()
17. .newInstance("StaticInner");
18. final var m = inner.getClass().getDeclaredMethod("a");
19. final var outer = (OuterClass)m.invoke(inner);
20. Assertions.assertEquals(56, outer.getZ());
source里面有OuterClass
,不过只是帮助编译,告诉SourceBuddy嵌套宿主的名字。当我们调用带有NESTMATE
选项的nest()
方法时,它知道类OuterClass
是嵌套宿主。它还标记了类加载器永远不会加载的类。内部类编译为不同的字节码,当被加载后成为OuterClass
的nestmate。
如果你注意本文中讨论的 Java 访问控制的复杂细节,会注意到我们没有提供查找对象。上面的例子仍然有效。这怎么可能?当调用nest()
时,SourceBuddy 查找已加载的OuterClass
版本并使用反射获取查找对象。为此,外部类必须具有 type 的静态字段或MethodHandles.Lookup
方法。OuterClass
有一个方法,所以SourceBuddy调用这个方法来获取查找对象。
上面的例子创建了一个静态内部类,也可以以相同的方式创建非静态内部类。
非静态内部类的创建与静态内部类的创建非常相似:
1. final var outer = new OuterClass();
2. final var inner = Compiler.java().from("""
3. package com.javax0.blog.hiddenclasses.otherpackage;
4.
5. public class OuterClass
6. private int z;
7.
8. public class Inner
9. public void a()
10. z++;
11.
12.
13.
14. """).nest(MethodHandles.Lookup.ClassOption.NESTMATE).compile().load()
15. .newInstance("Inner", classes(OuterClass.class), args(outer));
16. final var m = inner.getClass().getDeclaredMethod("a");
17. m.invoke(inner);
18. Assertions.assertEquals(56, outer.getZ());
我们需要一个外部类的实例来实例化内部类。它是变量outer
。我们必须通过 SourceBuddy 的 API 将这个变量传递给构造函数newInstance()
。此方法调用有一个版本,它接受一个构造函数参数类型Class[]
和一个值的数组Object[]
。在内部类的情况下,它是外部类和实例。
6.总结
本文讨论了 Java 15 中引入的隐藏类的一些细节,比通常的介绍性文章更深入一些,了解了隐藏类的工作原理以及如何在项目中使用它们。
以上是关于Java 15中的隐藏类是咋回事?的主要内容,如果未能解决你的问题,请参考以下文章