sql语句返回行数过多导致[B占爆jvm内存的问题一例

一次大版本上线之后,发现k8s的pod在不停自动重启。看到这种问题,第一反应就是是不是jvm内存被占爆之后oom导致重启。扫了眼gc日志,存在大量的full gc而且内存根本回收不下去:

[Full GC (Allocation Failure) 2018559K->2016225K(2018560K), 4.6366060 secs]

果然是jvm内存被占爆了。接下来就是看下到底是什么东西占了这么多内存,jmap -histo 1 | head -n 20(docker容器中应用进程的pid是1)走起:

num #instances #bytes class name
----------------------------------------------
1: 50953149 1719029856 [B
2: 12554824 851776232 [C
3: 3224844 335384120 [[B
4: 12556565 301357560 java.lang.String
5: 6785718 217142976 java.sql.Timestamp
6: 2164923 207832608 com.XXXX.api.model.PaymentPlan
7: 2457648 98305920 java.math.BigDecimal
8: 3224782 77394768 com.mysql.jdbc.ByteArrayRow
9: 2165292 51967008 java.lang.Long

……

本来以为打出来之后问题会迎刃而解,结果这下彻底懵逼了……怎么会有这么多的[B?这次发版没有新增用到流的地方,压根没地方会出现这么多byte[]啊…一大堆的[[B就更无从解释了,项目中根本不可能有用到byte[][]的地方。

一头雾水中的我仔细观察了下这些对象,发现第8名的com.mysql.jdbc.ByteArrayRow非常可疑,那一大堆byte[]和byte[][],大概都是它包含的吧。点进去看了下,果然:

QQ截图20200604155342

那怎么会有这么多的ByteArrayRow呢……该不会是哪条sql没限制返回数量,导致内存里一大堆sql返回结果吧?于是我们开始排查本次发版新增的sql,看看有没有这种问题。正在我们焦头烂额的检查代码的时候,测试同学在日志中一眼看到了一个不正常的sql:

QQ截图20200604154544

就是你了!只有逻辑删除的限制条件,可不是一下能查出来很多条嘛。仔细过了一遍代码,原来有一个地方在查询的时候,如果值为null就不传递限制条件,所以导致了这个没有限制条件的sql。

使用阿里云polardb自带的慢sql查询平台,也验证了这个问题——这条sql平均返回90万行结果、最大返回160万结果——这么多,内存不爆才有鬼了:

QQ截图20200604155118

发现问题之后,解决起来就很轻松了,赶紧修bug发版吧……还好出现这个问题的项目只是用来发送消息的,不影响主流程——无非是发送消息的时效性受点影响而已。

一次dubbo线程池打满导致dubbo调用全部超时的故障(Thread pool is EXHAUSTED!)

一、故障现象

今早6点到7点之间,线上突然出现大量的dubbo调用超时,且所有服务都存在超时的问题,共出现5次,每次持续1分钟左右,7:10左右最后一次故障出现后,全部服务自行恢复正常。

二、故障定位

发现这一故障后,首先怀疑的是我们应用部署的云服务的k8s集群和vpc是否出现故障,导致dubbo provider和customer间的网络不通,所以dubbo调用全部超时。后经云服务合作方调查,排除这一可能。

排除网络原因后,问题排查重新回到应用本身,查看日志后发现provider在故障时出现大量Thread pool is EXHAUSTED!的报警:

Caused by: java.util.concurrent.RejectedExecutionException: Thread pool is EXHAUSTED! Thread Name: DubboServerHandler-172.20.2.165:23801, Pool Size: 200 (active: 200, core: 200, max: 200, largest: 200), Task: 437931 (completed: 437731), Executor status:(isShutdown:false, isTerminated:false, isTerminating:false), in dubbo://172.20.2.165:23801!

三、故障原因

奇怪的是,当时并非我们的业务高峰期,并发量并不大。业务高峰期都没有把dubbo的线程池打满,为何业务低峰反而出现了这一故障?而且故障竟然在没有人工干预的情况下自动恢复了。

仔细分析customer调用超时的日志,终于发现了问题的原因:大部分调用超时,都是获取微信分享配置信息处理微信公众号消息回调这两个服务,而由于我们调用微信时经常出现微信服务器响应速度极慢的情况,因而调大了这两个服务的超时时间和重试次数,调整成了30秒超时、重试4次。今早6点到7点之间,很可能微信的服务器再次出现了响应速度极慢的情况,导致每次dubbo请求都需要花费4*30=120秒来处理,最终将dubbo线程池打满,殃及其他所有dubbo服务。

四、故障解决

定位了问题,解决起来也就很简单了。这两个服务在业务上并不重要,出现问题时影响仅仅是用户在微信内分享h5时无法显示正常的样式、以及在公众号内回复时得不到响应而已,大可不必为了这样的问题而殃及其他服务,因而我们把这两个服务的超时时间降为6秒、失败后不再重试。

五、总结

处理这一问题的过程中,有几个地方是值得总结的。

第一,dubbo对于线程池打满这种可能引起全部dubbo服务不可用的重要问题,仅仅只打印了WARN级别的报警,这是很值得商榷的,如果打印的是ERROR级别的报警,我们在排查问题时就不会先认为是网路故障从而走弯路了。

第二,dubbo provider在处理请求时,如果处理逻辑中存在http调用,超时时间和重试次数一定要谨慎设定,不重要的业务要最好及时放弃,避免dubbo线程池被打满,从而导致整个项目都无法提供服务。

KafkaProducer.send()在获取metadata时会阻塞主线程

根据Kakfa的文档,KafkaProducer.send()方法是异步的,一直以来我也是这样认为的:

* The {@link #send(ProducerRecord) send()} method is asynchronous. When called it adds the record to a buffer of pending record sends and immediately returns. This allows the producer to batch together individual records for efficiency.

最近我们改为使用阿里云提供的Kafka服务。迁移后发现开发环境中调用了KafkaProducer.send()的方法执行时间有时会变得异常长,排查后发现是KafkaProducer.send()在获取metadata时导致的。

之前使用自己搭建的Kafka集群时,连接速度快,因而没有发现这一情况。改为使用阿里云的Kafka集群后,由于开发环境和Kafka集群不在一个VPC下,需要使用公网地址访问,连接速度较慢(时间长达1~2秒),这一情况才暴露出来。

查看KafkaProducer的代码,发现doSend()方法中,第一步就是调用waitOnMetadata()方法获取topic的metadata信息:

private Future<RecordMetadata> doSend(ProducerRecord<K, V> record, Callback callback) {
    TopicPartition tp = null;

    try {
        KafkaProducer.ClusterAndWaitTime clusterAndWaitTime = this.waitOnMetadata(record.topic(), record.partition(), this.maxBlockTimeMs);
        …………

而waitOnMetadata()方法中使用do-while循环来获取metadata信息,直至获取成功或抛出异常,因而阻塞在此:

private KafkaProducer.ClusterAndWaitTime waitOnMetadata(String topic, Integer partition, long maxWaitMs) throws InterruptedException {
    this.metadata.add(topic);
    Cluster cluster = this.metadata.fetch();
    Integer partitionsCount = cluster.partitionCountForTopic(topic);
    if (partitionsCount == null || partition != null && partition >= partitionsCount) {
        long begin = this.time.milliseconds();
        long remainingWaitMs = maxWaitMs;

        long elapsed;
        do {
            log.trace("Requesting metadata update for topic {}.", topic);
            int version = this.metadata.requestUpdate();
            this.sender.wakeup();

            try {
                this.metadata.awaitUpdate(version, remainingWaitMs);
            } catch (TimeoutException var15) {
                throw new TimeoutException("Failed to update metadata after " + maxWaitMs + " ms.");
            }

            cluster = this.metadata.fetch();
            elapsed = this.time.milliseconds() - begin;
            if (elapsed >= maxWaitMs) {
                throw new TimeoutException("Failed to update metadata after " + maxWaitMs + " ms.");
            }

            if (cluster.unauthorizedTopics().contains(topic)) {
                throw new TopicAuthorizationException(topic);
            }

            remainingWaitMs = maxWaitMs - elapsed;
            partitionsCount = cluster.partitionCountForTopic(topic);
        } while(partitionsCount == null);

        if (partition != null && partition >= partitionsCount) {
            throw new KafkaException(String.format("Invalid partition given with record: %d is not in the range [0...%d).", partition, partitionsCount));
        } else {
            return new KafkaProducer.ClusterAndWaitTime(cluster, elapsed);
        }
    } else {
        return new KafkaProducer.ClusterAndWaitTime(cluster, 0L);
    }
}

解决方法很简单,自己开一个线程来调用KafkaProducer.send()就可以了。只不过这里不得不吐槽一下Kafka的文档,在这点上写的实在是有误导。

Spring MVC项目改为Spring Boot后上传文件MultipartFile为空

前因:

原有一Spring MVC项目,因公司整体改为Kubernetes+Docker的容器化部署方式,而Spring MVC项目制作Docker镜像过于繁琐,故将该项目改为Spring Boot项目方便打包为Docker镜像。

表征:

改造后其他功能全部正常,唯上传文件功能不能正常使用,排查后发现上传文件的Controller接收的MultipartFile为空。

原因:

项目虽已改造为Spring Boot项目,但仍使用xml文件进行配置,也没有修改过这些配置文件,因而仍然在applicationContext.xml中配置了CommonsMultipartResolver Bean。而Spring Boot会自动配置一个MultipartResolver Bean,两者冲突,导致获取MultipartFile为空。

前后端时区不一致导致的问题

出于安全考量,项目前后端在进行数据交互时,对数据进行了签名校验,算法可以归纳为:sign=MD5(要传输的数据+指定的特殊字符串+今天的第一秒)。为了防止前后端时间出现偏差(如前端在前一天23:59:59发送请求,后端在第二天0:0:0接收到请求),后端在校验时,如拼接今天的第一秒校验不通过,则还会尝试拼接昨天的第一秒和前天的第一秒,都不通过时才报错。

这套方法一直运作的很好,直到今天为了测试其他一个问题,修改了客户端的时区——当客户端时区修改后,便无论如何都无法通过校验了。

原因在于,每个时区的“今天的第一秒”的时间戳是不同的,服务器的时区是东八区,一旦客户端不是东八区,两者必然不相同。解决方法倒也很是简单,两边强制用一样的时区就好了——两边都转换为格林尼治时间,或者将客户端的时间转换为东八区时间都可以。

修改表结构前未关闭Druid PreparedStatementCache导致异常的总结

问题现象:

本次需求需要在表中增加一个字段,DBA操作后,系统立刻出现大量报警。查看日志后,发现异常出现在数据库查询阶段:

image2018-11-8_15-31-57

在对报警信息统计后,异常有如下三种:

java.sql.SQLException: 违反协议

java.sql.SQLException: OALL8 处于不一致状态

java.sql.SQLException: Io 异常

重启应用后,异常不再抛出。

 

问题原因:

  1. 为提高访问数据库的效率,开启了druid的PreparedStatementCache(以下简称为PSCache),在修改表结构前未将其关闭。
  2. 修改表结构后,因为未关闭PSCache,其中缓存的PreparedStatement与修改后的表结构不一致,因而抛出异常,其中,java.sql.SQLException: 违反协议是SQL中包含修改了表结构的表时报出的;java.sql.SQLException: OALL8 处于不一致状态是SQL不包含修改了表结构的表时报出的。
  3. 系统中使用的druid版本较低(1.0.21),在PreparedStatement执行出错后,不能将错误的PreparedStatement释放掉,而仍将其放回池子。
  4. 另外,理论上java.sql.SQLException: 违反协议异常会导致整个Connection不可用,druid有一套释放Connection的机制,如果这套机制正常执行,无论错误的PreparedStatement是否被释放,Connection都会被释放,异常也不会一直发生。然而由于低版本druid(1.0.29前)的这套释放Connection机制不兼容系统所配置的低版本Oracle Driver(oracle.jdbc.driver.OracleDriver),Connection未能被释放,最终导致异常一直发生。

 

源码分析:

在1.0.27版本前,closePoolableStatement方法的代码如下:

image2018-11-8_16-35-48

可以看到,druid不关注PreparedStatement在执行时是否出现异常,关闭时都会将它放回PreparedStatementPool中。

在1.0.27版本后,在DruidPooledStatement中增加了一个用于记录exception数量的int,在catch住异常后调用的checkException方法中自增:

image2018-11-8_18-33-14

image2018-11-8_18-27-39

在closePoolableStatement时,若exception数量不等于0,则不将其放回PreparedStatementPool中:

image2018-11-8_18-26-10

一切看起来很美好对不对?然而并不,即使更新了1.0.27版本的druid,如果使用了旧版本的Oracle Driver(oracle.jdbc.driver.OracleDriver),这一问题仍然存在。原因在于java.sql.SQLException: 违反协议(ORA-17401)这一异常是导致Connection无法修复的Fatal异常,应当将Connection也抛弃,然而1.0.27版本的druid存在Bug,仍然将应当抛弃的这个Connection扔回了池子。

仔细阅读源码,在对PreparedStatement执行时的expection进行计数+1后,还调用了DataSource的handleCollectionException方法进行处理:

image2018-11-8_19-57-19

image2018-11-8_19-58-47

而在这个handleCollectionException方法中,会调用ExceptionSorter,判断发生的异常是不是指定错误码的、会导致Connection无法修复的Fatal异常,若是,druid会将这个Connection抛弃掉:

image2018-11-8_20-5-4

而修改表结构导致的java.sql.SQLException: 违反协议(ORA-17401)异常是包含在这一列表中的,理论上应该将这个Connection直接抛弃。然而在1.0.29版本的druid之前,druid在生成ExceptionSorter时只对新版本的Oracle Driver(oracle.jdbc.OracleDriver)指定了对应的ExceptionSorter,对于旧版本的Oracle Driver(oracle.jdbc.driver.OracleDriver)则并未指定,如果使用旧版本的Oracle Driver,就不能获取到这个判断Connection是否需要抛弃的ExceptionSorter,自然也就无法根据错误码将Connection抛弃了:

image2018-11-8_20-19-4

在1.0.29版本后,这段代码中增加了对旧版本的Oracle Driver的兼容,解决了这一问题:

image2018-11-8_20-19-4

 

解决方案:

  1. 执行修改表结构前,务必将相关系统的PSCache关闭。目前使用了PSCache却未提供开关的系统,需增加PSCache的开关。
  2. 升级druid连接池的版本至1.0.29以上,这样即使修改表结构前忘记关闭PSCache,出现异常后,出错的PreparedStatement和Connection也会被直接抛弃,不会一直报错。
  3. 将配置文件中的Oracle Driver统一为新版本的oracle.jdbc.OracleDriver

Java默认不支持AES256的解决方法(java.security.InvalidKeyException: Illegal key size or default parameters)

近日在做微信支付相关的业务,微信支付对部分接口的返回值(如退款结果通知回调接口)做了AES256的加密。然而Java默认是不支持256位的AES的,因而在解密时会报java.security.InvalidKeyException: Illegal key size or default parameters异常。

Oracle做这个限制的原因非常令人无语:美国在《武器出口管制法》中,将一定长度位数以上的加密算法视为武器,因而除非获得国防部和国家安全局的批准,否则禁止出口——即使这种算法是公开的。

在网上搜寻到了解决方法后,让运维对开发、测试环境JDK中的jar包做了替换,本来以为问题就此解决了,在线上环境操作时却发现了新的问题:为什么线上的JDK8里没有要替换的两个jar包?再次搜寻之后才发现,原来对于不同小版本的JDK8,解决方法是不一样的,而网上大部分解决方法只介绍了低版本JDK8的。

因而,在这里贴一下针对不同版本JDK的详细解决方案:

JDK 1.8.0_151前的版本:

JDK中只内置了不支持AES256的jar包,需要手动下载支持AES256的jar包并进行替换:

  1. 下载Java Cryptography Extension (JCE) Unlimited Strength Jurisdiction Policy Files:
    https://www.oracle.com/technetwork/java/javase/downloads/jce8-download-2133166.htmlJDK6、JDK7的下载链接不同,在此一并贴出:
    JDK6:https://www.oracle.com/technetwork/java/javase/downloads/jce-6-download-429243.html
    JDK7:https://www.oracle.com/technetwork/java/javase/downloads/jce-7-download-432124.html
  2. 解压缩,将US_export_policy.jarlocal_policy.jar覆盖JDK安装目录\jre\lib\security\下的同名文件。

JDK 1.8.0_151后的版本:

JDK中内置了两种jar包,但默认启用不支持AES256的,需要修改配置文件以启用支持AES256的jar包:

修改JDK安装目录\jre\lib\security\java.security,将crypto.policy=unlimited这条配置前的注释#号删除:

从:

QQ截图20190426184840

改为:

QQ截图20190426184919

Java字节码操纵框架ASM入门

一、ASM是什么

ASM是一个Java字节码操纵框架,能被用来动态生成新类,或对既有类的内容进行增、删、改。
ASM可以直接产生二进制class文件,也可以在类被加载入Java虚拟机之前动态改变类行为。

二、使用ASM配合JavaAgent,在类被加载入Java虚拟机之前动态改变类

1、实现一个含有premain(String args, Instrumentation inst)方法的类,在premain(String args, Instrumentation inst)方法中,调用Instrumentation.addTransformer(ClassFileTransformer transformer)方法。

package cn.houseyoung.asm;

import java.lang.instrument.Instrumentation;

/**
* Created by houseyoung on 2018/9/13.
*/
public class StartAgent {
    public static void premain(String args, Instrumentation inst) {
        inst.addTransformer(new MyClassFileTransformer());
    }
}

2、实现ClassFileTransformer类及其中的byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer)方法,在该方法中,使用ASM框架对传入的字节数组形式的class文件classfileBuffer进行操纵,返回操纵完毕后的class文件字节数组。

3、在manifest.mf文件中指定premain()方法所在的类:

Premain-Class: cn.houseyoung.asm.StartAgent

4、将包含premain()方法的类打为jar包。

5、用如下方式运行Java程序:

java -javaagent:jar 文件的位置 [= 传入 premain 的参数 ]

 

三、ASM使用的设计模式——访问者模式简介

元素提供accept方法,该方法传入访问者对象,执行访问者提供的visit方法。
访问者提供visit方法,用于访问具体元素,并对之进行操作。
这种模式的优势在于,能在不修改元素的情况下添加对于元素访问的操作,只需要让元素accept一个新的访问者即可。
但在增加/修改元素内容后,所有的访问者类都需要修改相应的visit方法,所以访问者模式并不适合元素类频繁变动的场景,这也是访问者模式自身最大的缺陷。
对于ASM来说,ClassReader元素类来源于Java class,而Java class的格式几乎不变,因而规避了这一缺陷。

四、ASM API三大核心组件

ASM提供了三个基于ClassVisitor API的核心组件,用于生成和变化类:

ClassReader类分析以字节数组形式给出的已编译类,并针对在其accept()方法参数中传送的ClassVisitor实例,调用相应的visitXXX()方法。

ClassWriter类是ClassVisitor抽象类的一个子类,它直接以二进制形式生成编译后的类,它会生成一个字节数组形式的输出,其中包含了已编译类,可以用toByteArray()方法来提取。

ClassVisitor类将它收到的所有方法调用都委托给另一个ClassVisitor类。

时序图:

asm

 

五、ASM中的类信息访问次序

ASM依据对类信息的遍历顺序进而调用不同的visit方法,其访问顺序为:

visit
visitSource?
visitOuterClass?
( visitAnnotation | visitAttribute)*
( visitInnerClass | visitField | visitMethod)*
visitEnd

即,必须先调用visit()方法;然后调用0或1次visitSource()方法;接着调用0或1次visitOuterClass()方法;然后以任意顺序和任意次数(0、1或多次)调用visitAnnotation()和visitAttribute()方法;接着以任意顺序和任意次数(0、1或多次)调用visitInnerClass()、visitField()和visitMethod()方法;最后,调用visitEnd()方法结束对类的访问。

这一顺序不能被打破,例如,不能在visitAttribute()方法前调用visitField()。

六、使用ClassWriter生成新类/新抽象类/新接口

例如如下的一个接口:

package pkg;
public interface Comparable extends Mesurable {
    int LESS = -1;
    int EQUAL = 0;
    int GREATER = 1;
    int compareTo(Object o);
}

可以以如下的方式使用ClassWriter生成:

ClassWriter cw = new ClassWriter(0);
cw.visit(V1_5), ACC_PUBLIC + ACC_ABSTRACT + ACC_INTERFACE, “pkg/Comparable”, null, “java/lang/Object”, new String[] { “pkg/Mesurable” });
cw.visitField(ACC_PUBLIC + ACC_FINAL + ACC_STATIC, “LESS”, “I”, null, new Integer(-1)).visitEnd();
cw.visitField(ACC_PUBLIC + ACC_FINAL + ACC_STATIC, “EQUAL”, “I”, null, new Integer(0)).visitEnd();
cw.visitField(ACC_PUBLIC + ACC_FINAL + ACC_STATIC, “GREATER”, “I”, null, new Integer(1)).visitEnd();
cw.visitMethod(ACC_PUBLIC + ACC_ABSTRACT, “compareTo”, “(Ljava/lang/Object;)I”, null, null).visitEnd();
cw.visitEnd();

 

其中,第二行的visit()方法:

public final void visit(int version, int access, String name, String signature, String superName, String[] interfaces)

第一个参数version指的是Java版本,这里V1_5的常量指的是Java 1.5。

第二个参数access指的是修饰符,这里是一个接口,虽然Java代码中仅声明了public interface,但其隐含的abstract修饰符在这里也要被写出。

第三个参数name指的是类名,已编译类中没有import和package,因而这里必须写完全限定名。另外,在这里需要以Java字节码格式的类型描述符写出。具体Java字节码类型描述符与Java类型的对应,在下一节详细讲解。

第四个参数signature是泛型擦除时使用的,这里不涉及,因而为null。

第五个参数superName指的是父类名,这里是一个接口,虽然Java代码中不需要指定其父类,但其隐含的父类java.lang.Object必须写出。另外,在这里需要以Java字节码格式的类型描述符写出。具体Java字节码类型描述符与Java类型的对应,在下一节详细讲解。

第五个参数interfaces指的是实现/继承的接口名,因为Java支持接口的多实现,因而该参数为一个String数组。另外,在这里需要以Java字节码格式的类型描述符写出。具体Java字节码类型描述符与Java类型的对应,在下一节详细讲解。

第三、四、五行的visitField()方法:

public final FieldVisitor visitField(int access, String name, String desc, String signature, Object value)

第一个参数access指的是修饰符,这里是接口中的变量,虽然Java代码不需要指定,但其隐含的public static final修饰符在这里也要被写出。

第二个参数name指的是变量名。

第三个参数desc指的是类型描述符,I代表Java类型中的int,具体Java字节码类型描述符与Java类型的对应,在下一节详细讲解。

第四个参数signature是泛型擦除时使用的,这里不涉及,因而为null。

第五个参数value指的是变量的值,在Java字节码中,只有static final这种永不会改变的常量字段,才会在这里指定value,其他情况下该字段必须为null。|

第六行的visitMethod()方法:

public final MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions)

第一个参数access指的是修饰符,这里是接口中的方法,虽然Java代码不需要指定,但其隐含的public abstract修饰符在这里也要被写出。

第二个参数name指的是方法名。

第三个参数desc指的是方法描述符,(Ljava/lang/Object;)I代表Java中的返回类型int、入参Object,具体Java字节码方法描述符与Java方法声明的对应,在下一节详细讲解。

第四个参数signature是泛型擦除时使用的,这里不涉及,因而为null。

第五个参数exceptions是异常,这里不涉及,因而为null。

七、Java字节码中的类型描述符与方法描述符

类型描述符与Java类型的对应列表:

Java类型 类型描述符
boolean Z
char C
byte B
short S
int I
float F
long J
double D
Object Ljava/lang/Object;
int[] [I
Object[][] [[Ljava/lang/Object;

方法描述符与Java中的方法声明的对应列表:

Java中的方法声明 方法描述符
void m(int i, float f) (IF)V
int m(Object o) (Ljava/lang/Object;)I
int[] m(int i, String s) (ILjava/lang/String;)[I
Object m(int[] i) ([I)Ljava/lang/Object;

 

八、如何使用自定义的ClassVisitor对类进行操控

1、移除成员

在visitXXX方法中直接return null,不把它继续转发下去。

@Override
public MethodVisitor visitMethod(final int access, final String name, final String desc, final String signature, final String[] exceptions) {
    if (name.equals("XXXXXX")) {
        return null;
    }
    return cv.visitMethod(access, name, desc, signature, exceptions);
}

 

2、增加类成员

首先在这里回顾一下类信息访问次序

visit
visitSource?
visitOuterClass?
( visitAnnotation | visitAttribute)*
( visitInnerClass | visitField | visitMethod)*
visitEnd

因为类信息访问次序的存在,因而只能在visitInnerClass | visitField | visitMethod或者visitEnd中增加类成员。一般选择在visitEnd增加,因为可以保证肯定可以执行,而且只执行一次。但有特殊需求(例如需要计算该类一共有多少个方法)时,也可以选择在visitInnerClass | visitField | visitMethod增加。

增加时,重写visitEnd()方法,在其中调用visitField()、visitMethod()等方法即可。下面的例子是在类中加入public static final String addField = “added field”:

@Override
public void visitEnd() {
    FieldVisitor fv = cv.visitField(Opcodes.ACC_PUBLIC + Opcodes.ACC_STATIC + Opcodes.ACC_FINAL, "addField", "Ljava/lang/String;", null, "added field");
    if (fv != null) {
        fv.visitEnd();
    }
}

 

3、修改类成员

重写visitField()、visitMethod()等方法,在其中使用自己实现的MethodVisitor、FieldVisitor等类对类成员进行操作。

下面的例子是对类中以test开头的方法使用自定义的TestMethodVisitor操作:

@Override
public MethodVisitor visitMethod(final int access, final String name,
final String desc, final String signature, final String[] exceptions) {
    MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
    MethodVisitor wrappedMv = mv;
    if (mv != null) {
        if (name.startsWith("test")) {
            wrappedMv = new TestMethodVisitor(api, mv);
        }
    }

    return wrappedMv;
}

 

九、介绍Java字节码指令

在ASM中新增/修改方法时,需要使用Java字节码指令才可以实现各种操作。
具体的介绍,在《ASM使用指南》中已有详细的介绍,在此不再赘述,只粘贴过来:

字节代码指令由一个标识该指令的操作码和固定数目的参数组成:
操作码是一个无符号字节值——即字节代码名,由助记符号标识。例如,操作码0用助记符号NOP表示,对应于不做任何操作的指令。
参数是静态值,确定了精确的指令行为。它们紧跟在操作码之后给出。比如GOTO标记指令(其操作码的值为167)以一个指明下一条待执行指令的标记作为参数标记。不要将指令参数与指令操作数相混淆:参数值是静态已知的,存储在编译后的代码中,而操作数值来自操作数栈,只有到运行时才能知道。
字节代码指令可以分为两类:一小组指令,设计用来在局部变量和操作数栈之间传送值;其他一些指令仅用于操作数栈:它们从栈中弹出一些值,根据这些值计算一个结果,并将它压回栈中。

ILOAD, LLOAD, FLOAD, DLOADALOAD 指令读取一个局部变量,并将它的值压到操 作数栈中。它们的参数是必须读取的局部变量的索引 i
ILOAD 用于加载一个 booleanbytecharshortint 局部变量。LLOADFLOADDLOAD 分别用于加载 longfloatdouble 值。(LLOADDLOAD 实际加载两个槽 ii+1)。最后,ALOAD 用于加载任意非基元值,即对 象和数组引用。与之对应,ISTORELSTOREFSTOREDSTOREASTORE 指令从操作数栈 中弹出一个值,并将它存储在由其索引 i 指定的局部变量中。
可以看到,xLOAD 和 xSTORE 指令被赋入了类型(事实上,下面将要看出,几乎所有指令 都被赋予了类型)。它用于确保不会执行非法转换。实际上,将一个值存储在局部变量中,然后再以不同类型加载它,是非法的。例如,ISTORE 1 ALOAD 1 序列是非法的——它允许将一个任意内存位置存储在局部变量 1 中,并将这个地址转换为对象引用!但是,如果向一个局部变量中存储一个值,而这个值的类型不同于该局部变量中存储的当前值,却是完全合法的。这意味着一个局部变量的类型,即这个局部变量中所存值的类型可以在方法执行期间发生变化。
上面已经说过,所有其他字节代码指令都仅对操作数栈有效。它们可以划分为以下类别:

这些指令用于处理栈上的值:POP弹出栈顶部的值,DUP压入顶部栈值的一个副本, SWAP 弹出两个值,并按逆序压入它们,等等。

常量 这些指令在操作数栈压入一个常量值:ACONST_NULL压入nullICONST_0压入 int 值 0,FCONST_0 压入 0fDCONST_0 压入 0dBIPUSH b 压入字节值 bSIPUSH s 压入 shortsLDC cst 压入任意 intfloatlongdoubleStringclass1 常量 cst,等等。

算术与逻辑 这些指令从操作数栈弹出数值,合并它们,并将结果压入栈中。它们没有任何参数。xADDxSUBxMULxDIVxREM 对应于+-*/%运算,其中 xILFD 之一。类似地,还有其他对应于<<>>>>>|&^运算的指令,用于 处理intlong值。
类型变换这些指令从栈中弹出一个值,将其转换为另一类型,并将结果压入栈中。它们对应于 Java 中的类型转换表达式。I2F, F2D, L2D 等将数值由一种数值类型转换为另一种类型。CHECKCAST t 将一个引用值转换为类型 t
对象这些指令用于创建对象、锁定它们、检测它们的类型,等等。例如,NEWtype指令将一个 type 类型的新对象压入栈中(其中 type 是一个内部名)。

字段 这些指令读或写一个字段的值。GETFIELD owner name desc 弹出一个对象引用,并压和其 name 字段中的值。PUTFIELD owner name desc 弹出一个值和一个对象引用,并将这个值存储在它的 name 字段中。在这两种情况下,该对象都必须是 owner 类型,它的字段必须为 desc 类型。GETSTATICPUTSTATIC 是类似指令,但用于静态字段。

方法 这些指令调用一个方法或一个构造器。它们弹出值的个数等于其方法参数个数加 1 (用于目标对象),并压回方法调用的结果。INVOKEVIRTUAL owner name desc 调用在类 owner 中定义的 name 方法,其方法描述符为 descINVOKESTATIC 用于静态方法, INVOKESPECIAL 用于私有方法和构造器,INVOKEINTERFACE 用于接口中定义的方法。最后,对于 Java 7 中的类,INVOKEDYNAMIC 用于新动态方法调用机制。

数组 这些指令用于读写数组中的值。xALOAD指令弹出一个索引和一个数组,并压入此索引处数组元素的值。xASTORE 指令弹出一个值、一个索引和一个数组,并将这个值存 储在该数组的这一索引处。这里的 x 可以是 ILFDA,还可以是 BCS

跳转 这些指令无条件地或者在某一条件为真时跳转到一条任意指令。它们用于编译if、 for、do、while、break 和 continue 指令。例如,IFEQ label 从栈中弹出一个 int 值,如果这个值为 0,则跳转到由这个 label 指定的指令处(否则,正常执行下一条指令)。还有许多其他跳转指令,比如 IFNE 或 IFGE。最后,TABLESWITCH 和 LOOKUPSWITCH 对应于 switch Java 指令。

返回 最后,xRETURN和RETURN指令用于终止一个方法的执行,并将其结果返回给调用者。RETURN 用于返回 void 的方法,xRETURN 用于其他方法。

附录、《ASM4使用指南》中文版

ASM4使用指南

BASE64Encoder的坑:自动添加回车符

最近需要把图片Base64之后传到前端,一开始直接用的sun.misc.BASE64Encoder,但发现这个类有点问题,会自动往转换出来的String里添加换行符。无奈之下用replaceAll把\r\n都去掉,这下在本机运行的时候终于没问题了。

等到放到服务器上之后,还是不行。检查一下输出,发现竟然还有换行符……再一查,得,每个平台的换行符还不一样,Windows下是\r\n,Linux下是\n,Mac下是\r。服务器是Linux,难怪还有换行符……

知道了问题,解决起来也简单,一种是把\r\n都去掉,另一种就是干脆把这个倒霉的sun.misc.BASE64Encoder换掉。用Java 1.8的话,直接用自带的java.util.Base64;JDK版本低的话,可以用Apache commons里的Base64类。

Spring AOP不能拦截同一个类中内部的调用

例如,A中的方法a使用Spring AOP插入了一些功能(例如使用声明式事务):

@Transactional(rollbackFor = Exception.class)
public void a() {
 ……
 ……
}

在其他类中调用a方法时,一切正常。但A内部的其他方法调用a方法时,会发现Spring AOP并没有起作用,插入的功能无效了。

解决方法比较简单粗暴,在A内部调用a方法时,获取A的代理对象,然后调用这个代理对象的a方法即可,像这样:

((A) AopContext.currentProxy()).a();