Java“双括号初始化” 的效率?

Set<String> flavors = new HashSet<String>() {{
    add("vanilla");
    add("strawberry");
    add("chocolate");
    add("butter pecan");
}};
Set<String> flavors = Set.of("vanilla", "strawberry", "chocolate", "butter pecan");

答案

这是我对匿名内部类太过迷恋的问题:

2009/05/27  16:35             1,602 DemoApp2$1.class
2009/05/27  16:35             1,976 DemoApp2$10.class
2009/05/27  16:35             1,919 DemoApp2$11.class
2009/05/27  16:35             2,404 DemoApp2$12.class
2009/05/27  16:35             1,197 DemoApp2$13.class

/* snip */

2009/05/27  16:35             1,953 DemoApp2$30.class
2009/05/27  16:35             1,910 DemoApp2$31.class
2009/05/27  16:35             2,007 DemoApp2$32.class
2009/05/27  16:35               926 DemoApp2$33$1$1.class
2009/05/27  16:35             4,104 DemoApp2$33$1.class
2009/05/27  16:35             2,849 DemoApp2$33.class
2009/05/27  16:35               926 DemoApp2$34$1$1.class
2009/05/27  16:35             4,234 DemoApp2$34$1.class
2009/05/27  16:35             2,849 DemoApp2$34.class

/* snip */

2009/05/27  16:35               614 DemoApp2$40.class
2009/05/27  16:35             2,344 DemoApp2$5.class
2009/05/27  16:35             1,551 DemoApp2$6.class
2009/05/27  16:35             1,604 DemoApp2$7.class
2009/05/27  16:35             1,809 DemoApp2$8.class
2009/05/27  16:35             2,022 DemoApp2$9.class

这些都是在创建简单应用程序时生成的类,并且使用了大量匿名内部类 - 每个类都将被编译成一个单独的class文件。

如前所述,“双括号初始化” 是一个带有实例初始化块的匿名内部类,这意味着将为每个 “初始化” 创建一个新类,所有这些通常都是为了创建单个对象。

考虑到 Java 虚拟机在使用它们时将需要读取所有这些类,这可能会导致字节码验证过程中花费一些时间。更不用说增加存储所有那些class文件所需的磁盘空间。

利用双括号初始化似乎有一些开销,所以过分地考虑它可能不是一个好主意。但是正如 Eddie 在评论中指出的那样,不可能绝对确定其影响。


仅供参考,下面是双括号初始化:

List<String> list = new ArrayList<String>() {{
    add("Hello");
    add("World!");
}};

它看起来像 Java 的 “隐藏” 功能,但是它只是对以下内容的重写:

List<String> list = new ArrayList<String>() {

    // Instance initialization block
    {
        add("Hello");
        add("World!");
    }
};

因此,它基本上是一个实例初始化块 ,它是匿名内部类的一部分


约书亚 · 布洛赫(Joshua Bloch)为Project Coin 设计Collection Literals 提案大致如下:

List<Integer> intList = [1, 2, 3, 4];

Set<String> strSet = {"Apple", "Banana", "Cactus"};

Map<String, Integer> truthMap = { "answer" : 42 };

可悲的是,它并没有进入 Java 7 和 8 中,并被无限期搁置。


实验

这是我测试过的简单实验 - 使用元素"Hello""World!"制作 1000 个ArrayList 。使用两种方法通过add方法添加到它们:

方法 1:双括号初始化

List<String> l = new ArrayList<String>() {{
  add("Hello");
  add("World!");
}};

方法 2:实例化一个ArrayListadd

List<String> l = new ArrayList<String>();
l.add("Hello");
l.add("World!");

我创建了一个简单的程序来写出 Java 源文件,以使用以下两种方法执行 1000 次初始化:

测试 1:

class Test1 {
  public static void main(String[] s) {
    long st = System.currentTimeMillis();

    List<String> l0 = new ArrayList<String>() {{
      add("Hello");
      add("World!");
    }};

    List<String> l1 = new ArrayList<String>() {{
      add("Hello");
      add("World!");
    }};

    /* snip */

    List<String> l999 = new ArrayList<String>() {{
      add("Hello");
      add("World!");
    }};

    System.out.println(System.currentTimeMillis() - st);
  }
}

测试 2:

class Test2 {
  public static void main(String[] s) {
    long st = System.currentTimeMillis();

    List<String> l0 = new ArrayList<String>();
    l0.add("Hello");
    l0.add("World!");

    List<String> l1 = new ArrayList<String>();
    l1.add("Hello");
    l1.add("World!");

    /* snip */

    List<String> l999 = new ArrayList<String>();
    l999.add("Hello");
    l999.add("World!");

    System.out.println(System.currentTimeMillis() - st);
  }
}

请注意,使用System.currentTimeMillis检查初始化 1000 ArrayList和扩展ArrayList的 1000 个匿名内部类所花费的时间,因此计时器的分辨率不是很高。在我的 Windows 系统上,分辨率大约为 15-16 毫秒。

两次测试的 10 次运行的结果如下:

Test1 Times (ms)           Test2 Times (ms)
----------------           ----------------
           187                          0
           203                          0
           203                          0
           188                          0
           188                          0
           187                          0
           203                          0
           188                          0
           188                          0
           203                          0

可以看出,双括号初始化的执行时间约为 190 ms。

同时, ArrayList初始化执行时间为 0 ms。当然,应该考虑计时器分辨率,但是很可能在 15 毫秒以下。

因此,这两种方法的执行时间似乎存在明显差异。看来这两种初始化方法确实存在一些开销。

是的,通过编译Test1双括号初始化测试程序生成了 1000 个.class文件。

到目前为止,尚未指出这种方法的一个特性是,因为您创建了内部类,所以整个包含类都在其作用域中捕获。这意味着,只要您的 Set 处于活动状态,它将保留指向包含实例的指针( this$0 ),并防止该对象被垃圾回收,这可能是一个问题。

这以及即使常规的 HashSet 都可以正常工作(甚至更好)的情况下也要首先创建一个新类的事实,这使我不想使用此构造(即使我真的很想语法糖)。

第二个问题:新的 HashSet 必须是实例初始化程序中使用的 “this”…… 任何人都可以阐明该机制吗?我天真地希望 “this” 引用初始化 “flavors” 的对象。

这就是内部类的工作方式。他们得到了自己的this ,但是它们也有指向父实例的指针,因此您也可以在包含对象上调用方法。如果发生命名冲突,则内部类(在您的情况下为 HashSet)优先,但是您也可以在 “this” 前面加上类名,以获取外部方法。

public class Test {

    public void add(Object o) {
    }

    public Set<String> makeSet() {
        return new HashSet<String>() {
            {
              add("hello"); // HashSet
              Test.this.add("hello"); // outer instance 
            }
        };
    }
}

为了清楚地了解正在创建的匿名子类,您还可以在其中定义方法。例如,重写HashSet.add()

public Set<String> makeSet() {
        return new HashSet<String>() {
            {
              add("hello"); // not HashSet anymore ...
            }

            @Override
            boolean add(String s){

            }

        };
    }

每当有人使用双括号初始化时,就会杀死一只小猫。

除了语法非常不寻常且不是真正惯用的(当然,味道是值得商 bat 的)之外,您还在应用程序中不必要地创建了两个重要问题, 我最近在此处详细介绍了这些问题

1. 您正在创建太多匿名类

每次使用双括号初始化时,都会创建一个新类。例如这个例子:

Map source = new HashMap(){{
    put("firstName", "John");
    put("lastName", "Smith");
    put("organizations", new HashMap(){{
        put("0", new HashMap(){{
            put("id", "1234");
        }});
        put("abc", new HashMap(){{
            put("id", "5678");
        }});
    }});
}};

... 将产生以下类:

Test$1$1$1.class
Test$1$1$2.class
Test$1$1.class
Test$1.class
Test.class

这对于您的类加载器来说是相当大的开销 - 一无所有!当然,如果您进行一次,则不会花费太多的初始化时间。但是,如果您在整个企业应用程序中执行此操作 20,000 次…… 所有这些堆内存仅用于一点 “语法糖”?

2. 您可能会造成内存泄漏!

如果您采用上述代码并从一个方法返回该映射,则该方法的调用者可能会毫无疑问地保留无法回收的非常重的资源。考虑以下示例:

public class ReallyHeavyObject {

    // Just to illustrate...
    private int[] tonsOfValues;
    private Resource[] tonsOfResources;

    // This method almost does nothing
    public Map quickHarmlessMethod() {
        Map source = new HashMap(){{
            put("firstName", "John");
            put("lastName", "Smith");
            put("organizations", new HashMap(){{
                put("0", new HashMap(){{
                    put("id", "1234");
                }});
                put("abc", new HashMap(){{
                    put("id", "5678");
                }});
            }});
        }};

        return source;
    }
}

现在,返回的Map将包含对ReallyHeavyObject的封闭实例的ReallyHeavyObject 。您可能不想冒险:

内存泄漏就在这里

图片来自http://blog.jooq.org/2014/12/08/dont-be-clever-the-double-curly-braces-anti-pattern/

3. 您可以假装 Java 有地图文字

为了回答您的实际问题,人们一直在使用这种语法来假装 Java 具有类似于映射文字的内容,类似于现有的数组文字:

String[] array = { "John", "Doe" };
Map map = new HashMap() {{ put("John", "Doe"); }};

某些人可能会从语法上发现这种刺激。

参加以下测试班:

public class Test {
  public void test() {
    Set<String> flavors = new HashSet<String>() {{
        add("vanilla");
        add("strawberry");
        add("chocolate");
        add("butter pecan");
    }};
  }
}

然后反编译类文件,我看到:

public class Test {
  public void test() {
    java.util.Set flavors = new HashSet() {

      final Test this$0;

      {
        this$0 = Test.this;
        super();
        add("vanilla");
        add("strawberry");
        add("chocolate");
        add("butter pecan");
      }
    };
  }
}

对我来说,这看起来效率不高。如果我担心这种性能,我会介绍一下。上面的代码回答了问题 2:您位于内部类的隐式构造函数(和实例初始化程序)中,因此 “ this ” 引用此内部类。

是的,这种语法是晦涩的,但是注释可以澄清晦涩的语法用法。为了阐明语法,大多数人都熟悉静态初始化程序块(JLS 8.7 静态初始化程序):

public class Sample1 {
    private static final String someVar;
    static {
        String temp = null;
        ..... // block of code setting temp
        someVar = temp;
    }
}

您也可以为构造函数使用类似的语法(不带 “ static ” 一词)(JLS 8.6 实例初始化器),尽管我从未在生产代码中看到过这种用法。这是鲜为人知的。

public class Sample2 {
    private final String someVar;

    // This is an instance initializer
    {
        String temp = null;
        ..... // block of code setting temp
        someVar = temp;
    }
}

如果没有默认的构造函数,则{}之间的代码块将由编译器转换为构造函数。考虑到这一点,请解开双括号代码:

public void test() {
  Set<String> flavors = new HashSet<String>() {
      {
        add("vanilla");
        add("strawberry");
        add("chocolate");
        add("butter pecan");
      }
  };
}

最内括号之间的代码块由编译器转换为构造函数。最外面的花括号定界匿名内部类。要使此步骤成为使所有内容都不匿名的最后一步:

public void test() {
  Set<String> flavors = new MyHashSet();
}

class MyHashSet extends HashSet<String>() {
    public MyHashSet() {
        add("vanilla");
        add("strawberry");
        add("chocolate");
        add("butter pecan");
    }
}

出于初始化的目的,我会说没有任何开销(或太小而可以忽略不计)。但是,每一种flavors使用都不会不利于HashSet而是不利于MyHashSet 。这样做的开销可能很小(甚至可以忽略不计)。但是再说一次,在我担心它之前,我先介绍一下它。

再次,对于您的问题 2,以上代码在逻辑上和逻辑上明显等同于双括号初始化,并且在 “ this ” 所指之处显而易见:扩展了HashSet的内部类。

如果您对实例初始化器的详细信息有疑问,请查看JLS文档中的详细信息。

容易泄漏

我决定介入。对性能的影响包括:磁盘操作 + 解压缩(对于 jar),类验证,perm-gen 空间(对于 Sun 的 Hotspot JVM)。但是,最糟糕的是:它容易泄漏。你不能简单地返回。

Set<String> getFlavors(){
  return Collections.unmodifiableSet(flavors)
}

因此,如果集合转义到由不同的类加载器加载的任何其他部分,并且在其中保留了引用,则将泄漏整个类树 + 类加载器。为了避免这种情况,必须复制到 HashMap, new LinkedHashSet(new ArrayList(){{add("xxx);add("yyy");}})不再那么可爱了,我不再使用这种成语了,我自己,就像new LinkedHashSet(Arrays.asList("xxx","YYY"));

加载许多类可能会增加几毫秒的开始时间。如果启动不是那么关键,而您正在查看启动后的类效率,则没有区别。

package vanilla.java.perfeg.doublebracket;

import java.util.*;

/**
 * @author plawrey
 */
public class DoubleBracketMain {
    public static void main(String... args) {
        final List<String> list1 = new ArrayList<String>() {
            {
                add("Hello");
                add("World");
                add("!!!");
            }
        };
        List<String> list2 = new ArrayList<String>(list1);
        Set<String> set1 = new LinkedHashSet<String>() {
            {
                addAll(list1);
            }
        };
        Set<String> set2 = new LinkedHashSet<String>();
        set2.addAll(list1);
        Map<Integer, String> map1 = new LinkedHashMap<Integer, String>() {
            {
                put(1, "one");
                put(2, "two");
                put(3, "three");
            }
        };
        Map<Integer, String> map2 = new LinkedHashMap<Integer, String>();
        map2.putAll(map1);

        for (int i = 0; i < 10; i++) {
            long dbTimes = timeComparison(list1, list1)
                    + timeComparison(set1, set1)
                    + timeComparison(map1.keySet(), map1.keySet())
                    + timeComparison(map1.values(), map1.values());
            long times = timeComparison(list2, list2)
                    + timeComparison(set2, set2)
                    + timeComparison(map2.keySet(), map2.keySet())
                    + timeComparison(map2.values(), map2.values());
            if (i > 0)
                System.out.printf("double braced collections took %,d ns and plain collections took %,d ns%n", dbTimes, times);
        }
    }

    public static long timeComparison(Collection a, Collection b) {
        long start = System.nanoTime();
        int runs = 10000000;
        for (int i = 0; i < runs; i++)
            compareCollections(a, b);
        long rate = (System.nanoTime() - start) / runs;
        return rate;
    }

    public static void compareCollections(Collection a, Collection b) {
        if (!a.equals(b) && a.hashCode() != b.hashCode() && !a.toString().equals(b.toString()))
            throw new AssertionError();
    }
}

版画

double braced collections took 36 ns and plain collections took 36 ns
double braced collections took 34 ns and plain collections took 36 ns
double braced collections took 36 ns and plain collections took 36 ns
double braced collections took 36 ns and plain collections took 36 ns
double braced collections took 36 ns and plain collections took 36 ns
double braced collections took 36 ns and plain collections took 36 ns
double braced collections took 36 ns and plain collections took 36 ns
double braced collections took 36 ns and plain collections took 36 ns
double braced collections took 36 ns and plain collections took 36 ns

要创建集合,可以使用 varargs 工厂方法而不是双括号初始化:

public static Set<T> setOf(T ... elements) {
    return new HashSet<T>(Arrays.asList(elements));
}

Google Collections 库中有许多类似这样的便捷方法,以及许多其他有用的功能。

至于成语的晦涩之处,我一直遇到它,并一直在生产代码中使用它。我会更担心程序员,因为他们习惯用法被允许编写生产代码而感到困惑。

除了效率,我很少发现自己希望在单元测试之外创建声明性集合。我确实相信双括号语法非常易读。

具体实现列表的声明式构造的另一种方法是使用Arrays.asList(T ...)如下所示:

List<String> aList = Arrays.asList("vanilla", "strawberry", "chocolate");

当然,这种方法的局限性在于您无法控制要生成的列表的特定类型。

通常没有什么特别低效的。对于 JVM,您已经创建了一个子类并向其添加构造函数通常并不重要 - 这是使用面向对象语言进行的日常工作。我可以想到在这种情况下可能会造成效率低下的情况(例如,您有一个反复调用的方法,由于该子类而最终混合了不同的类,而普通的传入类完全是可预测的, - 在后一种情况下,JIT 编译器可能会进行第一种不可行的优化。但是,实际上,我认为这很重要。

从是否要使用大量匿名类来 “整理事情” 的角度来看,我会更多地看到这个问题。作为一个粗略的指导,考虑使用该惯用法不要比使用事件处理程序的匿名类多。

在(2)中,您位于对象的构造函数中,因此 “this” 是指您正在构造的对象。这与其他任何构造函数都没有什么不同。

至于(3),我想这实际上取决于谁维护您的代码。如果您事先不知道这一点,那么我建议使用的基准是 “您是否在 JDK 的源代码中看到了这一点?” (在这种情况下,我不记得看到过许多匿名初始化程序,并且在那是匿名类唯一的内容的情况下肯定不会)。在大多数中等规模的项目中,我认为您确实需要您的程序员在某种程度上理解 JDK 源代码,因此那里使用的任何语法或惯用法都是 “公平的游戏”。除此之外,我想说的是,如果您可以控制谁在维护代码,可以注释或避免,就可以对人们进行语法培训。

双括号初始化是不必要的 hack,可能导致内存泄漏和其他问题

没有合理的理由使用此 “技巧”。 Guava 提供了不错的不可变集合 ,其中包括静态工厂和构建器,使您可以使用干净,易读且安全的语法在声明了集合的位置填充集合。

问题中的示例变为:

Set<String> flavors = ImmutableSet.of(
    "vanilla", "strawberry", "chocolate", "butter pecan");

这不仅更短,更容易阅读,而且避免了其他答案中描述的双括号模式带来的众多问题。当然,它的性能类似于直接构造的HashMap ,但是它很危险且容易出错,并且有更好的选择。

每当您发现考虑使用双括号初始化时,都应该重新检查 API 或引入新的 API 以正确解决此问题,而不要利用语法技巧。

现在, Error-Prone 标记此反模式