《Java特种兵》阅读笔记

第一篇——Java功底篇

Posted by Haiming on February 27, 2020

开干。

第一章:看看功底如何

1.1 String的例子,见证下功底

下面看看 equals() 怎么用:

package JavaTeZhongBing;

public class Chapter1_1 {
    public static void main(String[] args) {
        String a = "a" + "b" + 1;
        String b = "ab1";
        System.out.println(a == b);
    }
}

按照之前的想法,是不是a和b两个对象使用equals() 结果为false嘛。

结果为true

为什么呢?

要弄清楚以下4点:

  1. ==是做什么的?
  2. equals是做什么的?
  3. a和b在内存之中是如何安排的?
  4. 编译时优化的方案

1.1.1 关于 ==

==比较内存单元的内容,其实比较的就是一个数字。

在 Java 之中,==来匹配的就是两个内存单元的内容是否相同。

分情况:

  1. 如果是原始类型,直接比较其值
  2. 如果是引用(reference),比较的是引用的值,其可以被理解为对象的“逻辑地址”。

1.1.2 关于 equals()

eqauls()方法,首先在 Object 之中被定义。

    public boolean equals(Object obj) {
        return (this == obj);
    }

可见对于对象,其原始定义之中就是比较对象的地址。

那么这个方法存在的意义是什么呢?就是希望子类可以重写这个方法,实现个性化的对比的功能。比如在String 之中就重写了 equals()方法:

   public boolean equals(Object anObject) {
        if (this == anObject) {
            return true;
        }
        if (anObject instanceof String) {
            String anotherString = (String)anObject;
            int n = value.length;
            if (n == anotherString.value.length) {
                char v1[] = value;
                char v2[] = anotherString.value;
                int i = 0;
                while (n-- != 0) {
                    if (v1[i] != v2[i])
                        return false;
                    i++;
                }
                return true;
            }
        }
        return false;
    }

提供个性化的equals()表达方法,是为了适应多种业务的个性化满足。换句话,如果你想,可以直接强制这个方法返回true,那么比较之后就是和所有参数都相等了。

有的人会问,那hashCode() 是不是也需要重写一下呢?

这里有一个概念辨析:有人会认为 hashCode() 也是用来标识一个对象。这是大错特错。

hashCode()是一个 native 方法,本身返回值默认和System.identityHashCode(object)一致。在通常情况下,值是对象头部的一部分二进制位组成的数字,其虽然具有一定的标识对象的意义,但是绝对不等价于地址也绝对不保证没有冲突

hashCode()的作用,是产生一个可以标识对象的数字。这个数字在什么时候需要呢?比如在HashSet,HashMap之中,基于对象本身产生 key,就可以使用。

hashCode 只能说是标识对象,因此在Hash算法之中可以讲对象相对离散开。但是并不是说hashCode()值是唯一的,所以在Hash算法之中定位到具体链表后,需要进一步循环链表,然后通过equals()来对比值是不是一致的。

换句话,hashCode()是用来快速定位数据,而equals()是用来对比真实值。

下面就hashCode()equals() 两者之间的资源消耗做一个比较:

拿String来说,其至少会在第一次调用 hashCode() 方法的时候遍历所有char[] 数组并且计算hashCode值,所以两个String进行比较至少会遍历两次char[],如果在期间遇到了并发使用hashCode()的情况,可能还会多很多次调用。可是即使耗费了这么多资源,也依旧是没法确定两个对象相等(前面讲过两个完全不同的对象 hashCode 有可能相同。

但是equals的内部是可以自己去设计逻辑,包括可以先从简单的东西还是比较再到比较复杂的结构,或者先对比地址,长度等等方式将不匹配的东西尽快排除。

1.1.3 编译时优化方案

为什么a引用是通过 + 操作来获得对象,b是直接赋值的对象,看起来完全是两个引用,但是最终其指向的是一个内存单元呢?这就是 JVM 之中的“编译时优化”。

是因为编译器在编译代码的时候,就会将"a"+"b"+1 作为"ab1”。原因是这三个值都是常量,在编译器编译的时候会认为这几个值在程序运行的过程之中也会保持不变,所以无需运行时再计算,就会这样优化。

为了提高效率和节约资源,能提前做的事情编译器就有可能会提前做。但是 JVM 只会优化其可以优化的部分,如果在上面的字符串之中出现了变量,那么就不会在编译阶段进行优化。

注意:只要JVM不能确定的情况,比如变量这种,JVM一定不会优化。但绝对不代表只要是常量JVM就会提前进行优化。

小Tip:这也证明了实际上”+” 操作不一定StringBuilder.append()慢,如果是编译时合并那么就会更快,因为在运行的时候是直接获取的,根本不需要再去进行额外的运算。也就是在讨论性能的时候,不要鉴定认为什么慢,什么快,而是要讲究场景的变化。

1.1.4 补充一个例子

package JavaTeZhongBing;

public class Chapter1_1 {
    public static void main(String[] args) {
        String a = "a";
        final String c = "a";

        String b = a + "b";
        String d = c + "b";
        String e = getA()+"b";

        String compare = "ab";
        System.out.println(b==compare);
        System.out.println(d==compare);
        System.out.println(e==compare);
    }

    private static String getA() {
        return "a";
    }
}

其输出是:

false
true
false

下面解释原因:

第一个输出false:

第一个是 b 和 “ab”比较。虽然我们在代码之中看到了整体而言,a的值是不变的。但是由于其并没有用final 修饰,其是可以被改变的。再加上“字节码增强”技术,当代码切入之后,就可能发生改变。所以编译器不可以对其做优化,此时的 + 操作会被变为类似于 StringBuilder.append 的操作。

第二个输出true:

由于 c 的前面有 final 修饰,所以可以认为是不变的量,会在编译阶段被优化。

第三个输出false: 有两点可以解释:一个是此处的 e 的值有一部分是从 getA()拿到的,而getA() 是一个函数,编译器在这个阶段不会去方法内部看看其到底做了什么,因为有可能其是一个递归函数,而递归的深度是不可预测的,哪怕经过了递归,也不能断定其一定返回一个常量,这就造成了无意义的资源消耗。

第二点是就算函数最后返回的是一个常量,那么对常量的引用也肯定是通过实现一份拷贝返回的,这份拷贝却不是final的。

1.1.5 跟 String 较上劲了

先上一段代码:

package JavaTeZhongBing;

public class Chapter1_1 {
    public static void main(String[] args) {
        String a = "a";
        String b = a + "b";
        String c = "ab";
        String d = new String(b);
        System.out.println(b == c);
        System.out.println(c == d);
        System.out.println(c == d.intern());
        System.out.println(b.intern() == d.intern());
    }
}

其返回值是:

false
false
true
true

其类似的地方就不再次解释了,那么后面的两个true 是因为什么原因呢?

其使用了intern 方法,事实上也是因为这个方法才有这样的结果。

intern方法相当于是制造了一个常量池,当调用这个方法时,JVM会在这个常量池之中用equals()方法找到相同的 String,如果存在的话就直接把这个String的地址返回。没有找到的话,会创建等值的字符串,也就是 char[] ,然后再返回这个新创建空间的地址。

那么只要是同样的字符串,调用intern 方法的时候,都会得到常量池之中String 的引用,所以两个字符串通过intern之后是可以匹配的。

1.1.6 intern() / equals()

先贴一下 equals 的代码:

   /**
     * Compares this string to the specified object.  The result is {@code
     * true} if and only if the argument is not {@code null} and is a {@code
     * String} object that represents the same sequence of characters as this
     * object.
     *
     * @param  anObject
     *         The object to compare this {@code String} against
     *
     * @return  {@code true} if the given object represents a {@code String}
     *          equivalent to this string, {@code false} otherwise
     *
     * @see  #compareTo(String)
     * @see  #equalsIgnoreCase(String)
     */
    public boolean equals(Object anObject) {
        if (this == anObject) {
            return true;
        }
        if (anObject instanceof String) {
            String anotherString = (String)anObject;
            int n = value.length;
            if (n == anotherString.value.length) {
                char v1[] = value;
                char v2[] = anotherString.value;
                int i = 0;
                while (n-- != 0) {
                    if (v1[i] != v2[i])
                        return false;
                    i++;
                }
                return true;
            }
        }
        return false;
    }

可以看到其分为下面几个步骤:

  1. 比较这两个对象是否是同一个对象,如果是的话就直接返回true。
  2. 判定传入对象的类型是不是String,不是的话就直接返回 false。
  3. 比较两个对象的长度是否是一致的,不一致的话直接返回 false。
  4. 循环对比两个字符串的 char[] 数组,逐个对比是否一致。不一致的话直接返回false。
  5. 循环结束都没找到不匹配的,最后返回 true。

下面说一下 intern 方法:

intern 在常量池之中,需要挨个对比其值是否相同,也就是逐个调用 equals 方法。而且其需要“保证唯一”,那么需要有锁的介入,效率自然大打折扣。因此,直接使用 intern 对比的效率比 equals 的效率低。但是这并不是说 对比地址 比 equals 要慢一些,其是输在了对比地址之前要先找到地址这个过程上

这并不说明 Intern 一无是处。一个方法被设计出来,一定是有其在工程方面的优势。下面我们思考这样一个场景:

在某些设计的时候需要涉及到很多种数据类型,比如 int, double 等等。那么在管理数据类型的时候,很一般会将其以字符串的形式进行存储。如果在这些数据类型之间进行转换,那么首先要对其进行判断。如果使用循环 equals 方法来获得是否相同,会造成不断的循环,效率很差。

但是这种情况下可以使用 intern 方法。在加载数据类型字典的时候直接就 intern 到内存之中,那么在比较的时候就是 “常量比常量”,对比地址,这样就快速多了。

1.1.7 StringBuilder.append() 和 String “+” 的 PK

之前在编程的过程中,我们应该都试过使用上千次循环加一个计时器来比较二者之间的性能差别,然后得出append() 方法比+ 更好的结论。下面是对这个过程进行详细的剖析和解释,并且驳斥这个比较的方法,得出比较真实的二者性能差距。

首先我们分析 + 操作

分两种情况:

  1. 都是常量,那么会像之前提到过的“编译时优化”,直接得到结果。
  2. 是运行时拼接,这也是我们下面要仔细剖析的一种情况。

如果是运行时拼接,那么其相当于将代码进行下面这种变化:

原始代码:

				String a = "a";
        String b = "b";
        String c = a + b + "f";

经过编译后:

        String a = "a";
        String b = "b";
        StringBuilder temp = new StringBuilder();
        temp.append(a).append(b).append("f");
        String c = temp.toString();

注意,此处实际的场景之中会是 class 文件之中的内容,并不是 java 代码。

如果将String 的 + 操作放在循环之中,那么自然的,在循环体内部就会生成许多的 StringBuilder 对象,并且在执行 append() 之后再调用 toString() 生成一个新的 String 对象。每一个循环都会生成这些对象,这些临时对象会占用大量的内存空间,造成频繁的 GC。

image-20200228163823889

在这个循环过程之中,a 所指向的字符串肯定越来越大,这就意味着垃圾空间越来越大。当这个 a 所指向的字符串达到一定程度之后,肯定会进入Old区域,若所占用的空间达到Old的 1/4, 需要再次分配空间的时候,就可能发生 OOM(Out of Memory),为何是 1/4?先看看 StringBuilder 做了什么。

在循环开始的时候,StringBuilder进行初始化,会先分配一个 StringBuilder对象,这个对象会分配16个长度的 char[] 数组,当发生 append() 操作的时候,如果空间足够,就会继续向后添加元素。

如果空间不够,那么会尝试扩展空间,StringBuilder 扩展的规则是:“基于当前StringBuilder 的 count 值+ 传入字符串的长度”来作为新的 char[] 的参考值。然后将这个参考值和StringBuilder 的 char[] 的长度的2倍来取最大值,也就是其最少也会扩展到原来长度的2倍。

count 值并不是 char[] 的总长度,而是当前 StringBuilder 之中有效元素的个数,或者可以说是 StringBuilder.length() 的值。char[] 数组的总长度可以通过 capacity()得到

image-20200228170808297

那么结合之前说的“循环扩展字符串”,每次扩容最少会扩容2倍。再加上此时的扩容前对象还需要保留,在某些时间点上面需要的是3倍的内存空间,自然 JVM 的 Young 空间的 Suvivor 区域会很快就装不下,要让其进入 Old 区域。

下面讲一下是为何如果一个对象占用 1/4 的空间就有可能发生 OOM:

当 a 所引用的对象占用了 Old 区域的 1/4 空间时,同样会先分配一个 StringBuilder 的对象,初始化的长度还是 16 个长度的 char[] 数组。按照上面的步骤,首先StringBuffer 会先进行append a 的操作,那么按照要么长度加倍要么 count+相应长度,我们选择后者,可以得到其需要占用 1/4 的长度。则目前就已经占用了 1/2 的 Old 空间。

不要忘了我们后面还有 append 随机字符的操作。如果这个随机字符串不是 “”, 那么在这次随机append的时候当前的StringBuilder已经满了,其需要扩容,按照之前的标准,要取原来长度的2倍,则 Old 空间之中仅存的 1/2 空间也被占用了。此时内存直接“撑死”。

释放空间的前提是数据已经拷贝过来。当我们分配一个2倍大小的空间时,数据还没拷贝,所以这个空间就没法释放。

2020.02.28_17.26.59

这里的假设还是所有的 JVM 内存都用来给一个 String,但是在实际生产过程之中这种情况近乎不可能,所以其 OOM 的概率肯定会更高。

我们上面说完了 + 操作,那再回头看看 StringBuilder 是怎么做的?

 StringBuilder builder = new StringBuilder();
        for(...){
            builder.append(random string)
        }

注意,在这个过程之中,并没有任何的新StringBuilder 对象被产生,其扩容的时候始终是2倍扩容,且每次扩容之后都近乎有一半空间是空闲的,其为扩容之前数组的大小(由于每次都 append 一个随机的字符)。

且因祸得福,对象空间越大,扩容的空间也越大,那么越不会进行再次扩容。更不会像之前的例子之中,每次都要新建一个巨大的 StringBuilder,并且很快将其GC,甚至对这个超大的 StringBuilder 做扩容操作。

这种情况之下,只有当其所占的内存区域接近于 Old 区域的 1/3 的时候进行扩容会发生OOM(原始长度1/3,扩容之后的新StringBuilder 是 2/3)。并且,在 StringBuilder.append 过程之中的垃圾内存大部分都是小块的内存,产生的垃圾就是拼接的对象和扩容时候的原来的空间;相对比之下,下一次再发生的 + 操作时,前一次 + 操作的结果就成了垃圾内存,自然垃圾就越来越多,且内在扩容会产生更多的垃圾。

做个总结,不是String 的 + 操作本身慢,而是在大循环之中大量的内存使用使其内存开销变大,然后使得系统需要频繁的GC,而且是更多的full GC,效率才会急剧下降。

回头我们再想想,实际上在业务过程之中,我们对这种几百万次的叠加字符是很少的,如果是在不同的线程之间,那么每个StringBuilder 都会开辟出不同的空间(StringBuilder 本身就是每个线程使用自己的空间,不然就会出现并发问题)。总而言之,如果是少量的小字符串叠加,那么使用append()带来的效率提升不会明显,但是如果是大量的字符串叠加或者是大字符串叠加的情况(这些字符串不是常量字符串),使用 append() 带来的效率提升的确会大一些。

在 JVM 之中,所提倡的是将“这个线程所用的内存”尽快结束,以便让 JVM 认为其是垃圾,在 young 空间就尽可能释放掉,尽量不要使其进入 Old 区域。那么要实现这样的目标,第一个重要的因素是代码跑的足够快,其次就是分配的空间要足够小。

StringBuilder 之中也是要注意的,因为在其内部也可能创建新数组,并且将老数组不断的拷贝到新数组之中。虽然不会像 + 那样每次操作都出现拷贝,但是也会有很多的内存碎片。

想要优化到极致,那么需要知晓每一个细节,比如可以使用StringBuilder(1024) 这种指定其初始化长度的操作。但是越是特定化的需求,越不能适应业务的变化,如果我们append 的字符串长度远远小于 1024, 那么会造成空间的严重浪费。

最后来一个问题:

多个字符串拼接,有的字符串很小,有的字符串很大,那么怎样的顺序是效率最高的呢?

要分情况讨论:

  1. 在小字符串不多,大字符串也不多的情况下,先添加小字符串再添加大字符串的效率最高。原因如下:

    先 append 小字符串,那么append的时候可能就扩展几十个或者几百个字符,几次扩容就可以搞定。而且扩容之中的垃圾都是很小块的,板块小的话,扩容也是比较迅速的,后面 append 大字符串的空间仍然有。但是如果先 append 大字符串,那么在扩容的时候,就会选择扩容(当前的count 值+字符串长度),这个时候是没有任何的空余空间的,那么只要 append 小字符串,就会发生2倍扩容,其浪费的空间会更多。

  2. 在小字符串很多,大字符串不多的情况下,先添加大字符串的效率最高。原因如下:

    先添加大字符串,那么在大字符串被 append 之后,第一次 append 小字符串时,其就会触发2倍扩容,在2倍扩容之后,这个剩余空间可以容纳很多的小字符串,其就可以在接下来的过程之中避免多次扩容。

关于 String 的 + 的补充说明

上面提到了 + 会创建 StringBuilder 对象,然后再操作。其粒度是以一行代码为粒度,即:

String str = a + b + c + d + e;

这就是一行,只会申请一个 StringBuilder 执行多次的 append() 操作,然后将其赋给 str 引用。不是说每一个 + 都申请一个新的 StringBuilder

1.2 一些简单算法,你会如何理解

1.2.1 在一堆数据里面找到 max 和 min

不需要进行完全的重排序,只要两个指针 max 和 min,将其和每个值相比较即可。只要是更大的或者更小的就进行值替换,最后返回。

1.2.2 在100万个数字里面找到最大的10个数字

那就按照同理可得,我们维护一个10个元素的 ArrayList,循环数组,如果这个数字比这10个数字的 ArrayList 的最小值更大,那么就进行替换,将最小的值抛出,将这个数字插入,并且重新进行排序。

如果数字是随机的,随着不断的叠加,这个最小值也会越来越大,那么这10个数字需要再次插入的几率也会小很多。最坏的情况下,也最多是10*100万的操作次数(每一次都需要剔除最小),而不是100万*100万(完全重排序)。

1.2.3 关于排序,实际场景很重要

来一个问题假设:

假设有杂乱无章的数据 200万个,想要排序,且发现这些数据之中 95%以上 是相对均匀的分布于 1~200万 之间的,且重复数据极少,那么在这种情况下应该如何排序?

先来一个无脑的暴力排序:完全重排序,两个 for 循环,那么就是 n^2 级别的复杂度。这可是4万亿,效率太差了,有没有更好的方法呢?

我们可以将数据先分堆再排序,总体分为两步:

  1. 将数据分堆,可以将其分为 2002 堆,中间的2000份是 1~200万之间,每一个堆都是一个连续的区间,比如 [1-1000],[1001-2000] 等等。那么每个区间的数据范围都是一个小堆,且小堆和小堆之间是天然有序的。还有剩下的两堆,一堆是 <1 的情况,一堆是 >200万的情况。那么只要这些堆各自内部是有序的,就是全局有序。

    这个过程需要什么?首先,扫描 200万数据一次,然后对每个数找到其在2002个堆之中的位置。此过程可以采用二分查找算法,因为2^11=2048,所以其最坏情况会查找11次,总体上是 11*100万=1100万。

    这个时候平均每个堆大概是1000条数据(由于数据均匀分布的前提),那么每个堆的排序的最坏时间是 1000*1000=100万,有2002个堆,所以其最后的复杂度就是 100万*2002=20.02亿。这个结果比之前的 4万亿减少了 2000倍。

    这里笔者自己总结一下:本场景的关键点在于“数据均匀分布,且范围大致确定“。没有前者,就会出现某一个堆之中的数据特别多,在进行后面的排序效率极低。没有后者,就没法确定大致的分堆范围,那也就没法分批进行数据的处理了。

    内容扩展: 当拆分成多个块之后,板块内部的排序是彼此隔离的,那么各个板块之间可以并行排序,还可以利用多线程来降低处理时间。

1.2.5 Hash算法的形象概念

Hash 算法,像是知道其内容,就可以知道其在数组之中的大概位置,从而在范围之内查找名称。

在 Java 之中,hash 是使用 HashCode 分布的,在同一个分组之中的数据,其通常是无序的,内部使用一个链表存放。这里的”无序“好像和链表的天然特性有所违背,但是实际上是意味着在链表之中存储的数据不要求其FIFO等等顺序,也不能以其遍历的顺序作为某些顺序的条件。

由于数据会被 Hash 到很多不同的桶上面,要使用 Hash 做大量数据的查找,需要设计合适多的 hash 桶。另外在 HashCode 等的设计之上也要有足够的离散能力,不然退化成链表查找的形式,其O(n)的复杂度就得不偿失了。

1.3 简单数字游戏玩一玩

1.3.1 变量 A,B交换有几种方式?

有以下三种方法:

  1. 定义一个中间变量C,代码例子如下:

     				int c = a;
            a = b;
            b = c;
    

    但是这样会使用C作为中间量,其会造成额外的资源消耗,有没有不单独使用资源的方法呢?

  2. 使用”数字叠加再减回“的方法:

    				A = A + B;
            B = A - B;
            A = A - B;
    

    使用这种方法,就可以避免额外内存空间的消耗。但是有一个问题,那就是 A + B 容易导致越界。

  3. ”异或“运算:(不同为1,相同为0)

    异或之中最有趣的一点是可以通过重复操作来得到自身:例如 A ^ B ^ B,还能得到 A 本身。其相当于是一个从不进位的加法。所以下面的操作:

            A = A ^ B; //得到两者混杂在一起的结果
            B = A ^ B; //将A的值给B
            A = A ^ B; //现在B的值是一开始的A,所以采用这样的操作将Bs赋值给A
    

1.3.2 将无序数据 Hash 到特定的板块

考虑以下的场景:有一个长度 5000 的数组,每个数组下标都是一个 Hash 桶这样存放一个链表,此时有一个数据 A 需要写入。我们希望使用 Hash 的方法,这样可以比较均衡的放入数据。那么要怎么做呢?

第一种方法,我们可以将这个数据 % 5000 就得到下标了。这样在数字不断变化的时候,得到的下标也会不断的变化,如果是负数,要将其采取绝对值再 %5000 来获取下标。

第二种方法,是采用 A&4999 来完成。这样的操作所得到的数据永远会 <=4999, 但是这样的情况之下有一些问题,比如我们使用 10 这个数字作为 & 操作的数,那么10 的 二进制位为 1010,也就意味着 0001, 0100, 0101 这些数字不可能获取到(因为是 & 操作),这种某些数据所得到的 bundle 是空,会导致其分散数据的效果大大下降。

1.3.3 大量判定”是|否“的操作

如果每一个值使用一个单独 int 来存储,那么会发生浪费空间的情况,一个 int 有 32 个 bit,一般会组合一串 bool 值存成一个 int.

但是这种情况下面,在拿出某些值的时候,有人是使用Integer.toBinaryString(int)再从这个字符串之中取到相应的char,再判定这个 char 是0还是1。这种方式的时间复杂度过高。

那么怎样的方法是比较高效的?还记得我们之前在 Modifier 源码解析之中一开始看到的那些变量值吗?

例如:

		/**
     * The {@code int} value representing the {@code public}
     * modifier.
     */
    public static final int PUBLIC           = 0x00000001;

    /**
     * The {@code int} value representing the {@code private}
     * modifier.
     */
    public static final int PRIVATE          = 0x00000002;

    /**
     * The {@code int} value representing the {@code protected}
     * modifier.
     */
    public static final int PROTECTED        = 0x00000004;

那么程序在读取字节码的时候,读取到了一个数字,就会与这种对应的编码值进行 & 按位取与,根据其结果是否为0来得到其类型(由于每一个数都只有一位不是0,因此只要不为0那么一定结果是我们特定的类型,不必担心有几位都是1的情况)。 这种方式还可以打“组合拳”,比如我要判断其是否为 public final static 的,那么就可以使用“或”运算,将三个位置的值都“或” 进去:

final static int PUBLIC_FINAL_STATIC = PUBLIC | FINAL | STATIC

这三个值的二进制并不冲突,所以做“或”运算就代表三者都成立的意思。传参之后按位求与即可。

但是,现在“按位取与”的操作之后不是看其是否为0,而是和这个PUBLIC_FINAL_STATIC比较是否等值。因为三个值都成立,才能说明三个特征都成立。

实际上,笔者认为,之前对于单一类型的比较其值也只有两种情况,一种是原值(例如PRIVATE就是0x00000002),一种是0。实际上,可以看做其是多个值比较的特例,即不是比较是否为0,而是比较是否为原值。

1.3.4 简单的数据转换

数据转换分两种,一种是进制转换,另一种是数字和 byte[] 的等值转换。

先看进制转换:

  1. 将十进制字符转换为“二进制的字符串”,使用 Integer.toBinaryString() 等等来操作。大家注意了,这里边说的是二进制的字符串,数字本身就是二进制存储的(都知道数据底层一定是二进制存储在硬盘上面),但是要在 terminal 上面看到数字的二进制位,所以才转换成字符串来表示。

  2. 将十进制数据转换成“十六进制的字符串”,使用 Integer.toHexString(int) 来操作。类似的,在 float, double, long 之中也有相应的方法存在。

  3. 将其他进制的数据转换成十进制数据,使用 Integer.valueOf(String, int) 来操作。其中第一个是要转换的字符串数据,第二个是进制。比如:

    System.out.println(Integer.valueOf("10",2));

    输出的就是2,因为其将 “10” 当做二进制的数字。

再看数字和 byte[] 之间的转换:

在 java 之中,int 是由4个字节(byte)组成的,在网络上面发送数据时,都是通过 byte 流处理的,所以会发送4个 byte 的内容。但是这4个 byte 是由高到低的顺序排列发送,接收方要反向解析

在 Java 之中可以基于 DataOutputStream ,DataInputStream 的 writeInt(int) 和 readInt(int) 来得到正确的数据。

public final void writeInt(int v) throws IOException{
            out.write((v >>> 24) & 0xFF);
            out.write((v >>> 16) & 0xFF);
            out.write((v >>> 8) & 0xFF);
            out.write((v >>> 0) & 0xFF);
            incCount(4);
        }
        public final void readInt () throws IOException {
            int ch1 = in.read();
            int ch2 = in.read();
            int ch3 = in.read();
            int ch4 = in.read();
            if ((ch1 | ch2 | ch3 | ch4) < 0)
                throw new EOFException();
            return ((ch1 << 24) + (ch2 << 16) + (ch3 << 8) + (ch4 << 0));
        }

如果使用了通道相关的技术,ByteBuffer 则由相关的 API 实现。如果其他语言的 API 希望将这些 byte 交换位置(比如要求低位优先发送),那么就得自己按照 DataXXXStream 的处理方式来处理。

在 DataXXXStream 的类之中,不仅有针对 int 类型的处理,还有对 boolean 等等的处理。并且其还有一个 readFull() 方法,这个方法要求读满一个缓冲区之后再返回数据,其会在内部循环处理。

在 ByteBuffer 的实现类之中,也提供了各种类型的 put,get操作,内部也是通过 byte 转换完成的。

1.3.5 数字太大,long 都存放不下

使用 BigDecimal 来进行存储。

下面是使用 BigDecimal 的一些要注意的坑:

Integer.toBinaryString(b) 之中传入的是 byte, 但是会转型成 int。如果 byte 的第一个位是1 ,那么代表其是负数,会将这个int 的高 24位都变为1。但是很多情况下不需要这24位,所以使用 substring() 。如果是正数,那么输出的字符串会将前面的0去掉。

在其他数字的印证过程之中,有时候会出现高位的整个字节的8位都是0的情况。例如 128,输出的结果就是两个字节的二进制字符串: 0000000011111111 。这里高8位全是0,还要单独拿出一个 byte 来存放的原因就是 128 是正数,而一个字节之中的最高位变成了1,如果直接用 11111111 来标识,其就是一个负数(具体值是 -1),所以需要多一个字节来标识其是正数。

long 的存储能力到底有多大?

先弄一下换算表:

G = 2^30

M = 2^20

K = 2^10

我们先看下 Int 的存储能力有多大?

Integer.MAX_VALUE 的 值是 2147483647, 也就是 2G-1 的大小。

原因是:一个G是2^30,Integer 是由32个bit表示的,那么MAX就是一半,也就是2^30,再减去0这一位,就是 2^30-1

同理可得,对于 long 而言,是使用 64 位表示的,那么其 MAX 就是 (4G*4G/2)-1

1.5 原生态类型

原生态类型,就是 primitive 的类型,比如 boolean, byte, short 之类。

为什么使用 primitive 类型而不是对象呢?

计算机之中的运算基础都来源于简单数字,即便是包装之后的对象 (wrapper),在真正计算的时候也是通过内在的数字完成的。

primitive 对象和包装之后的对象有什么区别呢?之前我们就提到过很多次,Integer 是包装之后的对象,那么其会按照对象的规则存储在堆中,比如 int 对应的就是 Integer 类型的对象。那么线程栈上面只存储引用地址,对象会放在占用资源较多的对象所在的——堆之中。但是对于 primitive 类型,“栈”上面直接存储了值,而不是引用。

下面又开始秀花活了:

 				Integer a = 1;
        Integer b = 1;
        Integer c = 200;
        Integer d = 200;
        out.println(a == b);
        out.println(c == d);

觉得结果不论是 true 或者 false,至少是一样的吧?

嘿嘿:

true
false

懵逼吗?

下面是解释:

在编译阶段

  1. 将原始类型 int 赋值给 Integer 类型:会将原始类型自动编译为 Integer.valueOf(int)
  2. 将 Integer 类型赋值给 int 类型: 会自动调用 intValue() 方法。如果 Integer 对象是空,那么这个时候会在自动拆箱的时候抛出空指针。(后文之中介绍 javap 命令的方法来证明)。

这些赋值操作可能不是那么明显,比如一些集合类的写入,一些对比操作(此处笔者不大懂,看看本书的后文会不会讲解),这就需要我们知道什么时候拆装箱。

就算有了自动拆装箱,那结果应该也是一样的呀?但是为何不同呢?

这是一个 JAVA API 的坑。

先上 Integer.valueOf(int i) 的源码:

 /**
     * Returns an {@code Integer} instance representing the specified
     * {@code int} value.  If a new {@code Integer} instance is not
     * required, this method should generally be used in preference to
     * the constructor {@link #Integer(int)}, as this method is likely
     * to yield significantly better space and time performance by
     * caching frequently requested values.
     *
     * This method will always cache values in the range -128 to 127,
     * inclusive, and may cache other values outside of this range.
     *
     * @param  i an {@code int} value.
     * @return an {@code Integer} instance representing {@code i}.
     * @since  1.5
     */
    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }

其中的 IntegerCache:

private static class IntegerCache {
        static final int low = -128;
        static final int high;
        static final Integer cache[];

        static {
            // high value may be configured by property
            int h = 127;
            String integerCacheHighPropValue =
                sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
            if (integerCacheHighPropValue != null) {
                try {
                    int i = parseInt(integerCacheHighPropValue);
                    i = Math.max(i, 127);
                    // Maximum array size is Integer.MAX_VALUE
                    h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
                } catch( NumberFormatException nfe) {
                    // If the property cannot be parsed into an int, ignore it.
                }
            }
            high = h;

根据上面代码的功能和下面代码的范围,可以知道其值的范围是 [-128,127]的范围内,会直接读取 IntegerCache.cache 之中的值。

但是为什么在代码之中使用 i+128 作为数组的下标呢?

因为数组的下标是从0开始的,但是表示的数字范围是从 -128 开始的,那么加上128 之后才好在代码之中取数组的元素。

那么也就是说,在传入的 int 值是 -128~127 之间的数字,通过 Integer.valueOf(int) 得到的对象就是被 cache 的。对于同一个对象的 cache 自然是一个内存地址,那么第一个输出是 true 就可以解释了。而第二个输出已经不在这个范围了,所以会重新 new Integer,得到的结果就是 false。

这个值也可以自己设置,通过-XX:AutoBoxCacheMax= 在开启的时候设置参数,从而达到目的(这部分看源码就可以了),也可以设置 JVM 的启动参数 -Djava.lang.Integer.IntegerCache.high=200 来间接设置 IntegerCache.high 值。

下面是对于这个功能在工程上面的评价:

这个功能看似是好事,其将部分小值数据通过 cache 的方式进行缓存,节省内存空间。但是有些人在自己测试的时候认为 Integer 可以使用 == 做匹配,而不需要使用 equals() 方法,因为其只是针对1,2,3,4 等数据做测试。如果将这两点混淆,那么在程序发布之后就会出现很奇怪的问题(比如两个大值 Integer 使用 == 比较)。但是 Java API 之中并没有明确的讲出这一点,所以这种“坑”会在开发时候造成很大的困扰。

自动拆装箱还有另外的坑,就是如果不知道自动拆装箱是如何发生的,就会在生产过程之中多出很多无谓的损耗。比如:

  1. 在传递参数的过程之中一会用 Integer,一会用 int,那么就会产生很多的拆装箱操作。每次装箱的过程之中都可能创建一个对象(低版本的 JDK 是没有 cache的,很多数字也不在 cache 的范围之内。

  2. 有些隐藏的装箱操作,比如想用一个 int 类型的值作为 HashMap 的Key,那么在 put() 操作的时候就会自动装箱(原因是Key会被认为是 Object 的,HashMap 要使用对象的 hashCode 方法做离散规则,其会被自动转型成 Integer)。同样的,如果想将基本类型的数据放在 List 之中,那么使用 add() 操作的时候也会触发自动装箱的操作。在这个时候,如果数据取出来之后变成了基本类型(此处触发拆箱操作),再用这个基本类型放到另一个集合类之中,就又会发生装箱操作。

在这些过程之中会隐藏的浪费大量的空间,但是程序员却不知道,这是很危险的。

笔者自己补充:

        Integer g = new Integer(1);
        Integer h = new Integer(1);
        out.println(g == h);

其结果为 false。原因很明显,这里直接使用 new Integer 来使用一块新的内存空间,也都是直接针对对象的操作,和之前说的 cache 没关系。此处不要弄混。

1.5.扩 横向扩展(笔者自定标题)

本章在书中并未单独拎出,但是笔者认为这部分应该单独拎出来,所以单起一节。

通过对 Integer 的了解,下面介绍 Boolean, Byte, Short, Long, Float, Double 等等是否有一样的情况。下面直接给结果:

valueOf()

  1. Boolean 之中的 true 和 false 都是 cache 在内存之中的,不需任何改造。但是 new Boolean() 是另一块空间。
  2. Byte 之中的 256 个值都 cache 在内存之中,但是 new Byte()
  什么值 cache 在内存之中 new 操作是否新建一块空间 备注
Boolean true, false 新建空间  
Byte 256个值全部cache 在内存之中 新建空间  
Short, Long -128~127,且无法调整尺寸    
Float, Double 无cache,要自己cache 操作    

Integer和int 进行互相操作/两个 Integer 做大小的数值比较/ switch case 操作

这个结果目前可以认为是当前虚拟机的设计规范。下面直接给出结果:

  1. 当 Integer 和 int 做类型比较的时候,会将 Integer 转换成 int 类型来比较(其方式为使用 intValue() 方法返回数字)
  2. Integer 做大小比较,比如 >, >=, <, <= 操作的时候, Integer 会进行自动拆箱,就是比较其数字值。
  3. switch 操作之中是选择语句,选择语句之中匹配的时候不能用 equals() ,而是直接使用 ==。在 switch 语句之中,语法层面的case 是没法传入对象的,只能是普通数字,那么为了和这个数字进行比较,程序就会将传入的 Integer 进行自动拆箱。下图是IDE对于case 处使用 Integer报错的例子:

Screenshot 2020-03-06 at 11.36.09 AM

​ 在 JDK 1.7(笔者的 JDK 1.8 之中也存在)之中,支持对 String 对象的 switch case 操作。这其实是语 法糖,在编译后的代码之中还是使用 if-else 实现的, 而且其是通过 equals() 实现的。

  1. 在反射之中,对 Integer 属性不能使用 field.setInt() 和 field.getInt() 操作。

1.5.2 集合类

集合类有很多,从早期的 java.utils 的普通集合类,到现在增加的 java.util.concurrent 包下面的很多集合类。下面就说说集合类的故事:

集合类之中包含了几大类基本接口,而我们最常用的就是 ArrayList, HashMap。

那么我们为什么要使用这两个类最多呢?在使用 ArrayList 的时候是否有 LinkedList, Vector; 在使用 HashMap 的时候是否有想起 TreeMap, HashSet,HashTable;当排序的时候是否想起了 SortedSet 等等。

下面有几个使用 ArrayList 的思考之处:

  1. 在经常做修改操作的列表之中,或者在数组通过下标检索并不是那么多的情况下,是否考虑过使用 LinkedList? 因为 ArrayList 通常始终有些元素是空着的。
  2. 在知道 List 长度的情况下,是否试过使用类似于 ArrayList(128) 这种在初始化的时候带上长度的方式?这样就降低了内存碎皮和内存拷贝的次数。
  3. 当List 过大的时候是否考虑过将其分片,而不是一次性加载到整个内存之中? 很多 OOM 都会在集合之中找到问题。
  4. 常见的框架之中用了集合类? 在什么情况下也会出问题?

另外一个大家经常使用的 hashMap 浪费空间更加严重,其代码之中有一个 0.75 因子,当写入 hashMap 的数据个数(所有元素个数)达到数组长度的0.75 之后,数组会自动扩展1倍,而且还需要做一个 rehash 的操作。这个时候很多桶上的节点也都是空的。

1.6 常见的目录和工具包

在 OpenJDK 之中有了源码之后,就可以从最常用的 API 开始看起。当看到需要使用哪个API的时候,就可以查看是否有相关的 API,并且将其记录下来。例如,对于 List 的使用,有几个问题:

  1. 将 ArrayList 转换成 LinkedList 有什么方法?各自的好和坏?
  2. 将集合类,数组做一次浅拷贝有什么方法?用for 循环还是什么其他方法
  3. 对 List,数组类型做排序使用什么方法?

其实,对集合类和数组操作上,有一些 Java 本身提供的工具类(静态工具方法),分别位于 java.util.Collections, java.util.Arrays 之中。其有非常多的对集合类和数组的操作动作。

场景1:通过常量构造一个 ArrayList 返回

最常见的一种写法:

        List<String> list = new ArrayList<String>();
        list.add("a");
        list.add("b");
        list.add("c");

但是使用工具就可以这样:

List<String> list = Arrays.asList("a","b","c");

场景2:中文拼音排序

先上代码:

package JavaTeZhongBing;

import java.text.Collator;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;

@SuppressWarnings("unchecked")
public class SampleChineseSort {

	@SuppressWarnings("rawtypes")
	private final static Comparator CHINA_COMPARE = Collator.getInstance(java.util.Locale.CHINA);

	public static void main(String []args) {
		sortArray();
		sortList();
		//System.out.println("李四".compareTo("张三"));//前者大于后者,则为正数,否则为负数,相等为0
	}

	
	private static void sortList() {
		List<String>list = Arrays.asList("张三", "李四", "王五");
		Collections.sort(list , CHINA_COMPARE);
		for(String str : list) {
			System.out.println(str);
		}
	}

	private static void sortArray() {
		String[] arr = {"张三", "李四", "王五"};
		Arrays.sort(arr, CHINA_COMPARE);
		for(String str : arr) {
			System.out.println(str);
		}
	}
}

Java 提供了 Comparable 和 Comparator 两个接口,下面介绍一下排序的两种实现方式,其各有利弊:

  1. Comparable: 需要让列表中对象实现方法 compareTo(E) ,返回正数说明当前对象比传入对象大,当前对象会排序靠后。返回0表示相等。返回负数表示当前对象比传入对象小,对象会排序靠前。也就是自己定义一个Java对象的大小关系,Java 就会从小到大排列。如果想要反向排序,就对这个返回值再 “取反”,如果想要按照多个字段排序,这个方法的内部也是可以完成的;

    这个方法不是很灵活,因为一个对象实现接口之后,这个方法就可以固定了。如果想要排序算法不固定,那么就得用 Comparator 来扩展,其独立于被排序的对象单独存在。

  2. Comparator: 其在需要排序的时候,以参数的形式来传递。上面例子里面的 “CHINA_COMPARE”就是一个 Comparator 实例。也可以自己实现一个自定义对象的排序方式来满足特定的需求,比如针对不同排序规则使用不同的 Comparator 实例。