月度归档:2014 年十月

Java Unsafe类实现任意实例浅克隆

了解java并发包或nio底层的都应该知道Unsafe这个类,如并发包的锁,通过Unsafe#park() 和Unsafe#unPark()来实现线程阻塞和恢复运行的,这个类没有公布源码,但是有很多比较有用的方法,它可以直接操作内存,使用的时候务必要谨慎,不小心可能会造成内存泄漏。

实现浅克隆思路
为了表述方便,用S代表要克隆的对象,D表示克隆后的对象,SD表示S的内存地址,DD表示D的内存地址,SIZE表示该对象在内存中的大小。

1,获取原对象的所在的内存地址SD
2,计算原对象在内存中的大小SIZE
3,新分配一块内存,大小为原对象大小SIZE,记录新分配内存的地址DD。
4,从原对象内存地址SD处复制大小为SIZE的内存,复制到DD处
5,DD处的SIZE大小的内存就是原对象的浅克隆对象,强制转换为源对象类型就可以了

unsafe方法介绍
unsafe有很多比较牛逼的方法,刚开始接触可能会感到不可思议,下面介绍几个比较实用的方法。
1,Object allocateInstance(Class aClass)
这个方法是分配一个实例,它的牛逼之处在于,只要是非abstract的类它都能实例化,即使这个类没有public的构造方法,它甚至能绕过各种JVM安全检查,不运行任何构造方法,当你用这个方法初始化类后,通过getClassLoader()方法视图去获取他的classloader会发现它返回的是null。是不是很神奇
2,long objectFieldOffset(Field f)
获取对象属性偏移量
3,int arrayBaseOffset(Class arrayClass)
这个方法是返回一个数组第一个元素的偏移量,这个可以用在获取对象内存地址的时候,因为unsafe没有提供直接获取对象实例内存地址的方法,只有获取通过对象属性偏移量获取属性内存地址的方法,所以我们可以通过构建一个数组对象,通过数组元素偏移量获取元素的内存地址,可以参考后面的代码。
4,long allocateMemory(long bytes)
这个方法是直接分配一个bytes大小的内存,然后返回内存的起始地址。
5,long getLong(Object o, long offset)
获取对象o的偏移量为offset位置的long值,这个long值是该位置后64位二进制的long值,同样的方法还有getInt,getByte等
6, putLong(Object o, long offset, long x);
把long x的2进制值放在对象o的offset偏移量的位置。

offset偏移量:是只相对于另一个地址便宜的byte数;

获取Unsafe实例
Unsafe这个类是在sun.misc包下,构造器是私有的,提供一个getUnsafe()的方法获取单例,仅供java类库的类使用,即只能是BootstrapClassLoader加载器加载的类使用,源码如下:

    private Unsafe() {}

    private static final Unsafe theUnsafe = new Unsafe();

    @CallerSensitive
    public static Unsafe getUnsafe() {
        Class cc = Reflection.getCallerClass();
        if (cc.getClassLoader() != null)
            throw new SecurityException("Unsafe");
        return theUnsafe;
    }

可以通过反射的方式获取它的实例:

        Field theUnsafe = null;
        try {
            theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
            theUnsafe.setAccessible(true);
            unsafe = (Unsafe) theUnsafe.get(null);
        } catch (NoSuchFieldException | IllegalAccessException e) {
            e.printStackTrace();
        }

1,获取原对象的内存地址

unsafe类没有提供直接获取实例对象内存地址的方法,但是可以间接获取,大概思路是:构建一个新对象N,N包含了S对象的引用,只要获取到N对S的引用地址就是S的内存地址了;
unsafe类获取内存地址的方法只有一个,就是getLong(Object o, long offset);为了方便,可以构建一个包含S的Object[]数组,获取到Object[]的S引用就可以了,代码如下:

    private static long getAddr(Object obj) {
        Object[] array = new Object[]{obj};
        long baseOffset = unsafe.arrayBaseOffset(Object[].class);
        return unsafe.getLong(array, baseOffset);
    }

2,计算原对象在内存中的大小
我们新建一个对象实例后,jvm做的其实只是在堆中分配非static的Field的内存,其他的static属性,或者方法在加载期间就已经放到内存中去了,所以当我们计算对象大小时只要计算field的大小就行了,jvm分配内存时单个实例中的每个field内存都是连续的,所以我们只需要获得最大偏移量的Field的偏移量,然后加上这个field的大小就可以了,代码如下:

    public static long sizeOf(Class<?> clazz) {
        long maximumOffset = 0;
        Class<?> maxiNumFieldClass = null;
        do {
            for (Field f : clazz.getDeclaredFields()) {
                if (!Modifier.isStatic(f.getModifiers())) {
                    long tmp = unsafe.objectFieldOffset(f);
                    if(tmp>maximumOffset){
                        maximumOffset = unsafe.objectFieldOffset(f);
                        maxiNumFieldClass = f.getType();
                    }
                }
            }
        } while ((clazz = clazz.getSuperclass()) != null);
        long last = byte.class.equals(maxiNumFieldClass)?1:
                ( short.class.equals(maxiNumFieldClass) || char.class.equals(maxiNumFieldClass))?2:
                        (long.class.equals(maxiNumFieldClass)||double.class.equals(maxiNumFieldClass))?8:4;
        return maximumOffset + last;
    }

3,新分配一块大小为SIZE的内存
Unsafe提供了方法 long allocateMemory(long bytes); 可直接分配一块内存,返回内存地址。

4,从原对象内存地址SD处复制大小为SIZE的内存到DD位置
见Unsafe方法:void copyMemory(long srcAddress, long destAddress, long bytes)

5,DD处的SIZE大小的内存赋值给目标对象
Unsafe没有提供直接读内存转为java对象的方法,但是可以通过类似获取对象内存地址的方法来实现:
先新建一个包含S类型属性的对象,让后把DD的内存地址赋值给S类型的属性变量就可以了:

    private static <T> T fromAddress(long addr, long size) {
        Object[] array = new Object[]{null};
        long baseOffset = unsafe.arrayBaseOffset(Object[].class);
        unsafe.putLong(array, baseOffset, addr);
        return (T) array[0];
    }

克隆方法
如果是数组,这个方法就不适用了,因为数组比较特殊,数组类是jvm在运行时动态生成的,有兴趣可以去研究下jvm对数组的处理。最终的克隆方法代码如下:

    public static <T> T shallowClone(T t) throws InstantiationException {
        Class clazz = t.getClass();
        if(clazz.isArray()){
            Object[] os = (Object[])t;
            return (T)Arrays.copyOf(os,os.length);
        }
        long srcAddr = getAddr(t);
        long size = sizeOf(clazz);
        long destAddr = unsafe.allocateMemory(size);
        unsafe.copyMemory(srcAddr, destAddr, size);
        return fromAddress(destAddr, size);
    }

测试

        Object s = new Foo(8,888L,"test")
        Object s2 = shallowClone(s);
        Assert.assertEquals(s, s2);
        Assert.assertTrue(s != s2);

ps:用Unsafe分配的内存不在jvm管理的范围内,所以,jvm不会自动去回收这一块内存,你得通过Unsafe#freeMemory(long address) 去释放这块的内存。

javamail接收时邮件标题乱码解决方案

通过javamail接收邮件时,获取到Message后,通过Message#getSubject()获取邮件标题时出现中文乱码,如:[�求�� 5068761788:�I家已收到退款],这种情况只有在对方标题内容有繁体字,而且使用的编码是GB2312时就出现,把编码改为GBK后显示正常,这种情况是GB2312没有收录这部分繁体字符而无法显示导致的。

GB2312(1980年)共7445个字符,GBK(1995年)共21886个字符,GBK字符编码向上兼容GB2312,这部分无法显示的繁体字符在GBK收录的范围,因此GBK能正常显示。

查看Email的源码可以看到类似 Subject: =?GB2312?B?1tA=?= 的字符,其中第一个 ?GB2312?是指字符编码,第二个?B?,表示内容的编码方式,如果是B则表示Base64编码,如果是“Q”则代表 Quoted-Printable编码。

javamail在Message#getSubject()的方法中,通过MimeUtility#decodeText()对邮件消息的标题进行解码 ,通过MimeUtility#javaCharset(String charset)这个方法获取真正的字符集编码,代码如下:


public static String javaCharset(String charset) {
 if (mime2java == null || charset == null)
 // no mapping table, or charset parameter is null
 return charset;

String alias =
 (String)mime2java.get(charset.toLowerCase(Locale.ENGLISH));
 return alias == null ? charset : alias;
 }

可以看到最终会通过mime2java这个对象获取编码,mimi2java是一个Hashtable对象,在类加载的时候进行初始化,代码如下:


InputStream is =
 javax.mail.internet.MimeUtility.class.getResourceAsStream(
 "/META-INF/javamail.charset.map");

if (is != null) {
 try {
 is = new LineInputStream(is);

// Load the JDK-to-MIME charset mapping table
 loadMappings((LineInputStream)is, java2mime);

// Load the MIME-to-JDK charset mapping table
 loadMappings((LineInputStream)is, mime2java);

if (java2mime.isEmpty()) {

...

}

if (mime2java.isEmpty()) {

mime2java.put("iso-2022-cn", "ISO2022CN");

......

}

程序会先在”/META-INF/javamail.charset.map”这个文件中加载字符的映射关系,如果没有则添加默认值。到这里已经找到问题的解决办法了。

在项目下添加”/META-INF/javamail.charset.map”这个文件,文件内容为默认的映射关系+GB2312->GBK的映射关系,让程序通过GBK去解码全部GB2312的内容:


---------mimi2java(required)--------------
iso-2022-cn ISO2022CN
iso-2022-kr ISO2022KR
utf-8 UTF8
utf8 UTF8
ja_jp.iso2022-7 ISO2022JP
ja_jp.eucjp EUCJIS
euc-kr KSC5601
euckr KSC5601
us-ascii ISO-8859-1
x-us-ascii ISO-8859-1
gb2312 GBK

ps:这里第一行带有“——“的内容必须有,否则系统会把这些内容赋值给java2mime对象,而不对赋值给mime2java对象,程序是通过”–”标识或者空行来分割的.

每行内容必须用一个tab分开,因为javamail是通过”\t”来分割key和value的.源代码如下:


while (true) {

... ...

if (currLine == null) // end of file, stop
 break;
 if (currLine.startsWith("--") && currLine.endsWith("--"))
 // end of this table
 break;

... ...

StringTokenizer tk = new StringTokenizer(currLine, " \t");

... ...

}