IBM Rational 助您轻松完成自动化功能测试1. 解析自动化测试的困惑在软件工程领域，如果说有一种工作让人在痛苦中感受它的价值、在无休止的加班中体会它的苦涩、在技术的进步中体验它的快乐，那它一定是软件测试。计算机技术发展到今天，自动化测试工具的广泛应用使人们重新认识到测试的源动力：最优的质量成本。软件开发过程中的测试及各种质量保证活动，无疑是在追求软件质量成本和收益间的最佳平衡点。谈到自动化测试，首先我们要明确什么情况下需要自动化。自动化测试的目的是通过自动执行测试脚本，使测试人员在更短的时间内完成更多的测试任务，并提供更高频率的执行能力，从而有效降低测试成本、提高测试效率。从软件测试的成本来看，使用测试工具进行软件自动化测试的成本可以用以下公式表示：自动化测试的成本 ＝ 测试工具成本 ＋ 测试脚本的创建成本 ＋ 测试脚本的维护成本既然自动化测试的目的在于降低测试成本、提高测试效率，因此，测试团队在选择自动化测试工具时，必须在提高测试效率的同时，尽量做到自动化测试的总体成本小于手工测试成本。因此，自动化测试工具的脚本自动化创建能力和可维护性，就成了衡量自动化测试工具的重要因素。在实际的测试工作中，一般说来，我们选择自动化的功能测试工具无外乎要解决以下三个问题：自动化的功能回归测试大批量数据驱动的软件测试整个软件测试生命周期的管理在选择自动化测试工具解决这些问题的过程中，人们主要关心的问题是：使用自动化测试工具创建测试脚本的能力、工具的易用性、测试脚本的编程和扩展能力、测试脚本的参数化技术，以及作为软件开发重要环节的测试工作和其它软件生命周期管理工具的集成能力。因此，摆脱自动化测试困惑的根本途径，就是理解自动化测试的本质，明确自己的自动化测试需求，选择合适的自动化测试工具，帮助测试团队提高效率、降低成本，最终实现软件开发过程的全过程质量保证。2. IBM 自动化功能测试解决方案：Rational Functional TesterIBM Rational Functional Tester（简称 RFT）是一款先进的、自动化的功能和回归测试工具，它适用于测试人员和 GUI 开发人员。使用它，测试新手可以简化复杂的测试任务，很快上手；测试专家能够通过选择工业标准化的脚本语言，实现各种高级定制功能。通过 IBM 的专利技术，例如基于 Wizard 的智能数据驱动软件测试技术、提高测试脚本重用的 ScriptAssurance 技术等等，大大提高了脚本的易用性和可维护能力。同时，它第一次为 Java 和 Web 测试人员提供了和开发人员同样的操作平台（Eclipse），并通过提供与 IBM Rational 整个测试生命周期软件的完美集成，真正实现了一个平台统一整个软件开发团队的能力。3. 使用 IBM RFT 轻松完成自动化功能测试3.1 基于与开发人员同一开发平台的功能测试IBM RFT 的最大特色就是基于开发人员的同一开发平台（Eclipse），为 Java 和 Web 测试人员提供了自动化测试能力。如图 1 所示，使用 RFT 进行软件测试时，我们只要在开发人员工作的 Eclipse 环境中打开 Functional Test 透视图，就会马上拥有专业的自动化功能测试工具所拥有的全部功能。图 1：IBM Rational Functional Test 工作界面在 RFT 中实现测试脚本的过程和大部分的自动化测试工具一样，是基于录制的脚本生成技术。当我们完成测试用例后，只要在功能测试工具条上选择测试脚本录制按钮，就会启动测试用例的脚本实现过程。如图 2 所示，在脚本录制的“选择脚本资产”对话框中，用户可以选择预定义好的公用测试对象图和公用测试数据池，也可以选择在脚本录制过程中生成私有测试对象图和数据池。测试对象图是 IBM 用来解决测试脚本在不同被测版本间成功回放的关键技术，它为测试脚本的重用提供了重要保证；而测试数据池是 IBM 用来实现数据驱动的自动化功能测试的重要手段，使用智能化的数据驱动测试向导，测试脚本的参数化几乎变得易如反掌。图 2：“选择脚本资产”对话框如图 3 所示，在功能测试的录制监视窗口，测试员可以根据提示启动被测应用系统，执行测试用例中规定的测试步骤，实现测试脚本的录制。在测试脚本录制过程中，测试员可以根据需要插入验证点和数据驱动的测试脚本。验证点是在指令中比较实际结果和预期结果的测试点，自动化功能测试工具正是通过它实现对被测系统功能需求的验证。图 3：测试脚本录制窗口完成脚本录制过程以后，RFT 会自动生成用工业标准语言 Java 描述的测试脚本，如下所示：import resources.ThirdwithDatapoolHelper; import com.rational.test.ft.*; import com.rational.test.ft.object.interfaces.*; import com.rational.test.ft.script.*; import com.rational.test.ft.value.*; import com.rational.test.ft.vp.*; /** * Description : Functional Test Script * @author ndejun */ public class ThirdwithDatapool extends ThirdwithDatapoolHelper { /** * Script Name : ThirdwithDatapool * Generated : 2005-4-17 15:22:36 * Description : Functional Test Script * Original Host : WinNT Version 5.1 Build 2600 (S) * * @since 2005/04/17 * @author ndejun */ public void testMain(Object[] args) { startApp("ClassicsJavaB"); // Frame: ClassicsCD classicsJava(ANY,MAY_EXIT).close(); } }基于 Java 的测试脚本，为高级测试人员提供了更强大的编程和定制能力，测试员甚至可以通过在 Helper 类中加入各种客户化脚本，实现各种高级测试功能。3.2 使用 RFT 轻松实现数据驱动的软件测试RFT 具有基于向导（Wizards）的数据驱动的功能测试能力。在功能测试脚本的录制过程中，如图 4 所示，我们可以方便选择被测应用图形界面上的各种被测对象，进行参数化，通过生成新的数据池字段或从数据池中选择已存在数据字段，实现数据驱动的功能回归测试。图 4：数据驱动的功能测试在生成测试脚本的同时，RFT 还能够帮助测试员在验证点中使用正则表达式或使用数据驱动的方法建立动态验证点。动态验证点用来处理普通验证点的期望值随着输入参数不同而发生变化的情况。在下面的例子中，如图 5 所示，订单总金额会随着购买商品数量的不同而变化。通过数据驱动的功能测试方法，测试员首先要对购买的商品数量和订单总金额进行参数化，然后编辑验证点中的期望值，将其用数据池中的对应订单总金额代替。这样验证点中的总金额就随着购买商品数量的不同而得出正确的总金额。通过简单操作、无需任何编程，测试员就可以很方便地实现动态验证点的功能。图 5：生成动态验证点此外，测试员还可以通过在验证点中使用正则表达式，建立更加灵活的验证点，保证测试脚本的重用性。图 6：正则表达式在验证点中的应用3.3 提供多种专利技术，提高脚本的可维护性使用 IBM Rational Functional Test 工具进行 Java 和 Web 应用系统测试时，标准 Java 的测试脚本语言，为测试脚本的可重用性和脚本能力提供了第一层保证。此外，通过维护“测试对象图”，IBM 为测试员提供了不用任何编程就可以实现测试脚本在不同的被测系统版本间的重用能力。“测试对象图”分为两种：一种是公用“测试对象图”，它可以为项目中的所有测试脚本使用；另一种是私有“测试对象图”，它只被某一个管理的测试脚本所使用。在软件开发的不同版本间，开发员会根据系统需求的变化，修改被测系统和用于构建被测系统的各种对象，所以测试脚本在不同的版本间进行回归测试时经常会失败。因此，通过维护公用“测试对象图”，如图 7 所示，测试员可以根据被测应用系统中对象的改变，更新测试对象的属性值及对应权重。这样在不修改测试脚本的前提下，就能使原本会失败的测试脚本回放成功。同时，为了方便测试员对测试对象图的修改和维护，RFT 还提供了强大的查询和查询定制能力，帮助测试脚本维护人员快速找到变化的测试对象，进行修改和维护工作。图 7：测试对象图的维护其次，IBM 提供的 ScriptAssurance 专利技术，使测试员能够从总体上改变工具对测试对象变更的容忍度，在很大程度上提高了脚本的可重用性。ScriptAssurance 技术主要使用以下两个参数：脚本回放时，工具所容忍被测对象差异的最大门值，和用于识别被测对象的属性权重。使用这种技术，测试员可以通过 Eclipse 的首选项设定脚本回放的容错级别，即门值，如图 8 和图 9 所示：图 8：IBM 专利技术：ScriptAssurance 容错级别设定点击“高级”，能够看到各种具体的可接受的识别门值。图 9：ScriptAssurance 门值设定其次，测试员可以根据被测对象实际更改情况，在测试对象图中（如图 7 所示）修改用于回放时识别被测对象的属性及其权重。在测试脚本回访时，测试对象的识别分数将由以下公式计算得出：int score = 0; for ( int i = 0; i < property.length; ++i ) score += (100 - match(property[i])) * weight;其中，match() 将根据属性的符合程度返回 0～100 之间的值，完全符合返回 100，完全不符合返回 0。测试脚本回放成功与否则取决于：识别得分 < 识别门值。通过这一技术，如图 10 所示，通过设置恰当的 ScriptAssurance 门值和为用于识别对象的属性设置合适的权重，即使在两个回归测试的版本间测试对象有多个属性不同，对象仍有可能被正确识别，脚本仍有可能回放成功。这为测试脚本的重用提供了最大程度的灵活性。图 10：ScriptAssurance 技术保证脚本的重用3.4 与其它生命周期管理软件的完美集成IBM Rational 的自动化功能测试工具基于 Eclipse 平台，提供了和需求管理工具（RequisitePro）、建模工具、代码级测试工具和变更及配置管理工具（ClearQuest 和 ClearCase）的完美集成。这使得系统测试人员能够和整个软件开发团队在同一个软件平台上，实现系统功能测试，完成测试脚本的配置管理和缺陷追踪。4. 小结如果一种软件工具能够在提供强健的自动化测试脚本录制和自动化测试能力的同时，很好地解决测试脚本的可维护性、大批量数据驱动的软件测试和整个软件开发生命周期的集成问题，它无疑为降低软件测试的质量成本提供了重要保证。而 IBM Rational Functional Tester 正是这样的工具，它的出现必将使我们的测试工作变得更加高效！5. 参考资料IBM Rational Functional Tester 工具帮助Evaluating Automated Functional Testing Tools by Carey Schwaber and Mike Gilpin说明：本文基于 IBM Rational Functional Tester 早期版本（ circa 2005-2008）编写，部分界面截图与功能特性可能随版本迭代有所变化。文中涉及的 IBM developerWorks 链接为原始出处，供参考查阅。
实验验证为了深入探究 Java 的 ClassLoader 机制，我们先进行以下实验。尝试在 java.lang 包下定义一个类，并访问 String 类的包权限构造函数：package java.lang; public class Test { public static void main(String[] args) { char[] c = "1234567890".toCharArray(); // 尝试访问 String 的 package-private 构造函数 String s = new String(0, 10, c); } }String 类拥有一个包权限（Package-private）的构造函数 String(int offset, int length, char[] array)。按照 Java 默认的访问控制规则，由于 Test 类也属于 java.lang 包，理论上应该可以访问该构造函数。编译可以通过！然而，执行时结果如下：Exception in thread "main" java.lang.SecurityException: Prohibited package name: java.lang at java.lang.ClassLoader.defineClass(Unknown Source) at java.security.SecureClassLoader.defineClass(Unknown Source) at java.net.URLClassLoader.defineClass(Unknown Source) at java.net.URLClassLoader.access$100(Unknown Source) at java.net.URLClassLoader$1.run(Unknown Source) at java.security.AccessController.doPrivileged(Native Method) at java.net.URLClassLoader.findClass(Unknown Source) at java.lang.ClassLoader.loadClass(Unknown Source) at sun.misc.Launcher$AppClassLoader.loadClass(Unknown Source) at java.lang.ClassLoader.loadClass(Unknown Source) at java.lang.ClassLoader.loadClassInternal(Unknown Source)为何会出现这种 SecurityException？要弄清原因，必须深入理解 ClassLoader 的机制。ClassLoader 机制与双亲委派Java 的 ClassLoader 负责动态装载 class 文件。对于一个 class，ClassLoader 只会装载一次。JVM 中常见的 ClassLoader 主要分为以下 4 种，优先级依次从高到低：启动类装载器（Bootstrap ClassLoader）标准扩展类装载器（Extension ClassLoader）类路径装载器（App/System ClassLoader）网络类装载器（Network ClassLoader）这些 ClassLoader 之间使用所谓的“双亲委派模型”（Parent Delegation Model）。确切地说，如果一个网络类装载器被请求装载一个类（例如 java.lang.Integer），它会首先把请求发送给上一级的类路径装载器。如果上级返回已装载，则网络类装载器不会再次装载；只有当上一级返回未装载时，它才会尝试自行装载。类似的，类路径装载器收到请求后（无论是直接请求还是下级上传的请求），也会先把请求发送到上一级的标准扩展类装载器。这样一层一层上传，直到启动类装载器。由于启动类装载器优先级最高，如果它按照自己的方式找到了 java.lang.Integer，则下层的 ClassLoader 都不能再装载该类。这意味着，尽管你自己写了一个 java.lang.Integer 试图取代核心库的类，这是不可能的。因为核心类已由启动类装载器加载，自己写的类根本无法被下层的 ClassLoader 装载。Package 权限与 ClassLoader 身份再说说 Package 权限。Java 语言规定，在同一个包中的 class，如果没有修饰符，默认为 Package 权限，包内的 class 都可以互相访问。但是这还不够准确。确切地说，只有由同一个 ClassLoader 装载的 class 才具有以上的 Package 权限访问能力。例如，启动类装载器装载了 java.lang.String，而类路径装载器装载了我们自己写的 java.lang.Test。尽管它们包名相同，但由于装载它们的 ClassLoader 不同，它们不能互相访问对方具有 Package 权限的方法。这种机制有效阻止了恶意代码通过自定义类访问核心类的包权限方法，保障了 Java 核心库的安全性。说明适用版本：本文实验代码基于 JDK 8 及更早版本。在 JDK 9 及之后版本中，String 类的内部实现发生了变更（如移除了基于 char[] 的特定构造函数），且模块系统（Project Jigsaw）引入了更严格的封装机制。安全限制：禁止用户自定义类使用 java.lang. 包名的安全限制在现代 JDK 中依然有效，但具体的异常堆栈或内部 API（如 loadClassInternal）可能随版本更新而变化。
Java 中的 Stream（流）可以定义为来自源的一系列元素，这些元素支持对它们的聚合操作。这里的“源”是指向流提供数据的 Collection（集合）或 Array（数组）。Stream 保持数据在源中的顺序。聚合操作或批量操作使我们能够轻松、清晰地表达对流元素的共同操作。在继续之前，了解 Java 8 Streams 的设计方式很重要：大多数流操作仅返回流本身。这有助于我们创建流操作链，称为管道化（Pipelining）。在这篇文章中，我将多次使用该术语，请牢记。1. Java 流与集合我们所有人都已经在 YouTube 或其他类似网站上观看过在线视频。当您开始观看视频时，文件的一小部分首先会加载到计算机中并开始播放，无需在下载完整视频之前等待。这称为流式传输。我将尝试将此概念与集合相关联，并区分 Streams 与 Collections。在基本级别上，集合和流之间的差异与计算时机有关：Collection 是一个内存内数据结构，它包含所有当前存在的数据结构。集合中的每个元素在添加到集合之前都必须已被计算出具体的值。Stream 是概念上的固定数据结构，其中的元素按需计算。这带来了显著的编程优势。其核心思想是，用户仅从 Stream 中提取他们需要的值，并且这些元素仅在需要时对用户无形地生成。这是生产者 - 消费者关系的一种形式。在 Java 中，java.util.Stream 表示可以在其上执行一个或多个操作的流。流操作分为中间操作或终端操作。虽然终端操作返回特定类型的结果，但中间操作返回流本身，从而可以在链中调用多个方法。流是在源上创建的，例如列表或集合（不支持映射）之类的 java.util.Collection。流操作可以顺序执行，也可以并行执行。基于以上几点，如果我们列出 Stream 的各种特征，它们将如下所示：不是数据结构专为 Lambda 表达式设计不支持索引访问可以轻松输出为数组或列表支持延迟访问（Lazy Evaluation）可并行化2. 创建流的不同方法以下是从集合构建流的最流行的几种方法。2.1. Stream.of(val1, val2, val3….)public class StreamBuilders { public static void main(String[] args) { Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9); stream.forEach(p -> System.out.println(p)); } }2.2. Stream.of(arrayOfElements)public class StreamBuilders { public static void main(String[] args) { Stream<Integer> stream = Stream.of(new Integer[]{1, 2, 3, 4, 5, 6, 7, 8, 9}); stream.forEach(p -> System.out.println(p)); } }2.3. List.stream()public class StreamBuilders { public static void main(String[] args) { List<Integer> list = new ArrayList<Integer>(); for(int i = 1; i < 10; i++){ list.add(i); } Stream<Integer> stream = list.stream(); stream.forEach(p -> System.out.println(p)); } }2.4. Stream.generate() 或 Stream.iterate()public class StreamBuilders { public static void main(String[] args) { // 注意：generate 生成无限流，实际使用通常需配合 limit() Stream<Date> stream = Stream.generate(() -> { return new Date(); }) .limit(5); stream.forEach(p -> System.out.println(p)); } }2.5. String chars 或 String tokenspublic class StreamBuilders { public static void main(String[] args) { // 获取字符流 (IntStream) IntStream stream = "12345_abcdefg".chars(); stream.forEach(p -> System.out.println(p)); // OR 获取字符串流 Stream<String> streamStr = Stream.of("A$B$C".split("\\$")); streamStr.forEach(p -> System.out.println(p)); } }除了上面列出的方法以外，还有其他一些方法，例如使用 Stream.Builder 或使用中间操作。我们将不时在单独的文章中了解它们。3. 将流转换为集合更准确地说，是将流中的元素收集到其他数据结构中。请注意，这不是真正的“转换”。它只是将流中的元素收集到集合或数组中。3.1. 将流转换为列表 – Stream.collect( Collectors.toList() )public class StreamBuilders { public static void main(String[] args){ List<Integer> list = new ArrayList<Integer>(); for(int i = 1; i < 10; i++){ list.add(i); } Stream<Integer> stream = list.stream(); List<Integer> evenNumbersList = stream.filter(i -> i%2 == 0) .collect(Collectors.toList()); System.out.print(evenNumbersList); } }3.2. 将 Stream 转换为数组 – Stream.toArray(EntryType[] :: new)public class StreamBuilders { public static void main(String[] args){ List<Integer> list = new ArrayList<Integer>(); for(int i = 1; i < 10; i++){ list.add(i); } Stream<Integer> stream = list.stream(); Integer[] evenNumbersArr = stream.filter(i -> i%2 == 0) .toArray(Integer[]::new); System.out.print(Arrays.toString(evenNumbersArr)); } }还有很多其他方法可以将流收集进一个 Set、Map 或者到其他方面。只需熟悉 Collectors 类并记住它们即可。4. 核心流操作流抽象为您提供了一长串有用的功能。我不会覆盖所有内容，但是我打算在这里列出所有最重要的内容，您必须第一时间掌握。在继续之前，让我们预先构建一个 String 集合。我们将在此列表上构建示例，以便易于联系和理解。List<String> memberNames = new ArrayList<>(); memberNames.add("Amitabh"); memberNames.add("Shekhar"); memberNames.add("Aman"); memberNames.add("Rahul"); memberNames.add("Shahrukh"); memberNames.add("Salman"); memberNames.add("Yana"); memberNames.add("Lokesh");这些核心方法分为以下两个部分：4.1. 中间操作 (Intermediate Operations)中间操作返回流本身，因此您可以连续链接多个方法调用。让我们学习重要的操作。4.1.1. Stream.filter()Filter 接受谓词以过滤流中的所有元素。此操作是中间操作，它使我们可以对结果调用另一个流操作（例如 forEach）。memberNames.stream().filter((s) -> s.startsWith("A")) .forEach(System.out::println); // Output: // Amitabh // Aman4.1.2. Stream.map()中间操作 map 通过给定的函数将每个元素转换为另一个对象。下面的示例将每个字符串转换为大写字符串。但是您也可以使用 map() 将每个对象转换为另一种类型。memberNames.stream().filter((s) -> s.startsWith("A")) .map(String::toUpperCase) .forEach(System.out::println); // Output: // AMITABH // AMAN4.1.3. Stream.sorted()Sorted 是一个中间操作，它返回流的排序视图。除非您通过自定义比较器，否则元素将以自然顺序排序。memberNames.stream().sorted() .map(String::toUpperCase) .forEach(System.out::println); // Output: // AMAN // AMITABH // LOKESH // RAHUL // SALMAN // SHAHRUKH // SHEKHAR // YANA请记住，sorted 只会创建流的排序视图，而不会操纵支持的集合的顺序。memberNames 的顺序保持不变。4.2. 终端操作 (Terminal Operations)终端操作返回某种类型的结果，而不是流。4.2.1. Stream.forEach()此方法有助于迭代流的所有元素，并对每个元素执行一些操作。该操作作为 Lambda 表达式参数传递。memberNames.stream().forEach(System.out::println);4.2.2. Stream.collect()collect() 方法用于从流中接收元素并将其存储在集合中，并在参数函数中指定集合类型。List<String> memNamesInUppercase = memberNames.stream().sorted() .map(String::toUpperCase) .collect(Collectors.toList()); System.out.print(memNamesInUppercase); // Output: [AMAN, AMITABH, LOKESH, RAHUL, SALMAN, SHAHRUKH, SHEKHAR, YANA]4.2.3. Stream.match()各种匹配操作可用于检查某个谓词是否与流匹配。所有这些操作都是终端操作，并返回布尔结果。boolean matchedResult = memberNames.stream() .anyMatch((s) -> s.startsWith("A")); System.out.println(matchedResult); matchedResult = memberNames.stream() .allMatch((s) -> s.startsWith("A")); System.out.println(matchedResult); matchedResult = memberNames.stream() .noneMatch((s) -> s.startsWith("A")); System.out.println(matchedResult); // Output: // true // false // false4.2.4. Stream.count()Count 是一个终端操作，返回流中元素的数量作为 long 值。long totalMatched = memberNames.stream() .filter((s) -> s.startsWith("A")) .count(); System.out.println(totalMatched); // Output: 24.2.5. Stream.reduce()此终端操作使用给定功能对流的元素进行归约。结果是一个 Optional 容器，包装了归约后的值。Optional<String> reduced = memberNames.stream() .reduce((s1,s2) -> s1 + "#" + s2); reduced.ifPresent(System.out::println); // Output: Amitabh#Shekhar#Aman#Rahul#Shahrukh#Salman#Yana#Lokesh5. 流短路操作 (Short-circuit Operations)尽管流操作通常对满足谓词的集合内的所有元素执行，但经常需要在迭代过程中遇到匹配元素时中断操作。在外部迭代中，将使用 if-else 块。在内部迭代中，可以使用某些方法来实现此目的。让我们来看两个这样的方法的示例：5.1. Stream.anyMatch()一旦作为谓词传递的条件满足，此方法将返回 true。它将不再处理任何元素（短路）。boolean matched = memberNames.stream() .anyMatch((s) -> s.startsWith("A")); System.out.println(matched); // Output: true5.2. Stream.findFirst()它将从流中返回第一个元素，然后将不再处理任何元素。String firstMatchedName = memberNames.stream() .filter((s) -> s.startsWith("L")) .findFirst().get(); System.out.println(firstMatchedName); // Output: Lokesh6. Java Stream 中的并行性借助 Java SE 7 中添加的 Fork/Join 框架，我们有了有效的机制来在我们的应用程序中实现并行操作。但是，实现此框架本身是一项复杂的任务，如果没有正确执行，那么这是一项艰巨的任务。它是可能导致应用程序崩溃的复杂多线程错误的来源。通过引入内部迭代，我们可以并行进行操作。要启用并行性，您要做的就是创建并行流，而不是顺序流。令您惊讶的是，这确实非常容易。在上面列出的任何流示例中，只要您想在并行内核中使用多个线程来完成特定作业，您都必须调用 parallelStream() 方法而不是 stream() 方法。public class StreamBuilders { public static void main(String[] args){ List<Integer> list = new ArrayList<Integer>(); for(int i = 1; i < 10; i++){ list.add(i); } // 这里创建的是一个并行流 Stream<Integer> stream = list.parallelStream(); Integer[] evenNumbersArr = stream.filter(i -> i%2 == 0) .toArray(Integer[]::new); System.out.print(Arrays.toString(evenNumbersArr)); } }这项工作的主要推动力是使并行性更易于开发人员使用。尽管 Java 平台已经为并发和并行性提供了强大的支持，但是开发人员在根据需要将其代码从顺序迁移到并行时会遇到不必要的障碍。因此，重要的是要鼓励顺序友好和并行友好的习语。通过将重点转移到描述应该执行什么计算而不是应该如何执行计算上，可以方便地进行操作。同样重要的是要在使并行性变得更容易而又不至于使其变得不可见之间取得平衡。使并行性透明将引入不确定性，并可能在用户可能不期望的情况下进行数据竞争。关于 Java 8 中引入的 Stream 抽象的基础，这就是我要分享的全部内容。我将在以后的文章中讨论与 Streams 相关的其他各种事情。说明： 本文基于 Java 8 编写。在 Java 16 及更高版本中，Stream.toList() 方法已被引入作为 Collectors.toList() 的便捷替代，且返回不可修改的列表。此外，并行流的性能表现高度依赖于具体场景和数据量，使用时需谨慎评估。
Java深度历险（二）——Java类的加载、链接和初始化
在 上一篇文章 中，我们介绍了 Java 字节码的操纵，其中提到了利用 Java 类加载器来加载修改后的字节码并在 JVM 上执行。本文接着上一篇的话题，深入讨论 Java 类的加载、链接和初始化。Java 字节码的表现形式为字节数组（byte[]），而 Java 类在 JVM 中的表现形式则是 java.lang.Class 类的对象。一个 Java 类从字节码到能够在 JVM 中被使用，需要经过加载、链接和初始化这三个步骤。在这三个步骤中，对开发人员直接可见的是 Java 类的加载，通过使用 Java 类加载器（Class Loader）可以在运行时刻动态地加载一个 Java 类；而链接和初始化则是在使用 Java 类之前会发生的动作。本文将详细介绍 Java 类的加载、链接和初始化的过程。Java 类的加载Java 类的加载是由类加载器来完成的。一般来说，类加载器分成两类：启动类加载器（Bootstrap Class Loader）和用户自定义类加载器（User-defined Class Loader）。两者的区别在于：启动类加载器是由 JVM 的原生代码实现的，而用户自定义的类加载器都继承自 Java 中的 java.lang.ClassLoader 类。在用户自定义类加载器的部分，一般 JVM 都会提供一些基本实现。应用程序的开发人员也可以根据需要编写自己的类加载器。JVM 中最常使用的是系统类加载器（System Class Loader），它用来启动 Java 应用程序的加载。通过 java.lang.ClassLoader 的 getSystemClassLoader() 方法可以获取到该类加载器对象。类加载器需要完成的最终功能是定义一个 Java 类，即把 Java 字节代码转换成 JVM 中的 java.lang.Class 类的对象。但是类加载的过程并不是这么简单。Java 类加载器有两个比较重要的特征：层次组织结构和代理模式。层次组织结构：指的是每个类加载器都有一个父类加载器，通过 getParent() 方法可以获取到。类加载器通过这种父 - 子的方式组织在一起，形成树状层次结构。代理模式：指的是一个类加载器既可以自己完成 Java 类的定义工作，也可以代理给其它的类加载器来完成。由于代理模式的存在，启动一个类的加载过程的类加载器和最终定义这个类的类加载器可能并不是一个。前者称为初始类加载器，而后者称为定义类加载器。两者的关联在于：一个 Java 类的定义类加载器是该类所导入的其它 Java 类的初始类加载器。比如类 A 通过 import 导入了类 B，那么由类 A 的定义类加载器负责启动类 B 的加载过程。一般的类加载器在尝试自己去加载某个 Java 类之前，会首先代理给其父类加载器。当父类加载器找不到的时候，才会尝试自己加载。这个逻辑是封装在 java.lang.ClassLoader 类的 loadClass() 方法中的。一般来说，父类优先的策略就足够好了。在某些情况下，可能需要采取相反的策略，即先尝试自己加载，找不到的时候再代理给父类加载器。这种做法在 Java 的 Web 容器中比较常见，也是 Servlet 规范 推荐的做法。比如，Apache Tomcat 为每个 Web 应用都提供一个独立的类加载器，使用的就是自己优先加载的策略。IBM WebSphere Application Server 则允许 Web 应用选择类加载器使用的策略。类加载器的一个重要用途是在 JVM 中为相同名称的 Java 类创建隔离空间。在 JVM 中，判断两个类是否相同，不仅是根据该类的 二进制名称，还需要根据两个类的定义类加载器。只有两者完全一样，才认为两个类的是相同的。因此，即便是同样的 Java 字节代码，被两个不同的类加载器定义之后，所得到的 Java 类也是不同的。如果试图在两个类的对象之间进行赋值操作，会抛出 java.lang.ClassCastException。这个特性为同样名称的 Java 类在 JVM 中共存创造了条件。在实际的应用中，可能会要求同一名称的 Java 类的不同版本在 JVM 中可以同时存在。通过类加载器就可以满足这种需求。这种技术在 OSGi 中得到了广泛的应用。Java 类的链接Java 类的链接指的是将 Java 类的二进制代码合并到 JVM 的运行状态之中的过程。在链接之前，这个类必须被成功加载。类的链接包括验证、准备和解析等几个步骤。验证（Verification）：用来确保 Java 类的二进制表示在结构上是完全正确的。如果验证过程出现错误的话，会抛出 java.lang.VerifyError 错误。准备（Preparation）：则是创建 Java 类中的静态域，并将这些域的值设为默认值。准备过程并不会执行代码。解析（Resolution）：在一个 Java 类中会包含对其它类或接口的形式引用，包括它的父类、所实现的接口、方法的形式参数和返回值的 Java 类等。解析的过程就是确保这些被引用的类能被正确的找到。解析的过程可能会导致其它的 Java 类被加载。不同的 JVM 实现可能选择不同的解析策略。一种做法是在链接的时候，就递归地把所有依赖的形式引用都进行解析。而另外的做法则可能是只在一个形式引用真正需要的时候才进行解析。也就是说如果一个 Java 类只是被引用了，但是并没有被真正用到，那么这个类有可能就不会被解析。考虑下面的代码：public class LinkTest { public static void main(String[] args) { ToBeLinked toBeLinked = null; System.out.println("Test link."); } }类 LinkTest 引用了类 ToBeLinked，但是并没有真正使用它，只是声明了一个变量，并没有创建该类的实例或是访问其中的静态域。在 Oracle 的 JDK 6 中，如果把编译好的 ToBeLinked 的 Java 字节代码删除之后，再运行 LinkTest，程序不会抛出错误。这是因为 ToBeLinked 类没有被真正用到，而 Oracle 的 JDK 6 所采用的链接策略使得 ToBeLinked 类不会被加载，因此也不会发现 ToBeLinked 的 Java 字节代码实际上是不存在的。如果把代码改成 ToBeLinked toBeLinked = new ToBeLinked(); 之后，再按照相同的方法运行，就会抛出异常了。因为这个时候 ToBeLinked 这个类被真正使用到了，会需要加载这个类。Java 类的初始化当一个 Java 类第一次被真正使用到的时候，JVM 会进行该类的初始化操作。初始化过程的主要操作是执行静态代码块和初始化静态域。在一个类被初始化之前，它的直接父类也需要被初始化。但是，一个接口的初始化，不会引起其父接口的初始化。在初始化的时候，会按照源代码中从上到下的顺序依次执行静态代码块和初始化静态域。考虑下面的代码：public class StaticTest { public static int X = 10; public static void main(String[] args) { System.out.println(Y); // 输出 60 } static { X = 30; } public static int Y = X * 2; }在上面的代码中，在初始化的时候，静态域的初始化和静态代码块的执行会从上到下依次执行。因此变量 X 的值首先初始化成 10，后来又被赋值成 30；而变量 Y 的值则被初始化成 60。Java 类和接口的初始化只有在特定的时机才会发生，这些时机包括：创建一个 Java 类的实例。如：MyClass obj = new MyClass()调用一个 Java 类中的静态方法。如：MyClass.sayHello()给 Java 类或接口中声明的静态域赋值。如：MyClass.value = 10访问 Java 类或接口中声明的静态域，并且该域不是常量字段（constant field）。如：int value = MyClass.value在顶层 Java 类中执行 assert 语句。通过 Java 反射 API 也可能造成类和接口的初始化。需要注意的是，当访问一个 Java 类或接口中的静态域的时候，只有真正声明这个域的类或接口才会被初始化。考虑下面的代码：class B { static int value = 100; static { System.out.println("Class B is initialized."); // 输出 } } class A extends B { static { System.out.println("Class A is initialized."); // 不会输出 } } public class InitTest { public static void main(String[] args) { System.out.println(A.value); // 输出 100 } }在上述代码中，类 InitTest 通过 A.value 引用了类 B 中声明的静态域 value。由于 value 是在类 B 中声明的，只有类 B 会被初始化，而类 A 则不会被初始化。创建自己的类加载器在 Java 应用开发过程中，可能会需要创建应用自己的类加载器。典型的场景包括实现特定的 Java 字节代码查找方式、对字节代码进行加密 / 解密以及实现同名 Java 类的隔离等。创建自己的类加载器并不是一件复杂的事情，只需要继承自 java.lang.ClassLoader 类并覆写对应的方法即可。java.lang.ClassLoader 中提供的方法有不少，下面介绍几个创建类加载器时需要考虑的：defineClass()：这个方法用来完成从 Java 字节代码的字节数组到 java.lang.Class 的转换。这个方法是不能被覆写的，一般是用原生代码来实现的。findLoadedClass()：这个方法用来根据名称查找已经加载过的 Java 类。一个类加载器不会重复加载同一名称的类。findClass()：这个方法用来根据名称查找并加载 Java 类。loadClass()：这个方法用来根据名称加载 Java 类。resolveClass()：这个方法用来链接一个 Java 类。这里比较容易混淆的是 findClass() 方法和 loadClass() 方法的作用。前面提到过，在 Java 类的链接过程中，会需要对 Java 类进行解析，而解析可能会导致当前 Java 类所引用的其它 Java 类被加载。在这个时候，JVM 就是通过调用当前类的定义类加载器的 loadClass() 方法来加载其它类的。findClass() 方法则是应用创建的类加载器的扩展点。应用自己的类加载器应该覆写 findClass() 方法来添加自定义的类加载逻辑。loadClass() 方法的默认实现会负责调用 findClass() 方法。前面提到，类加载器的代理模式默认使用的是父类优先的策略。这个策略的实现是封装在 loadClass() 方法中的。如果希望修改此策略，就需要覆写 loadClass() 方法。下面的代码给出了自定义的类加载的常见实现模式：public class MyClassLoader extends ClassLoader { protected Class<?> findClass(String name) throws ClassNotFoundException { byte[] b = null; // 查找或生成 Java 类的字节代码 return defineClass(name, b, 0, b.length); } }参考资料Java 语言规范（第三版）- 第十三章：执行JVM 规范（第二版）- 第五章：加载、链接和初始化深入探讨 Java 类加载器说明：本文内容基于 Java 5/6 时期的规范与实现细节编写。虽然类的加载、链接与初始化的核心机制在后续 Java 版本（如 Java 8/11/17 等）中保持一致，但类加载器的具体层级结构（如 Platform ClassLoader 的引入）及部分实现细节可能随版本演进有所变化。文中引用的官方文档链接为历史版本存档，仅供参考。
在 Java 编码实践中，开发者容易犯一些错误，也容易疏忽某些细节。笔者对日常编码中遇到的经典情形进行了归纳整理，形成以下十个常见问题，以供共同探讨与避坑。1. 纠结的同名现象很多类的命名相同（例如：常见于异常、常量、日志等类），导致在 import 时容易张冠李戴。这种错误有时候很隐蔽，因为往往同名的类功能也类似，IDE 可能不会提示警告（Warning）。解决写完代码时，扫视一下 import 部分，检查有没有不熟悉的类。替换成正确导入后，要注意注释是否也作了相应修改。启示命名尽量避开重复名，特别要避开与 JDK 中的类重名，否则容易导入错误。同时，存在大量重名类时，查找也需要更多的辨别时间。2. 想当然的 API现象有时候调用 API 时，会想当然地通过名字直接自信满满地调用，导致出现一些令人惊讶的错误。示例一：flag 是 true 吗？boolean flag = Boolean.getBoolean("true");结果可能老是 false。因为 Boolean.getBoolean 读取的是系统属性（System Property），而非字符串解析。示例二：这是去年的今天吗（今年是 2012 年）？Calendar calendar = GregorianCalendar.getInstance(); calendar.roll(Calendar.DAY_OF_YEAR, -365);结果还是 2012 年。roll 方法只改变指定字段，不影响更大字段（如年份）。下面的才是去年：calendar.add(Calendar.DAY_OF_YEAR, -365); 解决办法问自己几个问题：这个方法我很熟悉吗？有没有类似的 API？区别是什么？就示例一而言，需要区别如下方法：Boolean.valueOf(b)Boolean.parseBoolean(b)Boolean.getBoolean(b)启示名字起得更详细点，注释写得更清楚点。不要不经了解、测试就想当然地使用一些 API。如果时间有限，用自己最为熟悉的 API。3. 有时候溢出并不难现象有时候溢出并不难发现，虽然不常复现。示例一：long x = Integer.MAX_VALUE + 1; System.out.println(x);x 是多少？竟然是 -2147483648。明明加上 1 之后还在 long 的范围，但因为 Integer.MAX_VALUE 是 int，运算先在 int 范围内进行，导致溢出后再赋值给 long。类似的经常出现在时间计算或多数字连乘中：数字 1 × 数字 2 × 数字 3… 示例二：在检查是否为正数的参数校验中，为了避免重载，选用参数 number，于是下面代码结果小于 0，也是因为溢出导致：Number i = Long.MAX_VALUE; System.out.println(i.intValue() > 0);解决让第一个操作数是 long 型，例如加上 L 或者 l（不建议小写字母 l，因为和数字 1 太相似了）；不确定时，还是使用重载吧。即使使用 doubleValue()，当参数是 BigDecimal 时，也可能无法完全解决问题。启示对数字运用要保持敏感：涉及数字计算就要考虑溢出；涉及除法就要考虑被除数是 0；实在容纳不下了可以考虑 BigDecimal 之类。4. 日志跑哪了？现象有时候觉得 log 都打了，怎么找不到？示例一：没有 Stack Trace！} catch (Exception ex) { log.error(ex); }示例二：找不到 log！} catch (ConfigurationException e) { e.printStackTrace(); }解决替换成 log.error(ex.getMessage(), ex);，确保异常堆栈被记录；换成普通的 log4j 吧，而不是 System.out。启示API 定义应该避免让人犯错，如果多加个重载的 log.error(Exception) 自然没有错误发生；在产品代码中，使用的一些方法要考虑是否有效，使用 e.printStackTrace() 要想下终端（Console）在哪。5. 遗忘的 volatile现象在 DCL（Double-Checked Locking，双重检查锁定）模式中，总是忘记加一个 volatile。private static CacheImpl instance; // lose volatile public static CacheImpl getInstance() { if (instance == null) { synchronized (CacheImpl.class) { if (instance == null) { instance = new CacheImpl(); } } } return instance; }解决毋庸置疑，加上一个 volatile 吧。synchronized 锁的是一块代码（整个方法或某个代码块），保证的是这“块”代码的可见性及原子性，但是 instance == null 第一次判断时不在锁范围内。所以可能读出的是过期的 null。启示我们总是觉得某些低概率的事件很难发生，例如某个时间并发的可能性、某个异常抛出的可能性，所以不加控制。但是如果可以，还是按照前人的“最佳实践”来写代码吧。至少不用过多解释为啥另辟蹊径。6. 不要影响彼此现象在释放多个 IO 资源时，都会抛出 IOException，于是可能为了省事如此写：public static void inputToOutput(InputStream is, OutputStream os, boolean isClose) throws IOException { BufferedInputStream bis = new BufferedInputStream(is, 1024); BufferedOutputStream bos = new BufferedOutputStream(os, 1024); // ... if (isClose) { bos.close(); bis.close(); } }假设 bos 关闭失败抛出异常，bis 还能关闭吗？当然不能！解决办法虽然抛出的是同一个异常，但是还是各自捕获各的为好。否则第一个失败，后一个就没有机会去释放资源了。启示代码/模块之间可能存在依赖，要充分识别对相互的依赖，确保资源释放的独立性。7. 用断言取代参数校验现象如题所提，作为防御式 编程 常用的方式：断言，写在产品代码中做参数校验等。例如：private void send(List<Event> eventList) { assert eventList != null; }解决换成正常的统一的参数校验方法。因为断言默认是关闭的，所以起不起作用完全在于配置。如果采用默认配置，经历了 eventList != null 结果还没有起到作用，徒劳无功。启示有的时候，代码起不起作用，不仅在于用例，还在于配置，例如断言是否启用、log 级别等，要结合真实环境做有用编码。8. 用户认知负担有时候很重现象先来比较三组例子，看看哪些看着更顺畅？示例一：public void caller(int a, String b, float c, String d) { methodOne(d, c, b); methodTwo(b, c, d); } public void methodOne(String d, float c, String b) public void methodTwo(String b, float c, String d)示例二：public boolean remove(String key, long timeout) { Future<Boolean> future = memcachedClient.delete(key); public boolean delete(String key, long timeout) { Future<Boolean> future = memcachedClient.delete(key);示例三：public static String getDigest(String filePath, DigestAlgorithm algorithm) public static String getDigest(String filePath, DigestAlgorithm digestAlgorithm)解决保持参数传递顺序；remove 变成了 delete，显得突兀了点，统一表达更好；保持表达，少缩写也会看起来流畅点。启示在编码过程中，不管是参数的顺序还是命名都尽量统一，这样用户的认知负担会很少，不要让用户容易犯错或迷惑。例如用枚举代替 String 从而不让用户迷惑到底传什么 String，诸如此类。9. 忽视日志记录时机、级别现象存在下面两则示例：示例一：该不该记录日志？catch (SocketException e) { LOG.error("server error", e); throw new ConnectionException(e.getMessage(), e); } 示例二：记什么级别日志？在用户登录系统中，每次失败登录：LOG.warn("Failed to login by " + username);解决移除日志记录：在遇到需要 re-throw 的异常时，如果每个人都按照先记录后 throw 的方式去处理，那么对一个错误会记录太多的日志，所以不推荐如此做；但是如果 re-throw 出去的 exception 没有带完整的 trace（即 cause），那么最好还是记录下。调整级别：如果恶意登录，那系统内部会出现太多 WARN，从而让管理员误以为是代码错误。可以反馈用户以错误，但是不要记录用户错误的行为，除非想达到控制的目的。启示日志改不改记？记成什么级别？如何记？这些都是问题，一定要根据具体情况，需要考虑：是用户行为错误还是代码错误？记录下来的日志，能否在不造成过多干扰的前提下，提供有用的信息以快速定位问题。10. 忘设初始容量现象在 Java 中，我们常用 Collection 中的 Map 做 Cache，但是我们经常会遗忘设置初始容量。cache = new LRULinkedHashMap<K, V>(maxCapacity);解决初始容量的影响有多大？拿 LinkedHashMap 来说，初始容量如果不设置默认是 16，超过 16 × LOAD_FACTOR 会 resize（2 * table.length），扩大 2 倍：采用 Entry[] newTable = new Entry[newCapacity]; transfer(newTable)，即整个数组 Copy。那么对于一个需要做大容量 CACHE 来说，从 16 变成一个很大的数量，需要做多少次数组复制可想而知。如果初始容量就设置很大，自然会减少 resize。不过可能会担心，初始容量设置很大时，没有 Cache 内容仍然会占用过大体积。其实可以参考以下表格简单计算下，初始时还没有 cache 内容，每个对象仅仅是 4 字节引用而已。memory for reference fields (4 bytes each);memory for primitive fieldsJava typeBytes requiredboolean1byte1char2short2int4float4long8double8启示不仅是 Map，还有 StringBuffer 等，都有容量 resize 的过程。如果数据量很大，就不能忽视初始容量，可以考虑设置下，否则不仅有频繁的 resize 还容易浪费容量。结语在 Java 编程中，除了上面枚举的一些容易忽视的问题，日常实践中还存在很多。相信通过不断的总结和努力，可以将我们的程序完美呈现给读者。说明本文部分示例基于 Java 早期版本编写（如手动关闭 IO 资源、DCL 模式等）。在 Java 7 及以上版本中，建议优先使用 try-with-resources 语法自动管理 IO 资源。关于 volatile 的语义保证，主要适用于 Java 5 及以上内存模型。
本文整理了八大排序算法的基本思想、实例图解及 Java 实现代码，供学习参考。8 种排序算法之间的关系如下图所示：1. 直接插入排序（Insertion Sort）1.1 基本思想在要排序的一组数中，假设前面 (n-1) [n>=2] 个数已经是排好顺序的，现在要把第 n 个数插到前面的有序数中，使得这 n 个数也是排好顺序的。如此反复循环，直到全部排好顺序。1.2 实例1.3 代码实现package com.jsdiff; public class insertSort { public insertSort() { int a[] = {49, 38, 65, 97, 76, 13, 27, 49, 78, 34, 12, 64, 5, 4, 62, 99, 98, 54, 56, 17, 18, 23, 34, 15, 35, 25, 53, 51}; int temp = 0; for (int i = 1; i < a.length; i++) { int j = i - 1; temp = a[i]; for (; j >= 0 && temp < a[j]; j--) { a[j + 1] = a[j]; // 将大于 temp 的值整体后移一个单位 } a[j + 1] = temp; } for (int i = 0; i < a.length; i++) System.out.println(a[i]); } }2. 希尔排序（Shell Sort）2.1 基本思想算法先将要排序的一组数按某个增量 d（n/2，n 为要排序数的个数）分成若干组，每组中记录的下标相差 d。对每组中全部元素进行直接插入排序，然后再用一个较小的增量（d/2）对它进行分组，在每组中再进行直接插入排序。当增量减到 1 时，进行直接插入排序后，排序完成。2.2 实例2.3 代码实现public class shellSort { public shellSort() { int a[] = {1, 54, 6, 3, 78, 34, 12, 45, 56, 100}; double d1 = a.length; int temp = 0; while (true) { d1 = Math.ceil(d1 / 2); int d = (int) d1; for (int x = 0; x < d; x++) { for (int i = x + d; i < a.length; i += d) { int j = i - d; temp = a[i]; for (; j >= 0 && temp < a[j]; j -= d) { a[j + d] = a[j]; } a[j + d] = temp; } } if (d == 1) break; } for (int i = 0; i < a.length; i++) System.out.println(a[i]); } }3. 简单选择排序（Selection Sort）3.1 基本思想在要排序的一组数中，选出最小的一个数与第一个位置的数交换；然后在剩下的数当中再找最小的与第二个位置的数交换，如此循环到倒数第二个数和最后一个数比较为止。3.2 实例3.3 代码实现public class selectSort { public selectSort() { int a[] = {1, 54, 6, 3, 78, 34, 12, 45}; int position = 0; for (int i = 0; i < a.length; i++) { int j = i + 1; position = i; int temp = a[i]; for (; j < a.length; j++) { if (a[j] < temp) { temp = a[j]; position = j; } } a[position] = a[i]; a[i] = temp; } for (int i = 0; i < a.length; i++) System.out.println(a[i]); } }4. 堆排序（Heap Sort）4.1 基本思想堆排序是一种树形选择排序，是对直接选择排序的有效改进。堆的定义如下：具有 n 个元素的序列（h1,h2,...,hn)，当且仅当满足（hi>=h2i, hi>=2i+1）或（hi<=h2i, hi<=2i+1）(i=1,2,...,n/2) 时称之为堆。在这里只讨论满足前者条件的堆。由堆的定义可以看出，堆顶元素（即第一个元素）必为最大项（大顶堆）。完全二叉树可以很直观地表示堆的结构。堆顶为根，其它为左子树、右子树。初始时把要排序的数的序列看作是一棵顺序存储的二叉树，调整它们的存储序，使之成为一个堆，这时堆的根节点的数最大。然后将根节点与堆的最后一个节点交换。然后对前面 (n-1) 个数重新调整使之成为堆。依此类推，直到只有两个节点的堆，并对它们作交换，最后得到有 n 个节点的有序序列。从算法描述来看，堆排序需要两个过程，一是建立堆，二是堆顶与堆的最后一个元素交换位置。所以堆排序有两个函数组成：一是建堆的渗透函数，二是反复调用渗透函数实现排序的函数。4.2 实例初始序列：46, 79, 56, 38, 40, 84建堆：交换，从堆中踢出最大数：依次类推：最后堆中剩余的最后两个结点交换，踢出一个，排序完成。4.3 代码实现import java.util.Arrays; public class HeapSort { int a[] = {49, 38, 65, 97, 76, 13, 27, 49, 78, 34, 12, 64, 5, 4, 62, 99, 98, 54, 56, 17, 18, 23, 34, 15, 35, 25, 53, 51}; public HeapSort() { heapSort(a); } public void heapSort(int[] a) { System.out.println("开始排序"); int arrayLength = a.length; // 循环建堆 for (int i = 0; i < arrayLength - 1; i++) { // 建堆 buildMaxHeap(a, arrayLength - 1 - i); // 交换堆顶和最后一个元素 swap(a, 0, arrayLength - 1 - i); System.out.println(Arrays.toString(a)); } } private void swap(int[] data, int i, int j) { int tmp = data[i]; data[i] = data[j]; data[j] = tmp; } // 对 data 数组从 0 到 lastIndex 建大顶堆 private void buildMaxHeap(int[] data, int lastIndex) { // 从 lastIndex 处节点（最后一个节点）的父节点开始 for (int i = (lastIndex - 1) / 2; i >= 0; i--) { // k 保存正在判断的节点 int k = i; // 如果当前 k 节点的子节点存在 while (k * 2 + 1 <= lastIndex) { // k 节点的左子节点的索引 int biggerIndex = 2 * k + 1; // 如果 biggerIndex 小于 lastIndex，即 biggerIndex+1 代表的 k 节点的右子节点存在 if (biggerIndex < lastIndex) { // 若右子节点的值较大 if (data[biggerIndex] < data[biggerIndex + 1]) { // biggerIndex 总是记录较大子节点的索引 biggerIndex++; } } // 如果 k 节点的值小于其较大的子节点的值 if (data[k] < data[biggerIndex]) { // 交换他们 swap(data, k, biggerIndex); // 将 biggerIndex 赋予 k，开始 while 循环的下一次循环，重新保证 k 节点的值大于其左右子节点的值 k = biggerIndex; } else { break; } } } } }5. 冒泡排序（Bubble Sort）5.1 基本思想在要排序的一组数中，对当前还未排好序的范围内的全部数，自上而下对相邻的两个数依次进行比较和调整，让较大的数往下沉，较小的往上冒。即：每当两相邻的数比较后发现它们的排序与排序要求相反时，就将它们互换。5.2 实例5.3 代码实现public class bubbleSort { public bubbleSort() { int a[] = {49, 38, 65, 97, 76, 13, 27, 49, 78, 34, 12, 64, 5, 4, 62, 99, 98, 54, 56, 17, 18, 23, 34, 15, 35, 25, 53, 51}; int temp = 0; for (int i = 0; i < a.length - 1; i++) { for (int j = 0; j < a.length - 1 - i; j++) { if (a[j] > a[j + 1]) { temp = a[j]; a[j] = a[j + 1]; a[j + 1] = temp; } } } for (int i = 0; i < a.length; i++) System.out.println(a[i]); } }6. 快速排序（Quick Sort）6.1 基本思想选择一个基准元素，通常选择第一个元素或者最后一个元素，通过一趟扫描，将待排序列分成两部分，一部分比基准元素小，一部分大于等于基准元素，此时基准元素在其排好序后的正确位置，然后再用同样的方法递归地排序划分的两部分。6.2 实例6.3 代码实现public class quickSort { int a[] = {49, 38, 65, 97, 76, 13, 27, 49, 78, 34, 12, 64, 5, 4, 62, 99, 98, 54, 56, 17, 18, 23, 34, 15, 35, 25, 53, 51}; public quickSort() { quick(a); for (int i = 0; i < a.length; i++) System.out.println(a[i]); } public int getMiddle(int[] list, int low, int high) { int tmp = list[low]; // 数组的第一个作为中轴 while (low < high) { while (low < high && list[high] >= tmp) { high--; } list[low] = list[high]; // 比中轴小的记录移到低端 while (low < high && list[low] <= tmp) { low++; } list[high] = list[low]; // 比中轴大的记录移到高端 } list[low] = tmp; // 中轴记录到尾 return low; // 返回中轴的位置 } public void _quickSort(int[] list, int low, int high) { if (low < high) { int middle = getMiddle(list, low, high); // 将 list 数组进行一分为二 _quickSort(list, low, middle - 1); // 对低字表进行递归排序 _quickSort(list, middle + 1, high); // 对高字表进行递归排序 } } public void quick(int[] a2) { if (a2.length > 0) { // 查看数组是否为空 _quickSort(a2, 0, a2.length - 1); } } }7. 归并排序（Merge Sort）7.1 基本思想归并（Merge）排序法是将两个（或两个以上）有序表合并成一个新的有序表，即把待排序序列分为若干个子序列，每个子序列是有序的。然后再把有序子序列合并为整体有序序列。7.2 实例7.3 代码实现import java.util.Arrays; public class mergingSort { int a[] = {49, 38, 65, 97, 76, 13, 27, 49, 78, 34, 12, 64, 5, 4, 62, 99, 98, 54, 56, 17, 18, 23, 34, 15, 35, 25, 53, 51}; public mergingSort() { sort(a, 0, a.length - 1); for (int i = 0; i < a.length; i++) System.out.println(a[i]); } public void sort(int[] data, int left, int right) { if (left < right) { // 找出中间索引 int center = (left + right) / 2; // 对左边数组进行递归 sort(data, left, center); // 对右边数组进行递归 sort(data, center + 1, right); // 合并 merge(data, left, center, right); } } public void merge(int[] data, int left, int center, int right) { int[] tmpArr = new int[data.length]; int mid = center + 1; // third 记录中间数组的索引 int third = left; int tmp = left; while (left <= center && mid <= right) { // 从两个数组中取出最小的放入中间数组 if (data[left] <= data[mid]) { tmpArr[third++] = data[left++]; } else { tmpArr[third++] = data[mid++]; } } // 剩余部分依次放入中间数组 while (mid <= right) { tmpArr[third++] = data[mid++]; } while (left <= center) { tmpArr[third++] = data[left++]; } // 将中间数组中的内容复制回原数组 while (tmp <= right) { data[tmp] = tmpArr[tmp++]; } System.out.println(Arrays.toString(data)); } }8. 基数排序（Radix Sort）8.1 基本思想将所有待比较数值（正整数）统一为同样的数位长度，数位较短的数前面补零。然后，从最低位开始，依次进行一次排序。这样从最低位排序一直到最高位排序完成以后，数列就变成一个有序序列。8.2 实例8.3 代码实现import java.util.ArrayList; import java.util.List; public class radixSort { int a[] = {49, 38, 65, 97, 76, 13, 27, 49, 78, 34, 12, 64, 5, 4, 62, 99, 98, 54, 101, 56, 17, 18, 23, 34, 15, 35, 25, 53, 51}; public radixSort() { sort(a); for (int i = 0; i < a.length; i++) System.out.println(a[i]); } public void sort(int[] array) { // 首先确定排序的趟数 int max = array[0]; for (int i = 1; i < array.length; i++) { if (array[i] > max) { max = array[i]; } } int time = 0; // 判断位数 while (max > 0) { max /= 10; time++; } // 建立 10 个队列 List<ArrayList> queue = new ArrayList<ArrayList>(); for (int i = 0; i < 10; i++) { ArrayList<Integer> queue1 = new ArrayList<Integer>(); queue.add(queue1); } // 进行 time 次分配和收集 for (int i = 0; i < time; i++) { // 分配数组元素 for (int j = 0; j < array.length; j++) { // 得到数字的第 time+1 位数 int x = array[j] % (int) Math.pow(10, i + 1) / (int) Math.pow(10, i); ArrayList<Integer> queue2 = queue.get(x); queue2.add(array[j]); queue.set(x, queue2); } int count = 0;// 元素计数器 // 收集队列元素 for (int k = 0; k < 10; k++) { while (queue.get(k).size() > 0) { ArrayList<Integer> queue3 = queue.get(k); array[count] = queue3.get(0); queue3.remove(0); count++; } } } } }说明：文中部分图片链接来源于 2012 年，内容主要适用于 Java 基础算法学习参考，代码逻辑未随新版本特性进行调整。
Java 编程：常见问题汇总本文收集了一些看似无害但实际上存在问题的代码模式（反模式）。初学者往往在语言语法上挣扎，不熟悉标准 JDK 类库的最佳实践。这里的示例大多源自日常初级代码，经过修改以突出潜在问题。许多问题可以通过 SonarQube 等工具轻松发现，强烈推荐使用该工具进行代码质量检查。其中一些问题可能看起来像是微优化、未经剖析的过早优化或常数因子优化。然而，在成千上万个这样的小地方浪费的性能和内存会迅速累积，使应用程序变得难以维护。这里提到的“应用程序”主要指运行在应用服务器上的服务端应用。在桌面 GUI 应用程序上，情况可能没那么严重，但客户端 Java 应用的主要平台——Android，是一个资源（尤其是内存）非常有限的嵌入式平台。在这里，即使是常数因子的优化也能很快获得回报，例如遍历数组而不是列表。如果您对如何友好地优化性能感兴趣，请查看 JDK Performance Wiki。最后，应用程序的性能很大程度上取决于代码的整体质量。切勿低估内存占用的重要性。我看到过太多应用程序因疯狂的垃圾回收（GC）开销和内存不足错误而崩溃。尽管垃圾回收很快，但大多数服务端代码的可伸缩性主要受制于每个请求/事务的内存使用量和请求/事务持续时间。将这两个参数中的任何一个优化一个常数因子，都将直接带来更高的吞吐量。如果系数是 10，这意味着支持 100 或 1000 个用户的能力差异，这对客户至关重要。比较以下情况（假设年轻代堆内存为 100MB）：场景 (Scenario)线程池 (thread pool)事务持续时间 (tx duration)=> 最大事务数/秒 (max tx / s)内存/事务 (mem / tx)=> 垃圾/分钟 (garbage / min)GC 次数/分钟 (GC / min)基准 (base)30100 ms30050 KB900 MB9较慢 (slower)301000 ms3050 KB90 MB0.9更多内存 (more mem)30100 ms300500 KB9 GB90内存过剩 (excess mem)30100 ms3005 MB90 GB900在较慢的情况下，事务持续时间长了 10 倍。这也立即将每秒最大事务数减少了 10 倍（受限于线程池和 CPU 资源）。在更多内存的情况下，每个事务使用了 10 倍的内存。这直接将垃圾收集的频率提高到每秒超过一次，导致不可忽略的开销。使用更多的内存，例如在内存过剩场景中，将导致每秒 15 次收集，而每个收集只剩下 66ms，这显然是不够的，系统将崩溃。同样，66ms 低于 100ms 的事务持续时间，因此许多正在运行的事务仍将保留在内存中，阻止了它们的收集，并导致内存晋升到老年代。这意味着老年代将开始增长，并且需要频繁且缓慢的 Full GC。该场景中的应用程序将无法正常执行。与仅代码运行缓慢相比，这清楚地表明了过多的内存消耗是多么糟糕。当您分配过多的内存时，所有超快速代码都无法为您提供帮助。目录字符串串联StringBuffer 的性能误区测试字符串是否相等将数字转换为字符串解析和转换数字不利用不可变对象XML 解析的简易误区用 String 操作组装 XMLXML 编码陷阱字符不是整数假设 char 代表一个字符平台相关的文件名未定义的编码无缓冲流InputStreamReader/OutputStreamWriter 上的未缓冲操作使用 PrintWriter 进行文件 I/O堆内存溢出风险无限超时风险假设便宜的计时器呼叫捕获所有异常：未知的运行时异常异常很烦人重新包装 RuntimeException没有正确传播异常愚蠢的异常消息异常捕获与日志记录不完整的异常处理永远不会发生的异常transient 字段初始化陷阱过度初始化日志实例：静态还是非静态？选择错误的类加载器使用反射不良同步过度列表类型错误HashMap 大小陷阱Hashtable、HashMap 和 HashSet 被高估了列表被高估对象数组非常灵活对象过早分解修改 Setter不必要的 Calendar依赖默认的时区时区“转换”使用 Calendar.getInstance()危险 Calendar 操作调用 Date.setTime()假设 SimpleDateFormat 是线程安全的具有全局 Configuration/Parameters/Constants 类没有注意到溢出将 == 与 float 或 double 一起使用将钱存入浮点变量在 finally 块中不释放资源滥用 finalize()不由自主地重置 Thread.interrupted来自静态初始值设定项的生成线程取消了保持状态的计时器任务拥有对 ClassLoader 和无法刷新的缓存的强大引用嵌套同步语句通过 RandomAccessFile 进行随机文件访问字符串串联String s = ""; for (Person p : persons) { s += ", " + p.getName(); } s = s.substring(2); // remove first comma这是真正的内存浪费。循环中字符串的重复连接会导致大量垃圾对象和数组复制。而且，必须将生成的字符串截取以去除多余的逗号，这很丑陋。令人惊讶的是，直到 2016 年仍有人相信编译器会以某种方式对其进行优化。Java 8 中甚至没有！有些人甚至以执行时间为基准来“证明”这还可以。不，产生大量不必要的垃圾并不是好做法。StringBuilder sb = new StringBuilder(persons.size() * 16); // 预估缓冲区大小 for (Person p : persons) { if (sb.length() > 0) sb.append(", "); // JIT 可能会优化掉循环中的 if (peeling) sb.append(p.getName()); }StringBuffer 的性能误区StringBuffer sb = new StringBuffer(); sb.append("Name: "); sb.append(name + '\n'); sb.append("!"); ... String s = sb.toString();这看起来像优化的代码，但还不是最佳的。那么，如果您未能正确地进行优化，为什么要首先进行优化呢？最明显的错误是第 3 行中的字符串连接。在第 4 行中，添加 char 比添加 String 快。另外一个主要的遗漏是缓冲区缺少长度初始化，这可能导致不必要的调整大小（数组复制）。在 JDK 1.5 及更高版本中，应该使用 StringBuilder 而不是 StringBuffer：因为它只是一个局部变量，所以隐式同步是多余的。实际上，使用简单的 String 串联可以编译成几乎完美的字节码：仅缺少长度初始化。StringBuilder sb = new StringBuilder(100); sb.append("Name: "); sb.append(name); sb.append("\n!"); String s = sb.toString(); // 或者简单的字符串串联（编译器会优化） String s = "Name: " + name + "\n!";测试字符串是否相等if (name.compareTo("John") == 0) ... if (name == "John") ... if (name.equals("John")) ... if ("".equals(name)) ...以上比较均无语法错误，但它们也不是很好。compareTo 方法过大且过于冗长。== 是对象的身份运算符测试，可能不是你想要的。equals 方法是可行的方法，但是如果 name 是 null，则反转常量和变量将为您提供额外的安全性（避免空指针异常）。if ("John".equals(name)) ... if (name.length() == 0) ... if (name.isEmpty()) ...将数字转换为字符串"" + set.size() new Integer(set.size()).toString() Set.size() 方法的返回类型为 int。需要转换为 String。实际上，这两个示例可以进行转换。但是第一种方法会导致串联操作的代价（转换为 (new StringBuilder()).append(i).toString()）。第二个创建中间的 Integer 包装器。正确的方法是其中之一：Integer.toString(set.size())解析和转换数字int v = Integer.valueOf(str).intValue(); int w = Long.valueOf(Double.valueOf(str).longValue()).intValue();了解如何使用 API 而不分配不必要的对象。int v = Integer.parseInt(str); int w = (int) Double.parseDouble(str);不利用不可变对象zero = new Integer(0); return Boolean.valueOf("true");Integer 以及 Boolean 是不可变的。因此，创建代表相同值的多个对象没有任何意义。这些类具有针对常用实例的内置缓存。对于布尔型，甚至只有两个可能的实例。程序员可以利用以下优势：zero = Integer.valueOf(0); return Boolean.TRUE;XML 解析的简易误区int start = xml.indexOf("<name>") + "<name>".length(); int end = xml.indexOf("</name>"); String name = xml.substring(start, end);这种幼稚的 XML 解析仅适用于最简单的 XML 文档。但是，如果 a) name 元素在文档中不是唯一的，b) name 的内容不仅是字符数据，c) name 的文本数据包含转义字符，d) 将该文本数据指定为 CDATA 部分，e) 文档使用 XML 名称空间，它将失败。XML 对于字符串操作来说太复杂了。诸如 Xerces 之类的 XML 解析器是一个超过一兆字节的 jar 文件，这是有原因的！与 JDOM 等效的是：SAXBuilder builder = new SAXBuilder(false); Document doc = builder.build(new StringReader(xml)); String name = doc.getRootElement().getChild("name").getText();用 String 操作组装 XMLString name = ... String attribute = ... String xml = "<root>" +"<name att=\""+ attribute +"\">"+ name +"</name>" +"</root>";许多初学者很想通过使用 String 操作（他们非常了解而且很容易）来产生如上所示的 XML 输出。确实，这是非常简单且几乎精美的代码。但是，它有一个严重的缺点：它无法转义保留的字符。因此，如果变量名称或属性包含任何保留字符 <, >, &, " 或 '，则此代码将生成无效的 XML。此外，一旦 XML 使用名称空间，字符串操作可能很快就会变得讨厌并且难以维护。XML 应该在 DOM 中组装，JDom 库对此非常有用。Element root = new Element("root"); root.setAttribute("att", attribute); root.setText(name); Document doc = new Document(); doc.setRootElement(root); XmlOutputter out = new XmlOutputter(Format.getPrettyFormat()); String xml = out.outputString(root);XML 编码陷阱String xml = FileUtils.readTextFile("my.xml");读取 XML 文件并将其存储在 String 中是一个非常糟糕的主意。XML 在 XML 标头中指定其编码。但是在读取文件时，您必须事先知道编码！另外，将 XML 文件存储在字符串中会浪费内存。所有 XML 解析器都接受 InputStream 作为解析源，并且它们自己会正确计算出编码。因此，您可以向他们提供 InputStream 而不是将整个文件临时存储在内存中。当使用多字节编码（例如 UTF-8）时，字节顺序（大端，小端）是另一个陷阱。XML 文件可能在开头指定了字节顺序的字节顺序标记（BOM）。XML 解析器可以正确处理它们。字符不是整数int i = in.read(); char c = (char) i;上面的代码假定您可以从数字创建字符。从技术上讲，这已经是错误的：int 是有符号的，而 char 是无符号的。字符只是 16 位 UTF-16 编码的 Unicode。请注意，Unicode 定义的代码点数量超出了 16 位所能容纳的范围（Unicode 9.0 的编码点为 271792 个，而 16 位数字只能容纳 65536 个）。诸如流行表情符号之类的代码点已经远远超出了 BMP#Basic_Multilingual_Plane)，甚至在 Java 中也由多个 char 表示！无论如何，在 Java 中，请使用 Reader/Writer 或 CharsetEncoder/CharsetDecoder 在字符及其字节表示形式之间进行转换（请参见下文）。假设 char 代表一个字符"\uD83D\uDC31".length() == 2转义序列将 UTF-16 中的 Unicode 代码点 0x1F431 表示为 2 个字符。因此，即使这只是屏幕（🐱）上的单个猫脸符号，length() 方法仍返回 2。平台相关的文件名File tmp = new File("C:\\Temp\\1.tmp"); File exp = new File("export-2013-02-01T12:30.txt"); File f = new File(path + '/' + filename);绝对不要在文件系统中使用硬编码路径。不同的平台具有不同的约定，并且您永远不能确定在随机系统上实际是否可以使用硬编码路径。使用 API 调用来创建临时文件。请注意，不同的文件系统对产生有效文件名的限制不同。这里的 exp 文件包含一个冒号，在 Windows 文件系统上是非法的。在文件系统中构造绝对路径或相对路径时，请注意依赖于平台的分隔符。File tmp = File.createTempFile("myapp", "tmp"); File exp = new File("export-2013-02-01_1230.txt"); File f = new File(path + File.separatorChar + filename); // 或者更好 File dir = new File(path); File f = new File(dir, filename);未定义的编码Reader r = new FileReader(file); Writer w = new FileWriter(file); Reader r = new InputStreamReader(inputStream); Writer w = new OutputStreamWriter(outputStream); String s = new String(byteArray); // byteArray is a byte[] byte[] a = string.getBytes();以上各行在默认平台编码之间进行转换，byte 并 char 使用默认平台编码。该代码的行为根据其所运行的平台而有所不同。如果数据从一个平台流到另一个平台，这将是有害的。完全依靠默认平台编码被认为是不好的做法。转换应始终以定义的编码执行。Reader r = new InputStreamReader(new FileInputStream(file), "ISO-8859-1"); Writer w = new OutputStreamWriter(new FileOutputStream(file), "ISO-8859-1"); Reader r = new InputStreamReader(inputStream, StandardCharsets.UTF_8); Writer w = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8); String s = new String(byteArray, "ASCII"); byte[] a = string.getBytes("ASCII");无缓冲流InputStream in = new FileInputStream(file); int b; while ((b = in.read()) != -1) { ... }上面的代码逐字节读取文件。流上的每个 read() 调用都会导致对文件系统的本机实现的本机（JNI）调用。根据实现的不同，这可能导致对操作系统的系统调用。JNI 调用昂贵，而 syscall 也是如此。通过将流包装到 BufferedInputStream 中，可以大大减少本地调用的次数。使用上述代码从 /dev/zero 中读取 1 MB 的数据在笔记本电脑上花费了大约 1 秒钟。使用下面的固定代码，它可以减少到 60 毫秒！这样可以节省 94%。当然，这也适用于输出流。这不仅适用于文件系统，而且适用于套接字。InputStream in = new BufferedInputStream(new FileInputStream(file));InputStreamReader/OutputStreamWriter 上的未缓冲操作Writer w = new OutputStreamWriter(os, StandardCharsets.UTF_8); while (...) { // many small (<8kB) writes w.write("something"); } Reader r = new InputStreamReader(in, StandardCharsets.UTF_8); while (...) { // not reading into a buffer (char[], etc.) int c = r.read(); }如图所示，因为从 char 到字节的转换并不容易，所以 OutputStreamWriter 每次调用 write() 方法都会使用内存。始终缓冲那些写操作：Writer w = new BufferedWriter(new OutputStreamWriter(os, StandardCharsets.UTF_8)); Reader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));为了读写文本文件，正确的流链变为：Writer w = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(f), StandardCharsets.UTF_8)); Reader r = new BufferedReader(new InputStreamReader(new FileInputStream(f), StandardCharsets.UTF_8));使用 PrintWriter 进行文件 I/OPrintWriter w = new PrintWriter(new File("out.txt"), "UTF-8"); w.println("hello world");PrintWriter 从不抛出 IOException。即使磁盘已满。即使在磁盘已满后继续调用 println() 一百万次。即使在您打电话时也没有 close()。您需要显式调用 checkError() 以测试问题。然后您仍然没有得到一个异常，该异常可以告诉您到底发生了什么。您得到的只是一个布尔型说法，即在写入过程中的某个时刻出现问题，文件现在已损坏。问题就在这里。静默的文件损坏不是任何人想要的。要么生成一个完整的文件，要么根本不生成任何文件，然后出错。PrintWriter 是为网络 I/O（而非文件 I/O）而发明的。例如，它由 Servlet 使用。另一个不错的应用程序是日志记录，当您真的不在乎日志记录是否产生 I/O 错误时。例如，它在 JDBC API 中使用。如何避免写入损坏的文件：File f = new File("out.txt"); Writer w = null; try { w = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(f), StandardCharsets.UTF_8)); w.append("hello world"); ... w.close(); w = null; } finally { if (w != null) { // there was an exception and f is corrupt try { w.close(); } catch (IOException e) { } f.delete(); } }堆内存溢出风险byte[] pdf = toPdf(file);在这里，一种方法从某些输入创建 PDF 文件，然后将二进制 PDF 数据作为字节数组返回。此代码假定生成的文件足够小以适合可用的堆内存。如果此代码不能 100% 确定，则很容易出现内存不足的情况。特别是如果此代码在服务器端运行，通常意味着许多并行线程。批量数据绝不能使用字节数组进行处理。应该使用流，并且应该将数据假脱机到磁盘或数据库中。File pdf = toPdf(file);一种类似的反模式是缓冲来自“不受信任”（安全术语）源的流输入。例如缓冲到达网络套接字的数据。如果应用程序不知道将要到达多少数据，则必须确保它关注数据的大小。如果缓冲的数据量超过合理的限制，则应向调用者发出错误条件（异常）的信号，而不是通过使应用程序进入内存不足的情况来驱动应用程序。无限超时风险Socket socket = ... socket.connect(remote); InputStream in = socket.getInputStream(); int i = in.read();上面的代码有两个使用未指定超时的阻塞调用。想象一下，如果超时是无限的。这可能导致应用程序永久挂起。通常，首先设置无限超时是一个非常愚蠢的想法。无限长。即使到太阳变成红色巨人（爆炸）时，它仍然是通往无限的一种轻松方式。一般的程序员去世，享年 72 我们根本没有在现实世界中，我们要等待那么长时间。无限超时只是荒谬的事情。使用小时，天，周，月，1 年，10 年。但不是无限。要连接到远程计算机，我个人发现有 20 秒的超时时间。人类甚至没有患者耐心，因此会取消手术。尽管可以对采用超时参数的 connect() 方法进行很好的覆盖，但对于 read() 则没有这种要求。但是您可以在每个阻塞调用之前修改 Socket 的套接字超时。（不仅是一次！您可以为不同的情况设置不同的超时。）套接字将在该超时之后阻止调用时引发异常。通过网络进行通信的框架还应该提供 API，以控制这些超时并使用合理的默认值。无限是不明智的。Socket socket = ... socket.connect(remote, 20000); // fail after 20s InputStream in = socket.getInputStream(); socket.setSoTimeout(15000); int i = in.read();不幸的是，文件系统 API（FileInputStream, FileChannel, FileDescriptor, File）无法提供设置文件操作超时的方法。真是不幸。因为这些是 Java 应用程序中最常见的阻塞调用：写 stdout/stderr 和从 stdin 读取是文件操作，而写日志文件是常见的。标准输入/输出流上的操作直接取决于 Java VM 之外的其他进程。如果他们决定永远阻止，那么在我们的应用程序中将对这些流进行读/写操作。磁盘 I/O 是系统上所有进程竞争的有限资源。不能保证对文件的简单读写是快速的。可能会导致未指定的等待时间。今天，远程文件系统也无处不在。磁盘可以位于 SAN/NAS 上，也可以通过网络安装文件系统（NFS, AFS, CIFS/Samba）。因此，文件系统调用实际上可能是网络调用：太糟糕了，以至于我们在这里没有网络 API 的功能！因此，如果操作系统确定写入超时为 60 秒，则您将无法执行。不能假定任何磁盘/文件操作是快速的，甚至是远程的，都是失败的。应用程序可以通过假设文件操作需要几秒钟来帮助用户。因此，最好避免或异步完成（在后台）。解决此问题的方法是：适当的缓冲和排队/异步处理。假设便宜的计时器呼叫for (...) { long t = System.currentTimeMillis(); long t = System.nanoTime(); Date d = new Date(); Calendar c = new GregorianCalendar(); }创建新的日期或日历会执行系统调用以获取当前时间。在 Unix/Linux 上，这是系统调用 gettimeofday，被认为“非常便宜”。好吧，仅比其他系统调用便宜！因为它通常不需要从用户空间切换到内核空间，而是实现为对内存映射页面的读取。仍在呼叫 gettimeofday 与正常的代码执行相比，它是昂贵的。呼叫的确切代价在很大程度上取决于体系结构和配置（现代 x86 系统具有可由 OS 使用的众多计时器：HPET, TSC, RTC, ACPI, 时钟芯片等）。在我的 Linux-2.6.37-rc7 系统上，计时器调用似乎也在系统上同步。这意味着所有线程/进程共享每毫秒约 800 个调用的总可用带宽。因此，我的双核运行 2 个线程，每个线程每毫秒可以进行约 400 次调用。（感谢 J. Davies 的提示）最后但并非最不重要的一点是，此计时器的分辨率不是无限的。充其量最好是毫秒，但它可能是 25 到 50 毫秒左右的较大抖动。现代 Linux 系统可以在 System.currentTimeMillis 中轻松实现完整的 ms 分辨率。但这并非总是如此。System.nanoTime 当然不会具有其完整的理论分辨率：1ns = 10^-9 s 对应于 1GHz。因此，在具有 3GHz 的 CPU 上，这将允许约 3 条指令来执行调用，这显然是不够的。我测量了 800ns 至 1000000ns（1ms）之间的较大抖动。清楚地每 100 纳秒调用 gettimeofday 是浪费的。大多数时候，您不需要当前时间。将其缓存在循环之外是微不足道的。这样，您只需访问一次计时器。如果确实需要其他对象，您仍然可以决定克隆 Date 实例。与计时器访问相比，克隆非常便宜（我的系统中为 50）。Date d = new Date(); for (E entity : entities) { entity.doSomething(); entity.setUpdated((Date) d.clone()); }如果循环运行超过几毫秒，则可能无法选择缓存时间。在这种情况下，您可以设置一个计时器，以使用当前时间定期更新时间戳变量（使用中断）。将其设置为所需的确切粒度。粒度越大越好。在我的系统上，此循环比每次创建一个新的 Date 快 200 倍。private volatile long time; Timer timer = new Timer(true); try { time = System.currentTimeMillis(); timer.scheduleAtFixedRate(new TimerTask() { public void run() { time = System.currentTimeMillis(); } }, 0L, 10L); // granularity 10ms for (E entity : entities) { entity.doSomething(); entity.setUpdated(new Date(time)); } } finally { timer.cancel(); }捕获所有异常：未知的运行时异常Query q = ... Person p; try { p = (Person) q.getSingleResult(); } catch(Exception e) { p = null; }这是一个 J2EE EJB3 查询的示例。当 a) 结果不是唯一的，b) 没有结果，c) 当由于数据库故障而无法执行查询时，getSingleResult 会引发运行时异常。上面的代码捕获了任何异常。一个典型的万能块。使用 null 结果可能是 b 情况下正确的事情），而不是情况下 a) 或 c)。通常，不应捕获过多的异常。正确的异常处理是：Query q = ... Person p; try { p = (Person) q.getSingleResult(); } catch(NoResultException e) { p = null; }异常很烦人try { doStuff(); } catch(Exception e) { log.fatal("Could not do stuff"); } doMoreStuff();这小段代码有两个问题。首先，如果这确实是一个致命状况，则该方法应该中止并以适当的异常通知致命状况（为什么它首先被捕获了？）在出现致命状况之后，您几乎永远无法继续。其次，由于失败原因丢失，因此很难调试该代码。异常对象包含有关错误发生的位置以及导致错误的原因的详细信息。各个子类实际上可能携带许多额外的信息，调用者可以使用这些信息来适当地处理这种情况。它不仅仅是一个简单的错误代码（在 C 语言中非常流行。只需查看 Linux 内核。在任何地方都返回 -EINVAL ...）。如果捕获高级异常，则至少记录消息和堆栈跟踪。您不应将例外视为必要的邪恶。它们是错误处理的好工具。try { doStuff(); } catch(Exception e) { throw new MyRuntimeException(e.getMessage(), e); }重新包装 RuntimeExceptiontry { doStuff(); } catch(Exception e) { throw new RuntimeException(e); }有时您真的想将任何检查到的异常重新抛出为 RuntimeException。上面的代码没有考虑到，但是 RuntimeException 扩展了 Exception。RuntimeException 不需要在这里捕获。此外，异常的消息也无法正确传播。更好的方法是分别捕获 RuntimeException 而不包装它。更好的办法是单独捕获所有检查的异常（即使它们很多）。try { doStuff(); } catch(RuntimeException e) { throw e; } catch(Exception e) { throw new RuntimeException(e.getMessage(), e); } try { doStuff(); } catch(IOException e) { throw new RuntimeException(e.getMessage(), e); } catch(NamingException e) { throw new RuntimeException(e.getMessage(), e); }没有正确传播异常try { } catch(ParseException e) { throw new RuntimeException(); throw new RuntimeException(e.toString()); throw new RuntimeException(e.getMessage()); throw new RuntimeException(e); }该代码只是以不同的方式将解析错误包装到运行时异常中。它们都没有为呼叫者提供真正好的信息。第一个只会丢失所有信息。第二种方法可以执行任何操作，具体取决于 toString() 产生什么信息。默认的 toString() 实现列出了完全限定的异常名称，后跟消息。嵌套许多异常将产生笨拙且冗长的字符串，不适合用户。第三只保留消息，总比没有好。最后一个保留原因，但将运行时异常的消息设置为其原因的 toString()（请参见上文）。最有用，最易读的版本是在运行时异常中仅传播原因消息，并将原始异常作为原因传递：try { } catch(ParseException e) { throw new RuntimeException(e.getMessage(), e); }愚蠢的异常消息try { } catch (ParseException e) { throw new RuntimeException("**** --> OMFG something scary happened !!!!11! <---"); }这个异常是没有用的。它不会给呼叫者任何指示原因。相反，它包含 ASCII 艺术和情感用语，对任何人都没有帮助。添加有用的信息或简单地传递原始异常的消息。不要在原始消息前添加自定义“操作失败，因为：”字符串。这没用。并将该字符串添加到常量池中，该常量池将在大型应用程序中充满无用的字符串。字符串是已编译应用程序中的顶级空间使用者。try { } catch (ParseException e) { // for code so it gets access to some context throw new MyException(input, e); // for humans throw new RuntimeException(input +": "+ e.getMessage(), e); // or simply throw new RuntimeException(e.getMessage(), e); }异常捕获与日志记录try { ... } catch(ExceptionA e) { log.error(e.getMessage(), e); throw e; } catch(ExceptionB e) { log.error(e.getMessage(), e); throw e; }此代码仅捕获异常以写出一条日志语句，然后重新引发相同的异常。真傻。让调用者决定消息是否对日志记录很重要并删除整个 try/catch 子句。仅当您知道呼叫者未记录它时，它才有用。如果不是由您控制的框架调用该方法，就是这种情况。如果您因为调用者没有足够的信息来记录日志，那么您的异常类是不合适的：将所有必需的信息一起传递给异常。那就是他们的目的！不完整的异常处理try { is = new FileInputStream(inFile); os = new FileOutputStream(outFile); } finally { try { is.close(); os.close(); } catch(IOException e) { /* we can't do anything */ } }如果未关闭流，则底层操作系统无法释放本机资源。这位程序员想要关闭两个流时要小心。所以他把结束语放在了一个 finally 条款中。但是，如果 is.close() 抛出 IOException，则 os.close 甚至不会执行。两个 close 语句必须包装在它们自己的 try/catch 子句中。此外，如果创建输入流引发异常（因为未找到文件），os 则为 null 并 os.close() 引发 NullPointerException。为了使它不那么冗长，我删除了一些换行符。try { is = new FileInputStream(inFile); os = new FileOutputStream(outFile); } finally { try { if (is != null) is.close(); } catch(IOException e) { /* we can't do anything */ } try { if (os != null) os.close(); } catch(IOException e) { /* we can't do anything */ } }永远不会发生的异常try { ... do risky stuff ... } catch(SomeException e) { // never happens } ... do some more ...在这里，开发人员在 try/catch 块中执行一些代码。他不想将被调用的方法之一声明的异常抛出他的烦恼。由于开发人员很聪明，他知道在他的特定情况下永远不会抛出异常，因此他只是插入一个空的 catch 块。他甚至在空白的 catch 区域中添加了一个不错的注释 - 但它们是著名的遗言...问题是：他如何确定？如果被调用方法的实现发生变化怎么办？如果在某些特殊情况下仍然抛出异常，但他只是没有想到怎么办？在这种情况下，try/catch 之后的代码可能会做错事情。该异常将完全不被注意到。通过在这种情况下抛出运行时异常，可以使代码更加可靠。这就像断言一样，并遵循“快速失败”原则。try { ... do risky stuff ... } catch(SomeException e) { // never happens hopefully throw new IllegalStateException(e.getMessage(), e); // crash early, passing all information } ... do some more ...transient 字段初始化陷阱public class A implements Serializable { private String someState; private transient Log log = LogFactory.getLog(getClass()); public void f() { log.debug("enter f"); ... } }日志对象不可序列化。程序员知道这一点，并正确地将该 log 字段声明为 transient，因此不进行序列化。但是，此变量的初始化发生在类的初始化程序中。反序列化时，不执行初始化程序和构造函数！这将使反序列化的对象具有 null 变量，该变量随后导致 f() 中的 NullPointerException。经验法则：切勿将类初始化与 transient 变量一起使用。您可以在此处通过使用静态变量或通过使用局部变量来解决这种情况：public class A implements Serializable { private String someState; private static final Log log = LogFactory.getLog(A.class); public void f() { log.debug("enter f"); ... } } // 或者 public class A implements Serializable { private String someState; public void f() { Log log = LogFactory.getLog(getClass()); log.debug("enter f"); ... } }过度初始化public class B { private int count = 0; private String name = null; private boolean important = false; }这位程序员曾经使用 C 语言进行编程。因此，他自然希望确保每个变量都已正确初始化。但是，这里没有必要。Java 语言规范保证成员变量会自动使用某些值初始化：0, null, false。通过显式声明它们，程序员使类初始化程序在构造函数之前执行。这是不必要的过度杀伤，应该避免。public class B { private int count; private String name; private boolean important; }日志实例：静态还是非静态？本节已经过编辑，在实际不建议将日志实例存储在静态变量中之前。原来我错了。Mea culpa。我道歉。将织补日志实例存储在静态的 final 变量中并感到高兴。private static final Log log = LogFactory.getLog(MyClass.class);原因如下：自动线程安全。但仅包含 final 关键字！可用于静态和非静态代码。可序列化类没有问题。初始化只需花费一次：getLog() 可能不像您想象的那样便宜。无论如何，没人会卸载 Log 类加载器。选择错误的类加载器Class clazz = Class.forName(name); Class clazz = getClass().getClassLoader().loadClass(name);该代码使用加载当前类的类加载器。getClass() 可能会返回意外的内容，例如子类或动态代理。超出您的控制范围。动态加载其他类时，这几乎不是您想要的。尤其是在诸如应用程序服务器，Servlet 引擎或 Java Webstart 之类的托管环境中，这无疑是错误的。取决于运行的环境，此代码的行为将有很大不同。环境使用上下文类加载器为应用程序提供应用于检索“自己的”类的类加载器。ClassLoader cl = Thread.currentThread().getContextClassLoader(); if (cl == null) cl = MyClass.class.getClassLoader(); // fallback Class clazz = cl.loadClass(name);使用反射不良Class beanClass = ... if (beanClass.newInstance() instanceof TestBean) ...这位程序员正在努力使用反射 API。他需要一种检查继承的方法，但没有找到一种方法。因此，他只是创建一个新实例并使用 instanceof 他习惯的运算符。创建您不知道的类的实例很危险。您永远都不知道该类做什么。它可能非常昂贵。否则默认构造函数可能甚至不存在。然后，此 if 语句将引发异常。进行此检查的正确方法是使用 Class.isAssignableFrom(Class) 方法。它的语义是颠倒的 instanceof。Class beanClass = ... if (TestBean.class.isAssignableFrom(beanClass)) ...同步过度Collection l = new Vector(); for (...) { l.add(object); }Vector 是同步的 ArrayList。并且 Hashtable 是同步的 HashMap。只有明确需要同步时，才应使用两个类。但是，如果将这些集合用作本地临时变量，则同步将完全被淘汰，从而大大降低性能。我测算了 25% 的罚款。Collection l = new ArrayList(); for (...) { l.add(object); }列表类型错误没有示例代码。初级开发人员通常很难选择正确的列表类型。他们通常会选择从非常随意 Vector，ArrayList 和 LinkedList。但是需要考虑性能！当通过索引添加，迭代或访问对象时，实现的行为有很大不同。在此列表中，我将忽略 Vector，因为它的行为类似于 ArrayList，只是速度较慢。注意：n 是列表的大小，而不是操作数！我在这里避免使用 O() 表示法，因为它不能给出正在发生的事情的有用图像。该表列出了列表操作的成本。操作数组列表 (ArrayList)链表 (LinkedList)添加（追加）const 或 ~log(n)（如果增长）const插入（中间）线性或 ~n * log(n)（如果增长）线性的删除（中）线性（始终执行完整复制）线性的重复线性的线性的通过索引获取const线性的ArrayList 的插入性能取决于它在插入期间是否必须增大，或者是否合理设置了初始大小。增长呈指数增长（因数 2），因此增长成本是对数的。但是，指数增长可能会使用比实际需要更多的内存。突然需要调整列表大小也使响应时间变慢，并且如果列表很大，可能会导致大量垃圾回收。遍历列表同样便宜。但是，在链接列表中，对索引列表元素的访问非常慢。内存注意事项：LinkedList 将每个元素包装到包装对象中。ArrayList 每次需要增长时都会分配一个全新的数组，并在每个 remove() 上执行数组复制。所有标准 Collection 都不能重用其 Iterator 对象，这可能导致 Iterator 流失，尤其是在递归迭代大型树结构时。我个人几乎从不使用 LinkedList。仅当您想在列表中间插入对象时，这才有意义。但是，由于无法访问包装对象，因此无法缩放并且具有线性成本，因为必须首先遍历列表，直到找到插入位置。那么，LinkedList 类的确切含义是什么？我建议仅使用 ArrayLists。HashMap 大小陷阱Map map = new HashMap(collection.size()); for (Object o : collection) { map.put(o.key, o.value); }该开发人员有良好的意愿，并希望确保不需要调整 HashMap 的大小。因此，他将其初始大小设置为要放入其中的元素数量。不幸的是，HashMap 实现并不完全像这样。它将内部阈值设置为 threshold = (int)(capacity * loadFactor)。因此，在将集合的 75% 插入地图后，它将调整大小。因此，以上代码将始终导致额外的垃圾。Map map = new HashMap(1 + (int) (collection.size() / 0.75));Hashtable、HashMap 和 HashSet 被高估了这些课程非常受欢迎。因为它们对于开发人员来说具有很大的可用性。不幸的是，它们的效率也非常差。当您有 100 个或更多条目时，哈希表将变得很有用。但是不只是一些要素。在典型的代码中，此类集合包含大约 10 个条目 - 适合 CPU 缓存行！Hashtable 和 HashMap 将每个键/值对包装到 Entry 包装器对象中。Entry 对象非常大。它不仅保存对键和值的引用，还存储哈希码和对哈希存储桶下一个 Entry 的正向引用。当您使用内存分析器查看堆转储时，您会为它们在大型应用程序（如应用程序服务器）中浪费多少空间而感到震惊。在使用任何这些类之前，请三思。IdentityHashMap 可能是一个可行的选择。但请注意，它有意破坏 Map 界面。通过实现开放的哈希表（无存储桶），不需要 Entry 包装器并使用简单的 Object[] 作为其后端，可以提高内存效率。代替 HashSet，简单的 ArrayList 可以做得很好（可以使用 contains(Object)），只要它很小并且查找很少。对于仅包含少量条目的 Set 来说，整个哈希处理是多余的，并且 HashMap 后端加上包装器对象所浪费的内存只是胡说八道。只需使用 ArrayList 甚至是数组即可。实际上，在标准 JDK 中没有有效的 Map 和 Set 实现真是可惜！列表被高估List 的实现也很受欢迎。但是，甚至列表通常也没有必要。简单数组也可以。我并不是说您根本不应该使用列表。他们很棒。但是知道何时使用数组。以下是应该使用数组而不是列表的指示符：列表的大小固定。例如：星期几。一组常数。该列表经常被遍历（10,000 次）。该列表包含数字的包装对象（没有原始类型的列表）。让我用代码说明一下：List<Integer> codes = new ArrayList<Integer>(); codes.add(Integer.valueOf(10)); codes.add(Integer.valueOf(20)); codes.add(Integer.valueOf(30)); codes.add(Integer.valueOf(40)); // versus int[] codes = { 10, 20, 30, 40 }; // horribly slow and a memory waster if l has a few thousand elements (try it yourself!) List<Mergeable> l = ...; for (int i=0; i < l.size()-1; i++) { Mergeable one = l.get(i); Iterator<Mergeable> j = l.iterator(); // memory allocation! while (j.hasNext()) { Mergeable other = j.next(); if (one.canMergeWith(other)) { one.merge(other); other.remove(); } } } // versus // quite fast and no memory allocation Mergeable[] l = ...; for (int i=0; i < l.length-1; i++) { Mergeable one = l[i]; for (int j=i+1; j < l.length; j++) { Mergeable other = l[j]; if (one.canMergeWith(other)) { one.merge(other); l[j] = null; } } }您将保存一个额外的列表对象（包装一个数组），包装对象以及可能的许多迭代器实例。甚至 Sun 也意识到了这一点。这就是为什么 Collections.sort()) 实际上将列表复制到数组中并对数组执行排序的原因。对象数组非常灵活/** * @returns [1]: Location, [2]: Customer, [3]: Incident */ Object[] getDetails(int id) {...即使有文档记录，这种从方法传回值的方法也是丑陋且容易出错的。您应该真正声明一个将对象结合在一起的小类。这类似于 C 中的 struct。Details getDetails(int id) {...} private class Details { public Location location; public Customer customer; public Incident incident; }对象过早分解public void notify(Person p) { ... sendMail(p.getName(), p.getFirstName(), p.getEmail()); ... } class PhoneBook { String lookup(String employeeId) { Employee emp = ... return emp.getPhone(); } }在第一个示例中，分解对象只是将其状态传递给方法是很痛苦的。在第二个示例中，此方法的使用非常有限。如果总体设计允许，它可以通过对象本身。public void notify(Person p) { ... sendMail(p); ... } class EmployeeDirectory { Employee lookup(String employeeId) { Employee emp = ... return emp; } }修改 Setterprivate String name; public void setName(String name) { this.name = name.trim(); } public String getName() { return this.name; }这个可怜的开发人员在用户输入的名称的开头或结尾出现空格。他认为自己很聪明，只是删除了 bean 的 setter 方法内部的空格。但是，一个修改数据而不只是保存数据的 bean 有多奇怪？现在，getter 返回的数据与 setter 设置的数据不同！如果这是在 EJB3 实体 Bean 中完成的，则从数据库中进行简单的读取实际上会修改数据：对于每个 INSERT，将有一个 UPDATE 语句。更不用说调试这些副作用有多困难了！通常，bean 不应修改其数据。它是一个数据容器，而不是业务逻辑。在有意义的地方进行微调：在发生输入的控制器中或在不需要空格的逻辑中。person.setName(textInput.getText().trim());不必要的 CalendarCalendar cal = new GregorianCalendar(TimeZone.getTimeZone("Europe/Zurich")); cal.setTime(date); cal.add(Calendar.HOUR_OF_DAY, 8); date = cal.getTime();开发人员的一个典型错误，他们对日期，时间，日历和时区感到困惑。要将 8 小时添加到日期，则不需要日历。任何相关的时区也不是。（想想如果您不理解这一点！）但是，如果我们想增加天数（而不是小时数），我们将需要一个日历，因为我们不确定一天的时长（在 DST 更改的日子，可能会有 23 或 25 小时）。date = new Date(date.getTime() + 8L * 3600L * 1000L); // add 8 hrs Calendar cal = new GregorianCalendar(TimeZone.getTimeZone("Europe/Zurich")); SimpleDateFormat df = new SimpleDateFormat("dd.MM.yyyy HH:mm"); df.setCalendar(cal);在这里，Calendar 对象是完全不必要的。DateFormat 对象已经包含一个 Calendar 实例。再用一次。SimpleDateFormat df = new SimpleDateFormat("dd.MM.yyyy HH:mm"); df.setTimeZone(TimeZone.getTimeZone("Europe/Zurich"));依赖默认的时区Calendar cal = new GregorianCalendar(); cal.setTime(date); cal.set(Calendar.HOUR_OF_DAY, 0); cal.set(Calendar.MINUTE, 0); cal.set(Calendar.SECOND, 0); Date startOfDay = cal.getTime();开发人员想要计算一天的开始时间（0h00）。首先，他显然错过了日历的毫秒字段。但是，真正的大错误不是设置 Calendar 对象的 TimeZone。日历将因此使用默认时区。在桌面应用程序中，这可能很好，但是在服务器端代码中，这几乎不是您想要的：在上海，与伦敦相比，0h00 的时刻与现在截然不同。开发人员需要检查哪个时区与此计算相关。Calendar cal = new GregorianCalendar(user.getTimeZone()); cal.setTime(date); cal.set(Calendar.HOUR_OF_DAY, 0); cal.set(Calendar.MINUTE, 0); cal.set(Calendar.SECOND, 0); cal.set(Calendar.MILLISECOND, 0); Date startOfDay = cal.getTime();时区“转换”public static Date convertTz(Date date, TimeZone tz) { Calendar cal = Calendar.getInstance(); cal.setTimeZone(TimeZone.getTimeZone("UTC")); cal.setTime(date); cal.setTimeZone(tz); return cal.getTime(); }如果您认为此方法有帮助，请阅读 有关 time 的文章。该开发人员尚未阅读本文，并拼命尝试“固定”其约会的时区。实际上，该方法不执行任何操作。返回的日期将与输入没有任何不同的值。因为日期不包含时区信息。它始终是 UTC。Calendar 的 getTime/setTime 方法始终在 UTC 与 Calendar 的实际时区之间进行转换。使用 Calendar.getInstance()Calendar c = Calendar.getInstance(); c.set(2009, Calendar.JANUARY, 15);此代码假定使用公历。但是，如果返回的 Calendar 子类是佛教，儒略，希伯来语，伊斯兰，伊朗或 Discordian 日历，该怎么办？在这些年份中，2009 年具有非常不同的含义。而且一个月称为一月不存在。Calendar.getInstance() 使用当前的默认语言环境选择适当的实现。这取决于可用的 Java 实现。因此 Calendar.getInstance() 的实用程序非常有限，应避免使用它，因为其结果定义不明确。Calendar c = new GregorianCalendar(timeZone); c.set(2009, Calendar.JANUARY, 15);危险 Calendar 操作GregorianCalendar cal = new GregorianCalendar(TimeZone.getTimeZone("Europe/Zurich")); cal.set(Calendar.SECOND, 0); cal.set(Calendar.MILLISECOND, 0); if (cal.before(other)) doSomething(); cal.setTimeZone(TimeZone.getTimeZone("GMT")); cal.set(Calendar.HOUR_OF_DAY, 23); Date d = cal.getTime();此代码以一定方式处理 Calendar 对象，这些方法必然会产生未定义的结果。日历对象具有复杂的内部状态：日期，小时，年等的各个字段，历元值（如日期）后的毫秒数和时区。根据您所做的更改，这些字段中的某些是无效的，并且仅当您调用某些方法时才从其他值重新计算：set() 自历元值和相关字段以来的毫秒数无效（更改 DATE 显然会使 DAY_OF_WEEK 无效）setTimeZone() 使所有字段均无效，从纪元值开始执行的毫秒数get(), getTime(), getTimeInMillis(), add(), roll() 从字段重新计算自纪元以来的毫秒数get(), add() 还会重新计算自纪元以来的毫秒数内的无效字段当您更改与字段 set()，然后 dependend 领域没有得到更新，直到你打电话 get(), getTime(), getTimeInMillis(), add(), 或 roll()。上面代码调用的第一段，set() 后跟 before()。根据 API 文档，不能保证 before() 会看到修改后的时间值。第二段通过调用 setTimeZone() 和 set()，使历元值后的所有字段和毫秒无效，从而完全丢失日历的数据。另请参见 错误 4827490日历对象应始终根据以下简单规则进行操作：在构造函数中已经初始化了 TimeZone（如果需要，还可以初始化 Locale）通话后将通话 set() 添加到 getTimeInMillis()通话后将通话 setTimeZone() 添加到 get()GregorianCalendar cal = new GregorianCalendar(TimeZone.getTimeZone("Europe/Zurich")); cal.set(Calendar.SECOND, 0); cal.set(Calendar.MILLISECOND, 0); cal.getTimeInMillis(); if (cal.before(other)) doSomething(); cal.setTimeZone(TimeZone.getTimeZone("GMT")); cal.get(Calendar.DATE); cal.set(Calendar.HOUR_OF_DAY, 23); Date d = cal.getTime();调用 Date.setTime()account.changePassword(oldPass, newPass); Date lastmod = account.getLastModified(); lastmod.setTime(System.currentTimeMillis());上面的代码更新了帐户实体的最后修改日期。程序员希望保持保守，并避免创建新 Date 对象。相反，她使用的 setTime 方法来修改现有 Date 实例。实际上，这没有错。但我只是不推荐这种做法。日期对象通常会粗心传递。可以将同一 Date 实例传递给许多对象，这些对象不会在其设置器中进行复制。日期通常像图元一样使用。因此，如果您修改 Date 实例，则使用该实例的其他对象可能会出现异常情况。当然，如果您编写的代码严格遵循经典的 OO 原理（我认为这很不方便），那么如果对象将其固有的 Date 实例暴露给外界，那将是不干净的设计。但是，常规的日常 Java 做法是仅复制 Date 引用，而不用 setter 克隆对象。因此，每个程序员都应将 Date 视为不可变的，并且不应修改现有实例。仅出于特殊情况下的性能原因，才应这样做。即使那样简单的使用 long 可能同样好。account.changePassword(oldPass, newPass); account.setLastModified(new Date());假设 SimpleDateFormat 是线程安全的public class Constants { public static final SimpleDateFormat date = new SimpleDateFormat("dd.MM.yyyy"); }上面的代码有几种缺陷。坏了，因为它与任何数量的线程共享一个 SimpleDateFormat 的静态实例。SimpleDateFormat 不是线程安全的。如果多个线程同时使用此对象，则结果是不确定的。您可能会发现从输出陌生 format 和 parse 甚至例外。不幸的是，这个错误很常见！是的，共享 SimpleDateFormat 需要正确的同步。是的，需要付出一定的代价（缓存刷新，锁争用等）。是的，创建 SimpleDateFormat 也不是免费的（模式解析，对象分配）。但是，简单地忽略线程安全性不是解决方案，而是破解代码的肯定方法。当然，此代码也没有考虑时区。然后定义一个名为"Constants"的类，它发出另一个反模式的尖叫（请参阅下一节）。具有全局 Configuration/Parameters/Constants 类public interface Constants { String version = "1.0"; String dateFormat = "dd.MM.yyyy"; String configFile = ".apprc"; int maxNameLength = 32; String someQuery = "SELECT * FROM ..."; }在大型项目中经常见到：一个类或接口，包含在整个应用程序中使用的各种常量。为什么这样不好？因为这些常量彼此无关。此类是他们唯一的共同点。对该类的引用将再次污染应用程序的许多不相关组件。您要稍后提取一个组件并在其他应用程序中使用它吗？还是在服务器和远程客户端之间共享一些类？您可能还需要提供常量类！此类在其他不相关的组件之间引入了依赖关系。这抑制了重用和松耦合，并让混乱。而是将常量放在它们所属的位置。在任何情况下，均不得跨组件边界使用常量。仅当组件是需要显式依赖的库时才允许这样做。没有注意到溢出public int getFileSize(File f) { long l = f.length(); return (int) l; }无论出于何种原因，该开发人员都将确定文件大小的调用包装到返回 int 而不是 long 的方法中。此代码不支持大于 2 GB 的文件，在这种情况下只会返回错误的长度。将值强制转换为较小大小类型的代码必须首先检查可能的溢出并引发异常。public int getFileSize(File f) { long l = f.length(); if (l > Integer.MAX_VALUE) throw new IllegalStateException("int overflow"); return (int) l; }以下是溢出错误的另一个版本。请注意第一个 println 语句中缺少的括号。long a = System.currentTimeMillis(); long b = a + 100; System.out.println((int) b-a); System.out.println((int) (b-a)); 最后，是我在代码审查过程中连根拔起的真正瑰宝。请注意程序员是如何尝试小心的，但随后由于假设一个 int 可能变得大于其最大值而严重失败。int a = l.size(); a = a + 100; if (a > Integer.MAX_VALUE) throw new ArithmeticException("int overflow");将 == 与 float 或 double 一起使用for (float f = 10f; f!=0; f-=0.1) { System.out.println(f); }上面的代码无法正常工作。它导致无限循环。因为 0.1 是一个 无限的二进制十进制数，所以 f 永远不会完全为 0。通常，您永远不要将浮点或双精度值与相等运算符 == 进行比较。始终使用小于或大于。在这种情况下，应更改 Java 编译器以发出警告。甚至使 Java 语言规范中的浮点类型的 == 非法操作成为可能。拥有此功能真的没有任何意义。for (float f = 10f; f>0; f-=0.1) { System.out.println(f); }将钱存入浮点变量float total = 0.0f; for (OrderLine line : lines) { total += line.price * line.count; } double a = 1.14 * 75; // 85.5 represented as 85.4999... System.out.println(Math.round(a)); // surprising output: 85 System.out.println(10.0/3); // surprising output: 3.3333333333333335 (precision lost twice during division and on conversion to decimal) BigDecimal d = new BigDecimal(1.14); // precision has already been lost我已经看到许多开发人员编写了这样的循环。包括我早年的我自己。当此代码将 100 条订单行相加，每行包含一个 0.30 $ 项时，得出的总和将精确计算为 29.999971。开发人员注意到异常行为，并将 float 更改为更精确的 double 值，仅得到结果 30.000001192092896。当然，由于人（十进制格式）和计算机（二进制格式）在数字表示上的差异，有些令人惊讶的结果。当您增加零用钱或计算增值税时，它总是以其最令人讨厌的形式出现。对于固有的不精确 值（例如测量值），发明了浮点数的二进制表示形式。完美的工程！但是当您需要精确的数学运算时无法使用。像银行。或数数时。在某些业务案例中，您不能失去精确度。在十进制和二进制之间进行转换时，以及在舍入方式不明确或不明确的点上进行舍入时，您将失去精度。为了避免精度下降，您必须使用定点或整数运算。这不仅适用于货币价值，而且在业务应用程序中经常引起烦恼，因此是一个很好的例子。在第二个示例中，程序的一个毫无戒心的用户只会说计算机的计算器坏了。对于程序员来说，这当然是很尴尬的。因此，永远不要 以浮点数据类型（浮点数，双精度数）存储金额。请注意，不仅任何计算都是不精确的。即使是简单的整数乘法也可能产生不精确的结果。仅以二进制表示形式（浮点数，双精度）存储 值的事实 可能已经导致了舍入！您根本无法将 0.3 作为精确值存储在 float 或 double 中。因为 float 和 double 是 二进制 IEEE754 类型。另请参阅 此处。您可以在 此处尝试 各种数字及其 二进制表示形式。如果您在财务代码库中看到浮动或双精度，则该代码很可能会产生不精确的结果。相反，应该选择字符串或定点表示形式。文本表示形式必须采用定义明确的格式，并且不得 与区域设置特定格式的用户输入/输出混淆。两种表示形式都必须定义所存储的精度（小数点前后的位数）。为了进行计算，BigDecimal 类 提供了一种出色的工具。可以使用该类，以便在操作中意外丢失精度时引发运行时异常。这对于消除细微的数字错误非常有用，并使开发人员可以更正计算结果。BigDecimal total = BigDecimal.ZERO; for (OrderLine line : lines) { BigDecimal price = new BigDecimal(line.price); BigDecimal count = new BigDecimal(line.count); total = total.add(price.multiply(count)); // BigDecimal is immutable! } total = total.setScale(2, RoundingMode.HALF_UP); BigDecimal a = (new BigDecimal("1.14")).multiply(new BigDecimal(75)); // 85.5 exact a = a.setScale(0, RoundingMode.HALF_UP); // 86 System.out.println(a); // correct output: 86 BigDecimal a = new BigDecimal("1.14");在 finally 块中不释放资源public void save(File f) throws IOException { OutputStream out = new BufferedOutputStream(new FileOutputStream(f)); out.write(...); out.close(); } public void load(File f) throws IOException { InputStream in = new BufferedInputStream(new FileInputStream(f)); in.read(...); in.close(); }上面的代码打开输出流到文件，在操作系统中分配文件句柄。文件句柄是一种稀有资源，需要通过在 FileOutputStream 上调用 close 来正确释放（当然，与 FileInputStream 相同）。为了确保即使发生异常（在写操作期间文件系统可能已满），也必须在 finally 块中进行关闭。在此，流还被包装到缓冲流中。这意味着到我们到达磁盘时，并非所有数据都已被写入磁盘。close() 呼叫。close 调用本身会将缓冲区中的待处理数据刷新到磁盘，因此自身可能会失败并出现 IOException。如果关闭失败，则磁盘上的文件不完整（被截断），因此很可能已损坏。因此，在这种情况下，该方法应传播 IOException。对于 FileInputStream，我们可以从 close() 调用中安全地忽略潜在的 IOException。我们已经读取了所需的所有数据，并且如果底层的 close() 仍然失败，我们将无能为力。甚至不值得记录它。在一个完美的世界 BufferedOutputStream.close() 中将正确实施。但是可悲的是，它有一个无法 修复的错误：它从隐式刷新中丢失了任何 IOException，并以静默方式截断了文件。因此，在这里，我们在关闭之前使用显式刷新给出适当的解决方法。确切地说，下面的更正代码可能会在一个很小的情况下泄漏：分配文件流但随后分配缓冲的流神秘地失败（例如，内存不足）。作为一个务实的人，我认为在这种病态的情况下，我们可以安全地依靠垃圾收集器来清理垃圾。处理它是不值得的麻烦。// code for your cookbook public void save() throws IOException { File f = ... OutputStream out = new BufferedOutputStream(new FileOutputStream(f)); try { out.write(...); out.flush(); // don't lose exception by implicit flush on close } finally { out.close(); } } public void load(File f) throws IOException { InputStream in = new BufferedInputStream(new FileInputStream(f)); try { in.read(...); } finally { try { in.close(); } catch (IOException e) { } } }让我也为您提供另一种普遍使用的菜谱：数据库访问。同样，这是实用的方法。是的，rs.close() 可能会因神秘的错误而失败，除非它们仅出现在您关于量子力学的大学讲座中，而不是出现在《真实世界》（tm）中。而且只有变态者才会写出尝试/最终级联，即没有错误中微子无法逃脱。原谅我的嘲讽。一劳永逸的方法是处理 SQL 对象：Car getCar(DataSource ds, String plate) throws SQLException { Car car = null; Connection c = null; PreparedStatement s = null; ResultSet rs = null; try { c = ds.getConnection(); s = c.prepareStatement("select make, color from cars where plate=?"); s.setString(1, plate); rs = s.executeQuery(); if (rs.next()) { car = new Car(); car.make = rs.getString(1); car.color = rs.getString(2); } } finally { if (rs != null) try { rs.close(); } catch (SQLException e) { } if (s != null) try { s.close(); } catch (SQLException e) { } if (c != null) try { c.close(); } catch (SQLException e) { } } return car; }话虽如此，不要错过下一段。滥用 finalize()public class FileBackedCache { private File backingStore; ... protected void finalize() throws IOException { if (backingStore != null) { backingStore.close(); backingStore = null; } } }此类使用该 finalize 方法来释放文件句柄。问题是您不知道何时调用该方法。该方法由垃圾收集器调用。如果您用完了文件句柄，则希望此方法尽快被调用。但是 GC 可能只会在堆快用完时才调用该方法，这是非常不同的情况。从 GC 到完成定稿，可能需要几毫秒到几天的时间。垃圾收集器仅管理内存。它做得很好。但是，不得滥用它来管理除此之外的任何其他资源。GC 不是通用的资源管理机制！ 在这方面，我发现 Sun 的 finalize 方法的 API Doc 非常令人误解。它实际上建议使用此方法关闭 I/O 资源 - 如果您问我，请完全废话。再次：I/O 与内存无关！更好的代码提供了一个公共关闭方法，必须由定义良好的生命周期管理（例如 JBoss MBeans）调用。public class FileBackedCache { private File backingStore; ... public void close() throws IOException { if (backingStore != null) { backingStore.close(); backingStore = null; } } }JDK 1.7（Java 7）引入了 AutoClosable 接口。close 当变量（不是对象）超出 try-with-resource 块的范围时，它启用对方法的自动调用。它与终结器有很大不同。它的执行时间在编译时已明确定义。try (Writer w = new FileWriter(f)) { // implements Closable w.write("abc"); // w goes out of scope here: w.close() is called automatically in ANY case } catch (IOException e) { throw new RuntimeException(e.getMessage(), e); }不由自主地重置 Thread.interruptedtry { Thread.sleep(1000); } catch (InterruptedException e) { // ok } // or while (true) { if (Thread.interrupted()) break; }上面的代码重置线程的中断标志。随后的读者将不知道该线程已被中断。如果您需要传递有关中断的信息，请像这样重写代码。try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // or while (true) { if (Thread.currentThread().isInterrupted()) break; }来自静态初始值设定项的生成线程class Cache { private static final Timer evictor = new Timer(); }java.util.Timer 在其构造函数中增加了一个新线程。因此，以上代码在其静态初始化程序中产生了一个新线程。新线程将从其父级继承一些属性：上下文类加载器，可继承的 ThreadLocals 和一些安全属性（访问权限）。因此，很少希望以不受控制的方式设置这些属性。例如，这可能会阻止类加载器的 GC。静态初始化程序由首先加载类的线程（在任何给定的 ClassLoader 中）执行，该线程可以是例如来自 Web 服务器线程池的完全随机的线程。如果要控制这些线程属性，则必须在静态方法中启动线程，并控制谁在调用该方法。class Cache { private static Timer evictor; public static setupEvictor() { evictor = new Timer(); } }取消了保持状态的计时器任务final MyClass callback = this; TimerTask task = new TimerTask() { public void run() { callback.timeout(); } }; timer.schedule(task, 300000L); try { doSomething(); } finally { task.cancel(); }上面的代码使用计时器在 doSomething() 上强制超时。TimerTask 包含一个对外部类的（隐式）实例引用。因此，只要 TimerTask 存在，就不会对 MyClass 的实例进行 GC 处理。不幸的是，Timer 可能会一直取消被取消的 TimerTasks，直到它们的预定超时时间结束！这会使程序有 5 分钟的时间悬空地引用 MyClass 实例，在此期间它无法被收集！这是暂时的内存泄漏。更好的 TimerTask 会覆盖 cancel() 方法，并在那里引用无效。它需要更多的代码。TimerTask task = new Job(this); timer.schedule(task, 300000L); try { doSomething(); } finally { task.cancel(); } static class Job extends TimerTask { private volatile MyClass callback; public Job(MyClass callback) { this.callback = callback; } public boolean cancel() { callback = null; return super.cancel(); } public void run() { MyClass cb = callback; if (cb == null) return; cb.timeout(); } }拥有对 ClassLoader 和无法刷新的缓存的强大引用在像应用程序服务器或 OSGI 这样的动态系统中，应格外小心，不要阻止 ClassLoader 进行垃圾回收。在应用程序服务器中取消部署和重新部署各个应用程序时，将为它们创建新的类加载器。旧的未使用，应该收集。如果从容器代码到您的应用程序代码中只有一个悬挂引用，那么 Java 不会让这种情况发生。由于在整个企业应用程序中使用了各种库，因此直接意味着，库应尽其最大努力，不要保留对对象（以及它们的类加载器）的非自愿强引用。这不容易。诸如 java.beans.Introspector 来自 JDK 或 org.apache.commons.beanutils.PropertyUtils 来自 Apache BeanUtils 或 org.springframework.beans.CachedIntrospectionResults 来自 Spring 的类实现了缓存，以加速其内部工作。它们对您传递给它们进行分析的类保持强烈的引用。幸运的是，它们提供了刷新其缓存的方法。但是，找到所有可能具有内部缓存的类并在适当的时间刷新它们对于开发人员来说几乎是不可能的工作。如果您碰巧是 org.apache.commons.el.BeanInfoManager 从 Apache Commons EL 使用的，则可能存在泄漏。这个古老的类保留了强大的引用缓存，这些引用只会在内存不足之前不断增长。而且它没有冲洗方法。甚至 Tomcat 也必须实现一种涉及反射的 变通办法 以对其进行清理。如果这些库首先只使用软引用或弱引用，那就更好了。快速提醒：软引用和弱引用在其为零的时间点上基本上有所不同。WeakReference：在最后一个对对象的强引用消失时，或多或少同时将其为 null。典型用于类加载器引用（如果没有类加载器，则类加载器有什么用）。但是，如果在 ClassLoader 实现中使用它， 则要小心。SoftReference：只要内存允许，即使最后一个对对象的强引用消失，该引用也会保留。典型用于缓存。仅当库仅从其自己的包中缓存对象（没有外部引用）时，最好不使用这些特殊引用，而仅使用普通引用。使用软引用或弱引用也有助于应用程序的运行时行为：如果内存变紧，则要在内存上花费的最后一件事就是缓存。因此，垃圾收集器将在必要时回收缓存使用的内存。这里的一个不好的例子是 JBoss 的 SQL 语句缓存：它是完全静态的，并且即使在很紧的情况下也可以使用很多内存。另一个不好的例子是 JBoss 的身份验证缓存。而且，每个静态缓存都必须始终提供一种刷新其内容的简单方法。（干净的）缓存（与例如写入缓存相反）的本质是其内容不重要，可以随时安全地丢弃。缓存的限制是另一个陷阱。高速缓存永远都不会变大，也永远不会高速缓存对象。这是一个非常糟糕的示例，它是 JDK DNS 缓存的默认设置（它完全忽略 DNS 记录的生存期，并将否定查询永远存储在无边界列表中）。您的 API 文档应说明是否以及何时进行缓存。这也有助于用户估计运行时性能。嵌套同步语句class Message { private long id; ... public synchronized int compareTo(Message that) { synchronized (that) { return Long.compare(id, that.id); } } }上面的代码想提供一个线程安全的 compareTo() 方法。开发人员意识到对 id 字段的访问需要在所属实例上进行同步。因此，这里有两个嵌套的同步语句，一个用于保护 this.id，一个用于保护 that.id。不幸的是，该代码在被多个线程使用时会迅速死锁。我们想在这里支持多线程。当线程 1 a.compareTo(b) 和线程 2 都这样做时，b.compareTo(a) 它们将尝试以相反的顺序获得对 a 和 b 的锁定，并且将死锁。记住锁定规则一：所有线程必须始终以相同的顺序进行锁定。我们可以重写该方法，以便完全不嵌套同步语句。public int compareTo(Message that) { long a; long b; synchronized (this) { a = this.id; } synchronized (that) { b = that.id; } return Long.compare(a, b); }通过 RandomAccessFile 进行随机文件访问RandomAccessFile raf = new RandomAccessFile(f, "r"); for (...) { raf.seek(pos); byte b = raf.readByte(); }尽管它的名称如此，但 java.io.RandomAccessFile 该类不太适合以随机访问的方式访问文件。即：查找，读取，查找，读取等。这些命令中的每一个都直接在文件描述符上发出相应的系统调用/ioctl。每个 C 程序员都知道这种文件访问速度很慢，应将其替换为内存映射文件访问。您可以通过 MappedByteBuffer 在 Java 中完成此操作。在我的笔记本电脑上，速度快了 50 倍。FileInputStream in = new FileInputStream(f); MappedByteBuffer map = in.getChannel().map(MapMode.READ_ONLY, 0, f.length()); for (...) { byte b = map.get(pos); }说明：本文部分示例基于较早的 Java 版本（如 Java 1.5/1.7/8）。现代 Java（Java 8+）引入了许多新特性（如 try-with-resources、DateTimeFormatter、Stream API 等），可更好地解决部分问题（如资源管理、日期时间处理）。但文中关于内存管理、异常处理、并发安全及算法复杂度的核心原则依然适用。
探索Java性能优化：技巧与实例全解析-深入篇
2. 深入优化技巧篇2.1 方法内联优化现代 Java 编译器（JIT）与 JVM 会尝试进行方法内联（Method Inlining）优化。对于一些简单且被频繁调用的小方法，编译器将其代码直接嵌入到调用处，从而减少方法调用的栈帧开销。例如：public class MethodInlineExample { private static int add(int a, int b) { return a + b; } public static void main(String[] args) { int result = add(3, 5); // 编译器可能会将 add 方法内联，直接计算 3 + 5 System.out.println(result); } }在实际开发中，建议尽量保持方法的简洁性，避免过度复杂的逻辑，以利于编译器进行内联优化。2.2 逃逸分析与栈上分配逃逸分析（Escape Analysis）是 JVM 的一项关键优化技术。通过分析对象的作用域，JVM 确定对象是否会逃逸出方法或线程。如果一个对象不会逃逸出方法，它可能会被分配在栈上（标量替换），而不是堆上。这样可以减少垃圾回收（GC）的压力，显著提高性能。例如：public class EscapeAnalysisExample { public static void main(String[] args) { for (int i = 0; i < 1000000; i++) { // 这里的 StringBuilder 对象可能会被栈上分配 StringBuilder sb = new StringBuilder(); sb.append("Hello"); sb.append("World"); } } }在此例中，由于 StringBuilder 对象在循环内创建且生命周期仅限于方法内部，不会逃逸，JVM 可能会将其分配在栈上或通过标量替换优化，从而提升性能。2.3 锁优化策略在多线程编程中，锁的使用不可避免地带来性能开销。我们可以采用以下锁优化策略来降低竞争成本：减小锁的粒度：例如，在一个包含多个独立操作的类中，如果多个线程只对其中部分操作有竞争，可以将这些操作分别用不同的锁保护，而不是使用一个大锁保护整个类。读写锁：当数据的读取操作远远多于写入操作时，使用读写锁（ReentrantReadWriteLock）可以提高并发性能。多个线程可以同时获取读锁进行读取操作，但写入操作时需要获取写锁，且写锁会独占资源，防止读写冲突。锁粗化：如果在一个循环内频繁地获取和释放同一把锁，可以将锁的获取和释放操作放在循环外，减少锁操作的次数。但需注意不要过度粗化锁，以免降低并发度。2.4 避免伪共享问题伪共享（False Sharing）是多线程编程中容易被忽视的性能问题。由于现代 CPU 的缓存行（Cache Line）机制，当多个线程访问不同变量但这些变量位于同一个缓存行时，可能会导致缓存行的频繁失效和重新加载，严重影响性能。我们可以通过填充字节（Padding）的方式，使得不同线程访问的变量分布在不同的缓存行。例如：public class FalseSharingExample { public static class Data { // 使用 volatile 保证可见性 volatile long value; // 填充字节，避免与其他 Data 对象的 value 变量位于同一缓存行 long padding1, padding2, padding3, padding4, padding5, padding6, padding7; } public static void main(String[] args) throws InterruptedException { Data[] dataArray = new Data[2]; dataArray[0] = new Data(); dataArray[1] = new Data(); Thread t1 = new Thread(() -> { for (int i = 0; i < 100000000; i++) { dataArray[0].value = i; } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 100000000; i++) { dataArray[1].value = i; } }); t1.start(); t2.start(); t1.join(); t2.join(); } }在此例中，通过在 Data 类中添加填充字节，增加了对象大小，减少了伪共享的可能性，从而提高了多线程访问的性能。2.5 利用 CAS 操作实现无锁编程CAS（Compare and Swap）操作是一种乐观锁机制，可以在不使用传统锁的情况下实现原子性操作。在 Java 中，java.util.concurrent.atomic 包提供了一些基于 CAS 操作的原子类，如 AtomicInteger、AtomicLong 等。例如：import java.util.concurrent.atomic.AtomicInteger; public class CASExample { public static void main(String[] args) { AtomicInteger atomicInteger = new AtomicInteger(0); // 使用 CAS 操作进行自增 for (int i = 0; i < 1000000; i++) { atomicInteger.getAndIncrement(); } System.out.println(atomicInteger.get()); } }通过使用 CAS 操作，避免了传统锁的阻塞等待，提高了并发性能。但需要注意 CAS 操作可能存在的 ABA 问题，可以通过添加版本号等方式解决。2.6 优化对象创建与销毁除了重用对象和避免在循环中创建大对象外，还可以通过对象池技术来优化对象的创建与销毁。对象池预先创建一定数量的对象，当需要对象时从池中获取，使用完毕后归还到池中，而不是频繁地创建和销毁对象。例如，对于一些数据库连接、线程等资源，可以使用对象池进行管理。import java.util.ArrayList; import java.util.List; public class ObjectPool<T> { private List<T> pool; private Class<T> objectClass; public ObjectPool(Class<T> objectClass, int initialSize) { this.objectClass = objectClass; pool = new ArrayList<>(); for (int i = 0; i < initialSize; i++) { try { pool.add(objectClass.newInstance()); } catch (InstantiationException | IllegalAccessException e) { e.printStackTrace(); } } } public T getObject() { if (pool.isEmpty()) { try { return objectClass.newInstance(); } catch (InstantiationException | IllegalAccessException e) { e.printStackTrace(); } } return pool.remove(pool.size() - 1); } public void returnObject(T object) { pool.add(object); } }在使用对象池时，需要注意对象的状态管理，确保归还到池中的对象处于可用状态，避免脏数据污染池中的对象。2.7 优化反射性能反射在 Java 中虽然强大，但性能相对较低。为了提高反射性能，可以采取以下措施：缓存反射结果：对于频繁使用的反射操作，如获取类的方法、字段等，将反射结果缓存起来，避免重复获取。设置可访问性：在使用反射访问私有成员时，先调用 setAccessible(true) 方法，可提高访问速度，但需要注意安全性。使用 MethodHandle：从 Java 7 开始，MethodHandle 提供了一种更高效的动态调用方法的方式，可以替代部分反射操作，尤其是在频繁调用方法的场景下。2.8 优化网络 I/O 操作在网络编程中，除了减少不必要的网络请求和优化数据传输格式外，还可以采用以下优化措施：使用非阻塞 I/O：如 Java NIO（New I/O）库提供的非阻塞 I/O 操作，可以在等待 I/O 完成时不阻塞线程，提高线程的利用率。例如，使用 Selector 可以同时监听多个通道的事件，实现单线程处理多个网络连接。优化缓冲区大小：根据网络数据的特点，合理设置缓冲区大小，避免缓冲区过小导致频繁读写，也避免缓冲区过大浪费内存资源。使用连接复用：在 HTTP 协议中，使用长连接（Keep-Alive）可以减少连接建立和关闭的开销，提高网络传输效率。2.9 优化数据库查询性能进一步优化数据库查询性能，可以从以下方面入手：数据库连接池优化：合理配置连接池参数，如最大连接数、最小连接数、连接超时时间等。根据应用的并发量和数据库服务器的性能，调整这些参数，确保在高并发情况下不会因为连接不足而导致性能下降，同时也不会因为连接过多而浪费资源。查询语句优化：深入分析查询执行计划，使用 EXPLAIN 语句（不同数据库有不同的语法）查看数据库如何执行查询。根据执行计划优化查询语句，如添加合适的索引、避免全表扫描、优化子查询和连接查询等。例如，对于多表连接查询，选择合适的连接顺序和连接类型（如内连接、外连接等）可以提高查询效率。数据缓存策略：除了应用层的缓存外，还可以利用数据库自身的缓存机制，如 MySQL 的查询缓存（但需要注意查询缓存的适用场景和可能带来的问题，如数据更新时缓存的失效管理）。同时，可以采用分布式缓存（如 Redis）与数据库结合的方式，将热点数据缓存到 Redis 中，减少对数据库的查询压力。2.10 优化代码部署与运行环境选择合适的 JVM 版本：不同版本的 JVM 在性能优化方面可能有不同的特性和改进。例如，较新的 JVM 版本可能在垃圾回收算法、即时编译（JIT）优化等方面有更好的表现。根据项目的需求和运行环境，选择合适的 JVM 版本，并及时关注 JVM 的更新和升级。调整 JVM 参数：根据应用程序的内存需求、并发量等特点，合理调整 JVM 参数。例如，设置合适的堆内存大小（-Xms 和 -Xmx）、新生代和老年代的比例（-XX:NewRatio）、垃圾回收器类型（如 -XX:+UseG1GC 使用 G1 垃圾回收器）等。通过调整这些参数，可以优化 JVM 的内存管理和垃圾回收性能，提高应用程序的运行效率。容器化部署优化：如果应用程序采用容器化部署（如 Docker），优化容器的配置和资源限制。合理设置容器的 CPU 和内存限制，确保容器内的应用程序能够获得足够的资源，同时不会过度占用宿主机资源。此外，还可以优化容器镜像的大小，减少镜像下载和启动时间。通过以上全面且深入的 Java 性能优化技巧与实例解析，希望能帮助广大 Java 开发者在实际项目中提升程序性能，打造高效、稳定的 Java 应用。性能优化是一个持续的过程，需要不断地学习、实践和总结经验，以适应不断变化的业务需求和技术环境。说明：文中部分代码示例（如对象池中的 Class.newInstance()）在 Java 9 及更高版本中已被标记为 deprecated，实际生产中建议使用 Constructor.newInstance() 替代。此外，JVM 优化特性（如逃逸分析、内联）的具体行为可能随 JDK 版本更新而有所调整，请以实际运行环境的 JDK 文档为准。
在常规的 Java 应用开发流程中，开发人员使用 Java 的方式相对简单：打开常用的 IDE，编写 Java 源代码，再利用 IDE 提供的功能直接运行 Java 程序即可。这种开发模式背后的过程是：开发人员编写的是 Java 源代码文件（.java），IDE 会负责调用 Java 编译器把 Java 源代码编译成平台无关的字节代码（byte code），以类文件的形式保存在磁盘上（.class）。Java 虚拟机（JVM）会负责把 Java 字节代码加载并执行。Java 通过这种方式来实现其"编写一次，到处运行（Write once, run anywhere）"的目标。Java 类文件中包含的字节代码可以被不同平台上的 JVM 所使用。Java 字节代码不仅可以以文件形式存在于磁盘上，也可以通过网络方式来下载，还可以只存在于内存中。JVM 中的类加载器会负责从包含字节代码的字节数组（byte[]）中定义出 Java 类。在某些情况下，可能会需要动态地生成 Java 字节代码，或是对已有的 Java 字节代码进行修改。这个时候就需要用到本文中将要介绍的相关技术。首先介绍一下如何动态编译 Java 源文件。动态编译 Java 源文件在一般情况下，开发人员都是在程序运行之前就编写完成了全部的 Java 源代码并且成功编译。对有些应用来说，Java 源代码的内容在运行时刻才能确定。这个时候就需要动态编译源代码来生成 Java 字节代码，再由 JVM 来加载执行。典型的场景是很多算法竞赛的在线评测系统（如 PKU JudgeOnline），允许用户上传 Java 代码，由系统在后台编译、运行并进行判定。在动态编译 Java 源文件时，使用的做法是直接在程序中调用 Java 编译器。JSR 199 引入了 Java 编译器 API。如果使用 JDK 6 的话，可以通过此 API 来动态编译 Java 代码。比如下面的代码用来动态编译最简单的 Hello World 类。该 Java 类的代码是保存在一个字符串中的。public class CompilerTest { public static void main(String[] args) throws Exception { String source = "public class Main { public static void main(String[] args) {System.out.println(\"Hello World!\");} }"; JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null); StringSourceJavaObject sourceObject = new CompilerTest.StringSourceJavaObject("Main", source); Iterable<? extends JavaFileObject> fileObjects = Arrays.asList(sourceObject); CompilationTask task = compiler.getTask(null, fileManager, null, null, null, fileObjects); boolean result = task.call(); if (result) { System.out.println("编译成功。"); } } static class StringSourceJavaObject extends SimpleJavaFileObject { private String content = null; public StringSourceJavaObject(String name, String content) throws URISyntaxException { super(URI.create("string:///" + name.replace('.', '/') + Kind.SOURCE.extension), Kind.SOURCE); this.content = content; } public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException { return content; } } }如果不能使用 JDK 6 提供的 Java 编译器 API 的话，可以使用 JDK 中的工具类 com.sun.tools.javac.Main，不过该工具类只能编译存放在磁盘上的文件，类似于直接使用 javac 命令。另外一个可用的工具是 Eclipse JDT Core 提供的编译器。这是 Eclipse Java 开发环境使用的增量式 Java 编译器，支持运行和调试有错误的代码。该编译器也可以单独使用。Play 框架 在内部使用了 JDT 的编译器来动态编译 Java 源代码。在开发模式下，Play 框架会定期扫描项目中的 Java 源代码文件，一旦发现有修改，会自动编译 Java 源代码。因此在修改代码之后，刷新页面就可以看到变化。使用这些动态编译的方式的时候，需要确保 JDK 中的 tools.jar 在应用的 CLASSPATH 中。下面介绍一个例子，是关于如何在 Java 里面做四则运算，比如求出来 (3+4)*7-10 的值。一般的做法是分析输入的运算表达式，自己来模拟计算过程。考虑到括号的存在和运算符的优先级等问题，这样的计算过程会比较复杂，而且容易出错。另外一种做法是可以用 JSR 223 引入的脚本语言支持，直接把输入的表达式当做 JavaScript 或是 JavaFX 脚本来执行，得到结果。下面的代码使用的做法是动态生成 Java 源代码并编译，接着加载 Java 类来执行并获取结果。这种做法完全使用 Java 来实现。private static double calculate(String expr) throws CalculationException { String className = "CalculatorMain"; String methodName = "calculate"; String source = "public class " + className + " { public static double " + methodName + "() { return " + expr + "; } }"; // 省略动态编译 Java 源代码的相关代码，参见上一节 boolean result = task.call(); if (result) { ClassLoader loader = Calculator.class.getClassLoader(); try { Class<?> clazz = loader.loadClass(className); Method method = clazz.getMethod(methodName, new Class<?>[] {}); Object value = method.invoke(null, new Object[] {}); return (Double) value; } catch (Exception e) { throw new CalculationException("内部错误。"); } } else { throw new CalculationException("错误的表达式。"); } }上面的代码给出了使用动态生成的 Java 字节代码的基本模式，即通过类加载器来加载字节代码，创建 Java 类的对象的实例，再通过 Java 反射 API 来调用对象中的方法。Java 字节代码增强Java 字节代码增强指的是在 Java 字节代码生成之后，对其进行修改，增强其功能。这种做法相当于对应用程序的二进制文件进行修改。在很多 Java 框架中都可以见到这种实现方式。Java 字节代码增强通常与 Java 源文件中的注解（annotation）一块使用。注解在 Java 源代码中声明了需要增强的行为及相关的元数据，由框架在运行时刻完成对字节代码的增强。Java 字节代码增强应用的场景比较多，一般都集中在减少冗余代码和对开发人员屏蔽底层的实现细节上。用过 JavaBeans 的人可能对其中那些必须添加的 getter/setter 方法感到很繁琐，并且难以维护。而通过字节代码增强，开发人员只需要声明 Bean 中的属性即可，getter/setter 方法可以通过修改字节代码来自动添加。用过 JPA 的人，在调试程序的时候，会发现 实体类 中被添加了一些额外的域和方法。这些域和方法是在运行时刻由 JPA 的实现动态添加的。字节代码增强在 面向方面编程（AOP） 的一些实现中也有使用。在讨论如何进行字节代码增强之前，首先介绍一下表示一个 Java 类或接口的字节代码的组织形式。类文件 { 0xCAFEBABE，小版本号，大版本号，常量池大小，常量池数组， 访问控制标记，当前类信息，父类信息，实现的接口个数，实现的接口信息数组， 域个数，域信息数组，方法个数，方法信息数组，属性个数，属性信息数组 }如上所示，一个类或接口的字节代码使用的是一种特定的组织结构，其中所包含的内容依次排列。对于可能包含多个条目的内容，如所实现的接口、域、方法和属性等，是以数组来表示的。而在数组之前的是该数组中条目的个数。不同的内容类型，有其不同的内部结构。对于开发人员来说，直接操纵包含字节代码的字节数组的话，开发效率比较低，而且容易出错。已经有不少的开源库可以对字节代码进行修改或是从头开始创建新的 Java 类的字节代码内容。这些类库包括 ASM、cglib、serp 和 BCEL 等。使用这些类库可以在一定程度上降低增强字节代码的复杂度。比如考虑下面一个简单的需求，在一个 Java 类的所有方法执行之前输出相应的日志。熟悉 AOP 的人都知道，可以用一个前增强（before advice）来解决这个问题。如果使用 ASM 的话，相关的代码如下：ClassReader cr = new ClassReader(is); ClassNode cn = new ClassNode(); cr.accept(cn, 0); for (Object object : cn.methods) { MethodNode mn = (MethodNode) object; if ("<init>".equals(mn.name) || "<clinit>".equals(mn.name)) { continue; } InsnList insns = mn.instructions; InsnList il = new InsnList(); il.add(new FieldInsnNode(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;")); il.add(new LdcInsnNode("Enter method -> " + mn.name)); il.add(new MethodInsnNode(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V")); insns.insert(il); mn.maxStack += 3; } ClassWriter cw = new ClassWriter(0); cn.accept(cw); byte[] b = cw.toByteArray();从 ClassWriter 就可以获取到包含增强之后的字节代码的字节数组，可以把字节代码写回磁盘或是由类加载器直接使用。上述示例中，增强部分的逻辑比较简单，只是遍历 Java 类中的所有方法并添加对 System.out.println 方法的调用。在字节代码中，Java 方法体是由一系列的指令组成的。而要做的是生成调用 System.out.println 方法的指令，并把这些指令插入到指令集合的最前面。ASM 对这些指令做了抽象，不过熟悉全部的指令比较困难。ASM 提供了一个工具类 ASMifierClassVisitor，可以打印出 Java 类的字节代码的结构信息。当需要增强某个类的时候，可以先在源代码上做出修改，再通过此工具类来比较修改前后的字节代码的差异，从而确定该如何编写增强的代码。对类文件进行增强的时机是需要在 Java 源代码编译之后，在 JVM 执行之前。比较常见的做法有：由 IDE 在完成编译操作之后执行。如 Google App Engine 的 Eclipse 插件会在编译之后运行 DataNucleus 来对实体类进行增强。在构建过程中完成，比如通过 Ant 或 Maven 来执行相关的操作。实现自己的 Java 类加载器。当获取到 Java 类的字节代码之后，先进行增强处理，再从修改过的字节代码中定义出 Java 类。通过 JDK 5 引入的 java.lang.instrument 包来完成。java.lang.instrument由于存在着大量对 Java 字节代码进行修改的需求，JDK 5 引入了 java.lang.instrument 包并在 JDK 6 中得到了进一步的增强。基本的思路是在 JVM 启动的时候添加一些代理（agent）。每个代理是一个 jar 包，其清单（manifest）文件中会指定一个代理类。这个类会包含一个 premain 方法。JVM 在启动的时候会首先执行代理类的 premain 方法，再执行 Java 程序本身的 main 方法。在 premain 方法中就可以对程序本身的字节代码进行修改。JDK 6 中还允许在 JVM 启动之后动态添加代理。java.lang.instrument 包支持两种修改的场景，一种是重定义一个 Java 类，即完全替换一个 Java 类的字节代码；另外一种是转换已有的 Java 类，相当于前面提到的类字节代码增强。还是以前面提到的输出方法执行日志的场景为例，首先需要实现 java.lang.instrument.ClassFileTransformer 接口来完成对已有 Java 类的转换。static class MethodEntryTransformer implements ClassFileTransformer { public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { try { ClassReader cr = new ClassReader(classfileBuffer); ClassNode cn = new ClassNode(); // 省略使用 ASM 进行字节代码转换的代码 ClassWriter cw = new ClassWriter(0); cn.accept(cw); return cw.toByteArray(); } catch (Exception e){ return null; } } }有了这个转换类之后，就可以在代理的 premain 方法中使用它。public static void premain(String args, Instrumentation inst) { inst.addTransformer(new MethodEntryTransformer()); }把该代理类打成一个 jar 包，并在 jar 包的清单文件中通过 Premain-Class 声明代理类的名称。运行 Java 程序的时候，添加 JVM 启动参数 -javaagent:myagent.jar。这样的话，JVM 会在加载 Java 类的字节代码之前，完成相关的转换操作。总结操纵 Java 字节代码是一件很有趣的事情。通过它，可以很容易的对二进制分发的 Java 程序进行修改，非常适合于性能分析、调试跟踪和日志记录等任务。另外一个非常重要的作用是把开发人员从繁琐的 Java 语法中解放出来。开发人员应该只需要负责编写与业务逻辑相关的重要代码。对于那些只是因为语法要求而添加的，或是模式固定的代码，完全可以将其字节代码动态生成出来。字节代码增强和源代码生成是不同的概念。源代码生成之后，就已经成为了程序的一部分，开发人员需要去维护它：要么手工修改生成出来的源代码，要么重新生成。而字节代码的增强过程，对于开发人员是完全透明的。妥善使用 Java 字节代码的操纵技术，可以更好的解决某一类开发问题。参考资料Java 字节代码格式Java 6.0 Compiler API深入探讨 Java 类加载器说明：本文内容主要基于 JDK 5/6 时期的技术背景（如 JSR 199、JSR 223 及早期 ASM 版本），部分链接指向的官方文档可能已归档或迁移，但核心原理依然适用。
编者注：本文为历史博文归档；涉及 JDK、框架与工具链版本请以当前官方文档为准。引用外链图片可能失效，阅读时请注意时效性。关于作者与交流扫码关注微信公众号「Java 码界探秘」，获取更多技术文章。博客地址：https://blog.jsdiff.com/项目简介：java-redis本项目是一个基于 Java 实现的简易 Redis 服务器（redis-server），旨在通过代码模拟 Redis 的核心功能，帮助开发者深入理解内存数据库的工作原理。项目仓库：java-redis (https://github.com/daichangya/java-redis)系统架构概览下图展示了 java-redis 的整体系统架构，涵盖了从客户端请求到数据存储的完整流程： +---------------+ | 客户端 | +-------+-------+ | +----------+----------+ | 网络层 | +----------+----------+ | +-----------------+------------+ | java-redis 服务器 | +-----------------+------------+ | | | +-------v-----+ +-------v-----+ +-----v-----+ |命令解析模块 | |数据处理模块 | |...其他模块 | +---------------+ +---------------+ +---------+ | +----------+ | 内存/磁盘 | | 存储 | +----------+核心模块设计为了实现高性能与高可维护性，java-redis 采用了模块化设计。以下是系统内部的核心组件划分：1. 客户端与服务器架构客户端（Clients）：任何能够通过网络发送请求到 java-redis 服务器的应用程序。请求支持标准的 Redis 命令，如 GET、SET 等。网络层（Network Layer）：负责处理客户端与服务器之间的网络通信。基于 TCP/IP 协议，监听端口并接受客户端连接请求。服务器核心（Server）：使用 Java 语言编写，模拟 Redis 服务器行为。主要负责处理客户端请求、管理数据结构（字符串、列表、集合等）并执行相应操作。2. 内部功能模块网络通信模块：监听网络端口，接受客户端连接，并将接收到的请求数据传递给命令处理模块。项目中推测使用 Netty 框架来实现高效的网络通信。命令解析模块：解析来自客户端的 Redis 命令字符串，将其转换为内部操作指令，并调用相应的处理函数。数据处理模块：根据命令要求，对内存中的数据结构进行增删改查（CRUD）操作。项目实现或集成了一系列高效的数据结构和算法，以支持 Redis 各种数据类型的高效存取。内存管理模块（可选）：针对高性能需求，可实施专门的内存管理策略以优化性能，减少垃圾回收（GC）的影响。异常处理与日志：包含完善的异常处理机制和日志记录功能，记录服务器运行状态和错误信息，便于调试与维护。配置模块：提供服务器配置的管理界面，支持运行时参数的调整。3. 扩展功能（可选）数据持久化：为了在系统故障时恢复数据，java-redis 可实现磁盘持久化功能。支持方式包括 AOF（Append Only File）或 RDB（Redis Database）快照。安全性与认证：若需处理敏感数据或在公开网络环境中运行，可实现 TLS/SSL 加密通信及客户端身份验证等安全机制。项目内容与用途1. 项目主要内容Redis 服务器模拟：实现了简化的 Redis 服务器核心，支持字符串、列表、集合、哈希表、有序集合等基本数据结构的存取操作。Redis 客户端工具：提供了与服务器交互的 Java 客户端工具，演示如何使用 Java 语言连接并操作 Redis 服务器。教学示例代码：包含一系列示例代码，涵盖配置、连接、执行命令等环节，用于教学如何在 Java 中使用 Redis。2. 项目适用场景学习与研究：为 Java 开发者提供研究 Redis 内部工作原理的机会，适合希望深入了解内存数据库实现或尝试编写自己 Redis 版本的开发者。教学辅助：可作为大学课程或在线课程的教学材料，帮助学生通过实际代码理解 Redis 的基本概念和操作逻辑。测试与验证：可用于测试自定义的 Redis 客户端库或应用程序，验证其与 Redis 服务器的交互是否符合预期。说明：本文涉及的技术实现基于历史版本归档，具体代码细节与依赖库版本请以 GitHub 仓库最新提交为准。