深入浅出了解Java程序中的乱码

p.s.文章Java环境是基于windows平台上的,所有程序建议在命令行验证,原因无它:Eclipse或IDEA在编译或运行时,会默认增加编译、运行时参数,会影响代码效果。最后文章基于本人理解,如有差错或者不足之处,欢迎指正!

一、Java程序乱码踩坑

大家写java程序,肯定或多或少有一些关于字符编码方面的问题,尤其是乱码,今天我们就一个由浅入深,来解析一些我们编写Java程序常遇到的坑和要注意的知识点。

首先,相信大家编写Java程序,都是写完源文件之后,编译运行,这年头加上IDE一些的智能化,相信大家也很少了解Java程序在编译运行过程中到底有些什么内容是跟字符编码有关。在这里,我一个简单的程序来举例。

1
2
3
4
5
6
class TestOne {
    public static void main(String[] args) {
        System.out.println("hello world");
        System.out.println("你好世界");
    }
}

大家写完直接用IDE编译运行一套连招下来,结果哗哗的出现在IDE的控制台界面,完事,这是一种情况,但是有人坚信没有自己手动编译和运行的程序不完整,不用记事本写程序的程序员不是好程序员,这种写完之后在cmd环境中用javac和java命令编译整个源文件然后运行,然后结果傻了。
在这里插入图片描述
明明网上的Java入门教程都是说一套javac和java命令组合下来,然后就是程序完美呈现,虽然这里确实没有任何“毛病”,但是这很明显乱码了,然后网上找回答,说是源文件编码问题,改成Windows平台默认的就好了或者指定编译时的javac的编码参数。根据这些看了一下自己的源文件字符编码格式是UTF-8。
在这里插入图片描述

二、Java编译相关知识点

问题是知道了这些,也不明白上面的那个解释,这里就借用RednaxelaFX大神的解释和一张图来说明:
在这里插入图片描述
从Java源码文件到Java Class文件,中间会经过Java源码编译器(例如javac或ECJ)的编译。也就是说,是Java源码编译器负责将Java源码文件的编码转换为最终的UTF-8。导致乱码的不是Java源码编译器的“编码”(写出UTF-8)的过程,而是“解码”(读入Java源码内容)的过程。

以javac为例,它有一个参数可以指定输入的Java源码文件的编码:

-encoding_encoding_Set the source file encoding name, such as EUC-JP and UTF-8. If -encoding is not specified, the platform default converter is used.

关键在于“如果不指定encoding,则使用平台默认的转换器”。在简体中文的Windows上,平台默认编码会是GBK,那么javac就会默认假定输入的Java源码文件是以GBK编码的。javac能够正确读取文件内容并将其中的字符串以UTF-8输出到Class文件里,就跟自己写个程序以GBK读文件以UTF-8写文件一样。如果实际输入的确实是GBK编码(GBK兼容ASCII编码)的文件,那么一切都会正常。但如果实际输入的是别的编码的文件,例如超过了ASCII范围的UTF-8,那javac读进来的内容就会出问题,就“乱码”了。

另外为了大家更好的理解这里,我们附带下面这样一些知识:

  1. 内码 :简单来说,某种语言运行时,其char和string在内存中的编码方式。
  2. 外码 :除了内码,皆是外码。

要注意的是,源代码编译产生的目标代码文件(可执行文件或class文件)中的编码方式属于外码。

先看一下内码:
JVM中内码采用UTF16。早期,UTF16采用固定长度2字节的方式编码,两个字节可以表示65536种符号(其实真正能表示要比这个少),足以表示当时unicode中所有字符。但是随着unicode中字符的增加,2个字节无法表示所有的字符,UTF16采用了2字节或4字节的方式来完成编码。Java为应对这种情况,考虑到向前兼容的要求,Java用一对char来表示那些需要4字节的字符。所以大家这里也应该理解为什么java中的char是占用两个字节,只不过有些字符需要两个char来表示。

详细了解:
https://docs.oracle.com/javase/tutorial/i18n/text/unicode.html
http://www.zhihu.com/question/27562173

外码:
由上面概念可以知道:Java的源文件和class文件编码方式都属于外码,另外Java源文件是任意字符编码的,这个是可以由我们指定的,就像上面那个记事本的UTF-8,GBK编码等等,而Java的class文件里存储的字符串是UTF-8编码的。(具体是一种modified UTF-8,请参考JVM规范:Chapter 4. The class File Format

String content is encoded in modified UTF-8. Modified UTF-8 strings are encoded so that code point sequences that contain only non-null ASCII characters can be represented using only 1 byte per code point, but all code points in the Unicode codespace can be represented. Modified UTF-8 strings are not null-terminated. The encoding is as follows:
……

看完这些,想必也知道对于这种乱码问题怎么解决,就是两方法:

  1. 改变 .java文件编码格式为Windows上平台默认编码GBK
  2. 指定javac编译命令参数: javac -encoding UTF-8 xx.java

方法一:其实这里javac TestOne.java在Windows默认情况下就是java -encoding GBK TestOne.java
在这里插入图片描述

方法二:(我们一般建议用方法二,原因无它,UTF-8现在多流行你就知道了)
在这里插入图片描述
一般弄到这里,大部分关于编译时的乱码问题基本上大家都清楚了,当然有些甚至javac编译都通不过,如下:
在这里插入图片描述在这里插入图片描述
这些报错很好解释,就是编译.java源文件时,编译器编码参数设置和.java源文件不一样产生的,修改为统一格式就行。这类错误也经常出现在跨平台上:

  • Linux下为UTF-8编码,javac编译gbk编码的java文件时,容易出现“错误: 编码UTF8的不可映射字符”
    解决方法是添加encoding 参数:javac -encoding gbk xxx.java
  • Windows下为GBK编码,javac编译utf-8编码的java文件时,容易出现“错误: 编码GBK的不可映射字符”
    解决方法是添加encoding 参数:javac -encoding utf-8 xxx.java

三、深入理解 jvm的 file.encoding 参数

上面我们讲的主要是编译时指定参数不当不当可能导致程序乱码的产生,前面我们提到了一张图,其实那种图只是java源文件编译然后加载进内存(JVM)运行的过程,至于,JVM中还有一些关于字符编码操作的没有列出来,如IO操作的等等,这些具体就是指Java程序运行过程中存在的一些问题。

重点:下面我讲详细介绍这个file.encoding,网上现在关于这个真的是太杂了,而且都没怎么解释的清楚。它这个是 jvm 的启动参数之一,它是设置用于Java程序的文件的编码格式,它与JVM用到的UTF-16,以及java源文件用到的不同编码格式不一样

可能看到上面都不清楚上面意思,我们通过Java源码来理解:
查找 java 源码,只有四个类调用了 file.encoding 这个属性,分别是:

  1. java.nio.Charset.defaultCharset()
  2. java.net.URLEncoder的静态构造方法, 影响到的方法 java.net.URLEncoder.encode(String):这是Web环境中最常遇到的编码使用
  3. com.sun.org.apache.xml.internal.serializer.Encoding的getMimeEncoding方法: 影响对无编码设置的xml文件的读取
  4. javax.print.DocFlavor类的静态构造方法:影响打印的编码

这个后面三个都好理解,我们着重讲一下前面的第一个,在java.nio.Charset.defaultCharset()源码中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
     * Returns the default charset of this Java virtual machine.
     *
     * <p> The default charset is determined during virtual-machine startup and
     * typically depends upon the locale and charset of the underlying
     * operating system.
     *
     * @return  A charset object for the default charset
     *
     * @since 1.5
     */
public static Charset defaultCharset() {
    if (defaultCharset == null) {
        synchronized (Charset.class) {
            String csn = AccessController.doPrivileged(
                new GetPropertyAction("file.encoding"));
            Charset cs = lookup(csn);
            if (cs != null)
                defaultCharset = cs;
            else
                defaultCharset = forName("UTF-8");
        }
    }
    return defaultCharset;
}

这里我们就可以知道,其实这里就是设置file.encoding 这个属性,如果没有指定就是使用默认的,那么默认的是什么呢?

分析file.encoding 参数默认值

说明: 以下测试中,操作系统编码:GBK,java类文件编码:UTF-8

先看一下未指定启动参数值的情况下输出系统参数file.encoding的值。代码如下:

1
2
3
4
5
public class TestTwo {
    public static void main(String[] args) {
        System.out.println("file.encoding : "+System.getProperty("file.encoding"));
    }
}

执行结果:
在这里插入图片描述
从结果来看,file.encoding的值与操作系统的编码值一致。但是并不能说明file.encoding的默认值由操作系统的编码决定。

需要进一步验证,将操作系统默认编码调整为UTF-8,重新运行得出测试结果:

file.encoding : UTF-8

调整操作系统编码为UTF-8后,file.encoding的值也变为UTF-8。
到这里可以得出结论,file.encoding的默认值由操作系统的当前编码决定。

如果我们设置启动参数:
在这里插入图片描述
所以到这我们就明白了file.encoding这个不自己指定的话,就是用默认平台的编码格式,如果指定的话,就使用我们指定的,他的作用是设置用于Java程序的文件的编码格式。

那么他这个具体是个咋回事,一张图来解释,还是基于之前的那个:
在这里插入图片描述
这张图应该可以完全弄懂file.encoding的意义,前面说过它设置用于Java程序的文件的编码格式,它与JVM用到的UTF-16,以及java源文件用到的不同编码格式不一样。其实简单来说就是设置与JVM IO流操作有关的文件读取的编码和解码形式。我们可以更深入一点,看看它究竟是不是主要用在IO操作中:

分析file.encoding 对字符输入流的影响

无编码设置的字符输入流方法:java.io.InputStreamReader.InputStreamReader(InputStream in)的源码如下:

1
2
3
4
5
6
7
8
9
public InputStreamReader(InputStream in) {
        super(in);
        try {
            sd = StreamDecoder.forInputStreamReader(in, this, (String)null); // ## check lock object
        } catch (UnsupportedEncodingException e) {
            // The default encoding should always be available
            throw new Error(e);
        }
    }

接着看StreamDecoder.forInputStreamReader的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static StreamDecoder forInputStreamReader(InputStream var0, Object var1, String var2) throws UnsupportedEncodingException {
    String var3 = var2;
    if (var2 == null) {
        var3 = Charset.defaultCharset().name();
    }
    try {
        if (Charset.isSupported(var3)) {
            return new StreamDecoder(var0, var1, Charset.forName(var3));
        }
    } catch (IllegalCharsetNameException var5) {
    }
    throw new UnsupportedEncodingException(var3);
}

到这里就发现,如果没有设置编码参数,即上面源码中的if (var2 == null),则又回到了开始说的:Charset.defaultCharset(),获取到的默认编码也就是file.encoding 指定的编码。

还有就是String类的getBytes方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public byte[] getBytes() {
    return StringCoding.encode(value, 0, value.length);
}

// 调用的是下面这个方法:
static byte[] encode(char[] ca, int off, int len) {
    String csn = Charset.defaultCharset().name();
    try {
        // use charset name encode() variant which provides caching.
        return encode(csn, ca, off, len);
    } catch (UnsupportedEncodingException x) {
        warnUnsupportedCharset(csn);
    }
    try {
        return encode("ISO-8859-1", ca, off, len);
    } catch (UnsupportedEncodingException x) {
        // If this code is hit during VM initialization, MessageUtils is
        // the only way we will be able to get any kind of error message.
        MessageUtils.err("ISO-8859-1 charset not available: "
                         + x.toString());
        // If we can not find ISO-8859-1 (a required encoding) then things
        // are seriously wrong with the installation.
        System.exit(1);
        return null;
    }
}

又看到了熟悉的Charset.defaultCharset()方法,获取到的默认编码同样也是file.encoding 指定的编码。

至于那种在这几个方法里自行指定的字符集编码格式的,不就是怕在jvm没有指定file.encoding参数,使用平台默认某些情况下会导致乱码出现而给你提供的暂时性方法嘛,使用就像这样:

1
2
3
4
5
6
7
8
9
10
InputStreamReader isr = new InputStreamReader(new FileInputStream("xxx.txt"));
换成:
InputStreamReader isr = new InputStreamReader(new FileInputStream("yyy.txt"),"UTF-8");

或者是
byte[] b = s.getBytes();
String s2 = new String(b);
换成
byte[] b = s.getBytes("UTF-8");
String s2 = new String(b, "UTF-8");

要我说,这样临时性的弄,不如永久的在jvm启动时指定这个file.encoding参数(现在的IDE就是这样做的,然后临时这些指定编码格式搭配着用,哪里还有什么像tomcat等之类java乱码问题)

另外我们看到在java中,像如果没有指定charset这种情况,比如new String(byte[] bytes), 都会调用Charset.defaultCharset()的方法,但是这里注意源码中defaultCharset是只能被初始化一次,这里还是有点小问题的,在多线程并发调用的时候还是会初始话多次,当然后面都是从cache(lookup的函数)里读出来的,问题也不大。(file.encoding运行时不可被更改,你可以理解为这个参数是个全局参数,而且被缓存了,如果一旦运行时更改了, 可能会造成整个 jvm 里面的程序奔溃)

当我们在改变System.getProperties里的file.encoding 的时候(其实我们一般不建议这么做,上面已经说了很详细),defaultCharset已经被初始化过了,所以不会在调用初始化的代码。所以JVM启动后设置系统配置值System.setProperty("file.encoding", "UTF-8")不会影响到这个字符集的编码。但是比如有这样的场景:我们file.encoding是UTF-8,但是我们需要读取一个GBK文件。这个时候我们就可以通过字符流的构造器InputStreamReader(InputStream in, Charset cs)来指定读取文件内容的编码,这也是刚刚我们说过的。

Charset.defaultCharset()反映了对file.encoding属性的更改,但是核心Java库中需要确定默认字符编码的大多数代码都不使用此机制。这也是为什么我们在JDK源码中找到它使用的地方不多,以后大家只要知道这个一般是用在IO流,尤其是字符流操作中,除此之外,现在核心Java库中需要确定默认字符编码的大多数代码都不使用此机制。我们日常使用过程中编码或解码时,一般做法就是查询file.encoding属性或Charset.defaultCharset()查找当前的默认编码,并使用适当的方法或构造函数重载来指定它。

四、编码知识梳理

最后我们梳理一下,针对这个图做一些解释:
在这里插入图片描述
①、A.java就是一个文本文件(以某种编码格式来存储:UTF-8、GBK、ISO-8859-1等),java编译器要解析这个文本文件并编译生成.class文件。而要想解析它,就必须知道它的编码方式。(javac - encoding charset)

②:以不同编码方式编码的A.java经过Java编译器编译生成了同一个相同的A.class。(字符串以UTF-8格式存储)

③:java虚拟机以二进制字节流的形式加载A.class,读取该字符串并构建String。(JVM中字符串格式为UTF-16)
在运行期呈现在我们控制台或者终端上,GUI界面的都是这种String形式。

④跟JVM的相关IO操作,文件读取,字符串转化字节涉及的内存操作等等,这里是看你产生乱码的

从上图可以理解不管采用哪种格式编码的源文件(.java),只要正确告诉编译器,编译器就会得到正确的结果(.class)。同时只要告诉JVM正确的输入输出流需要的编码格式,JVM总会正确的处理好这些流操作,那么要想不产生乱码要注意两个环节:

告诉编译器(javac -encoding)你的源文件编码格式,适当设置JVM文件里字符串编码方式。(java -Dfile.encoding)

这个就是我们要注意的,懂了这些,你的Java程序以后都不会出现乱码和其他编码问题,至于JVM里面这些UTF-16跟其他编码如果打交道等等,这都不是我们要考虑的,它总是会正确处理好这些事。

五、本文总结

其实文章说这么多,也只是让大家对Java的一些编码乱码问题有个基本了解,现在的IDE一般在编译和运行的过程中都会根据设置默认地帮我们添加上相应的编码参数,这个参数和main方法所在类的编码一致。不需要我们考虑太多。所以可能这些编码乱码问题我们很少遇到,最多也就是在IO操作中遇到一些,所以我写这篇文章的目的就是希望大家以后遇到此类问题能有一些底,知其然更知其所以然。
在这里插入图片描述在这里插入图片描述
这里我没找到javac命令,可能就是像IDEA,eclipse这类IDE编译和运行时一套来了,不过即使没有我们也应该知道它肯定是通过设置帮我们完成了。

file.encoding的值
在这里插入图片描述

文尾附上文章参考:
1、java编译器编码和JVM编码问题?
2、java运行时参数file.encoding和sun.jnu.encoding详解
3、一例 jvm file.encoding 属性引起的 MapReduce/HBase 乱码问题
4、Setting the default Java character encoding