聊聊Jdk中你没听过的关键词-synthetic

1,324 阅读8分钟

前言

为什么要讲讲synthetic和NBAC呢?其实在这之前,对Jdk中这两种机制并不了解,甚至没有听过,主要原因还是因为在阅读SkyWalking中Agent源码过程中,有这么一行,

AgentBuilder agentBuilder = new AgentBuilder.Default(byteBuddy).ignore(
                nameStartsWith("net.bytebuddy.")
                        ...
                        .or(ElementMatchers.isSynthetic()));

而这段代码的作用就是通过ByteBuddy操作字节码时需要忽略synthetic修饰的代码。

什么是synthetic?

翻译一下,我们知道synthetic是意思是:合成的。那在Jdk中"合成的"又代表什么意思呢?

首先通过上面源码,能感觉它好像类似static、final这样的修饰关键词,但是写在代码中却没有办法识别,其实这是因为synthetic是属于编译器层面的一种关键词。

既然已经超出知识范围以外,那我们就得想办法查阅官方文档来找到它的定义,但是凭空去Jdk文档中搜synthetic或者肉眼翻阅,相信我你是绝对没有办法找到的(为什么我这么肯定,原因应该都懂),那就得需要一个锲机。

在平时开发的反射工具包中,通过下面代码你能看到,

public class Test {
    
    public static void main(String[] args) {
        Field[] fields = Test.class.getDeclaredFields();
        for (Field field : fields) {
            field.isSynthetic();
        }
    }
}

Field有一个isSynthetic()方法,判断这个字段是否是合成的,那我们再来看看它的类图结构,

image-20211105172741413

发现isSynthetic()方法是Member接口中的实现,原来Method、Constructor都实现了这个方法,再通过方法上的注释,

    /**
     * Returns {@code true} if this member was introduced by
     * the compiler; returns {@code false} otherwise.
     *
     * @return true if and only if this member was introduced by
     * the compiler.
     * @jls 13.1 The Form of a Binary
     * @since 1.5
     */
    public boolean isSynthetic();

已经描述的比较清楚了,该成员是由编译器引入,从1.5版本之后就出现了,那更详细的解释呢,注释中也提示了,到Java语言规范文档中 13.1 The Form of a Binary 章节查阅,于是在文档中看到这么一句话,

image-20211105173345433

翻译过来就是:如果 Java 编译器发出的构造与源代码中显式或隐式声明的构造不对应,则必须将其标记为合成的,除非发出的构造是类初始化方法(JVMS §2.9)。

到这里,synthetic代表的意思就大概清楚了,就是代码中本来并没有写的一些字段或方法代码,但是在编译时由编译器修改添加上去的,同时用synthetic来标识修饰。

作用和原理

那为什么会出现这样的情况呢?我们通过下面一些Demo来研究一下,这里我们使用Jdk1.8的版本,首先我们定义一个很简单的内部类,如下:

public class FieldOut {

    public String outStr = "out";

    class FieldIn{
        private String inStr = outStr;
    }
}

实际开发经验告诉我们上面内部类的代码时没有任何错误且能正常允许的,但是我们先回顾一下Java语法规范中规定:一个类要是访问另一个类的public属性,必须先获取这个类的实例。也就是说按这种规范话,上面第6行代码应该是下面这样,

private String inStr = new FieldOut().outStr;

咦,这样的话岂不是证明最上面代码应该报错吗?这时你会想到虽然语法规范有这么一条,但是在内部类的访问控制规范中也是允许这样的语法存在的。那我们通过反射看一下,为什么内部类中这么定义是没有问题的,

public class Test {

    public static void main(String[] args) {

        Field[] fields = FieldOut.FieldIn.class.getDeclaredFields();
        for (Field field : fields) {
            System.out.println(field.getType() + " " + field.getName() + " : " + field.isSynthetic());
        }
    }
}

允许上面测试代码,控制台打印如下:

class java.lang.String inStr : false
class com.example.demo.test.FieldOut this$0 : true

发现我们在上面代码中明明只定义了一个属性字段inStr,怎么通过反射遍历所有的字段还多了一个FieldOut类型的字段this$0,通过isSynthetic()方法我们能知道这个this$0就是由编译器合成的,好像有那么点意思了;那我们再看看类FieldIn通过javac编译生成的class文件,

image-20211108112935556

打开FieldOut$FieldIn.class文件,

package com.example.demo.test;

class FieldOut$FieldIn {
    private String inStr;

    FieldOut$FieldIn(FieldOut var1) {
        this.this$0 = var1;
        this.inStr = this.this$0.outStr;
    }
}

你能看到这里添加了一些代码,通过有参构造器来定义初始化了字段FieldOut this$0,并将this$0.outStr赋值给了inStr,到这里恍然大悟,原来内部类中允许这么编写代码的原理是因为有编译器帮我们进行相应代码的生成添加。

我们还知道除了Filed,还有MethodConstrictor都实现了isSynthetic()方法,同理他们也应该会被编译器进行代码合成,

我们将上面的代码修改,

public class FieldOut {

    private String outStr = "out";

    class FieldIn{
        private String inStr = outStr;
    }
}

其中将字段outStr的访问权限变为了private,上面代码依然没有问题且正常运行,但是这里又有疑问了,虽然编译器会帮我们生成字段this$0,使我们能够访问到outStr,但是根据Java面向对象的三个特性之一的封装,私有属性字段不能直接被外界访问,只能通过公共方法去get和set,所以我们在这里并没有编写字段outStrget方法,FieldIn中又是怎么拿到的呢?

我们再写个测试方法,

public class Test {

    public static void main(String[] args) throws InvocationTargetException, IllegalAccessException {

        Method[] methods = FieldOut.class.getDeclaredMethods();
        for (Method method : methods) {
            System.out.println(method.getReturnType() + " " + method.getName() + " : " + method.isSynthetic());
        }
        
    }
}

这次用来看看类FieldOut里面发生了什么变化,运行控制台打印如下:

class java.lang.String access$000 : true

耶嘿,我们知道我们在类FieldOut中并没有定义任何方法,这个方法是哪来的,肯定和synthetic脱不开关系了,我们再来编译一下,然后看看编译后的FieldIn的class文件,

package com.example.demo.test;

class FieldOut$FieldIn {
    private String inStr;

    FieldOut$FieldIn(FieldOut var1) {
        this.this$0 = var1;
        this.inStr = FieldOut.access$000(this.this$0);
    }
}

access$000()方法是不是很眼熟,原来编译器帮我们在类FieldOut生成了这个方法,然后在类FieldIn中就是通过这个方法获取到outStr的值的,它其实就是编译器帮我们生成的outStr的get方法。

Constrictor呢,我门再将Demo改下,

public class FieldOut {

    private FieldIn fieldIn = new FieldIn();

    class FieldIn{
        private FieldIn(){
        }
    }
}

反射遍历打印Constrictor出现什么呢?同样class文件里面生成的代码又是什么样的呢?相信我们已经知道了答案(感兴趣可以动手测试下),同时经过上面的文档和示例,对synchetic的作用以及原理也已经掌握了。

产生的问题

在上面我们知道synchetic其实就是通过编译器来帮我们解决了内部类中对其外部类的字段或方法访问控制的问题,但是在某些情况下会出现问题,我们来看一看。

基于上面的Demo修改下,Jdk版本依然是1.8,

public class FieldOut {

    public void handle1(){
        new FieldIn().test();
    }

    public void handle2() throws Exception {
        new FieldIn().reflectTest(new FieldOut());
    }

    class FieldIn{

        private void test(){
            hello();
        }

        public void reflectTest(FieldOut fieldOut) throws Exception {
            Method method = fieldOut.getClass().getDeclaredMethod("hello");
            method.invoke(fieldOut);
        }
    }

    private void hello(){
        System.out.println("hello");
    }
}

这里主要在类FieldIn中定义了两个方法:test()方法是正常调用外部类FieldOut私有的hello()方法,reflectTest()方法是通过反射进行调用。我们再写个main函数测试下,

public class Test {

    public static void main(String[] args) throws Exception {

        new FieldOut().handle1();
        new FieldOut().handle2();
    }
}

允许代码,控制台打印如下,

hello
Exception in thread "main" java.lang.IllegalAccessException: class com.example.demo.test.FieldOut$FieldIn cannot access a member of class com.example.demo.test.FieldOut with modifiers "private"
	at java.base/jdk.internal.reflect.Reflection.newIllegalAccessException(Reflection.java:361)
	at java.base/java.lang.reflect.AccessibleObject.checkAccess(AccessibleObject.java:591)
	at java.base/java.lang.reflect.Method.invoke(Method.java:558)
	at com.example.demo.test.FieldOut$FieldIn.reflectTest(FieldOut.java:27)
	at com.example.demo.test.FieldOut.handle2(FieldOut.java:16)
	at com.example.demo.test.Test.main(Test.java:32)

Process finished with exit code 1

发现handle1()方法没有问题,但是handle2()却异常报错了,可能你会想到这里的反射调用并没有添加method.setAccessible(true)来允许访问,加了之后肯定运行没问题。确实如此,但是仔细想想,handle1()方法是可以直接调用外部类的私有方法,那反射是不是也应该可以直接访问,如果必须通过设置允许访问才能访问,那他们就不在一个起跑线了,那不是典型的"双标"嘛。

所以这里其实就有个逻辑上的问题:同是方法调用,但是却出现两种不同的结果,

  • 直接调用:正常运行
  • 反射调用:异常报错

什么是NBAC?

了解上面的问题之后,我们再来看看什么是NBAC,它的全称是Nested Based Access Controll,翻译过来就是基于嵌套的访问控制,这种机制是Jdk 1.11版本才出现的,而它其实在某些方面来说是对Jdk中synthetic的一种完善补充,在编译时也会对之前synthetic的代码合成产生一些影响。

同样是上面问题的Demo,如果你将Jdk的编译版本切换到1.11的话,再运行测试的main方法,handle1()和handle2()方法都是正常打印的。

相应的一些Java API是由Class类定义实现的,源码如下,

    private native Class<?> getNestHost0();

    @CallerSensitive
    public Class<?> getNestHost() {
        if (!this.isPrimitive() && !this.isArray()) {
            Class host;
            try {
                host = this.getNestHost0();
            } catch (LinkageError var3) {
                return this;
            }

            if (host != null && host != this) {
                SecurityManager sm = System.getSecurityManager();
                if (sm != null) {
                    this.checkPackageAccess(sm, ClassLoader.getClassLoader(Reflection.getCallerClass()), true);
                }

                return host;
            } else {
                return this;
            }
        } else {
            return this;
        }
    }

    public boolean isNestmateOf(Class<?> c) {
        if (this == c) {
            return true;
        } else if (!this.isPrimitive() && !this.isArray() && !c.isPrimitive() && !c.isArray()) {
            try {
                return this.getNestHost0() == c.getNestHost0();
            } catch (LinkageError var3) {
                return false;
            }
        } else {
            return false;
        }
    }

    private native Class<?>[] getNestMembers0();

    @CallerSensitive
    public Class<?>[] getNestMembers() {
        if (!this.isPrimitive() && !this.isArray()) {
            Class<?>[] members = this.getNestMembers0();
            if (members.length > 1) {
                SecurityManager sm = System.getSecurityManager();
                if (sm != null) {
                    this.checkPackageAccess(sm, ClassLoader.getClassLoader(Reflection.getCallerClass()), true);
                }
            }

            return members;
        } else {
            return new Class[]{this};
        }
    }

其中有一些是native方法,调用的是底层C++的接口;而每个方法的作用在Jdk 11的API文档中可以查阅,同时在文档中也提到了这是由Java虚拟机规范中的5.4.4 访问控制定义的。它的主要作用就是:通过NestHost属性保存类的宿主类以及嵌套成员类的引用,这样去访问成员类的属性和方法是,就可以直接通过保存的对象引用去访问,某些情况下可以替代synthetic合成代码的方式来进行访问。感兴趣的可以基于上面synchetic中Demo里面的类用Jdk 11重新编译生成class文件,然后进行对比看看,相信结果更加一目了然。


身未动,心已远。

把一件事做到极致就是天分!