上线流程-DESKTOP-J06DG54
使用java8实现List中对象属性的去重
使用java8实现List中对象属性的去重
今天在工作的时候遇到了一个问题,就是List的去重,不想用双重for,感觉太low,不想用for+Map,感觉应该有更好的方法,于是,google之。发现java8的stream流能完美解决这个问题。
1 | List<BookInfoVo> list |
比如在 BookInfoVo 中有一个 recordId 属性,现在需要对此去重.
怎么办呢?
有两种方法:
- 第一种: 不使用java8 的 Stream这也是大多数人第一想到的,借助 TreeSet 去重,其中 TreeSet 的其中一个构造函数接收一个排序的算法,同时这也会用到 TreeSet 的去重策略上.
1
2
3
4
5private List<BookInfoVo> removeDupliByRecordId(List<BookInfoVo> books) {
Set<BookInfoVo> bookSet = new TreeSet<>((o1, o2)->o1.getRecordId().compareTo(o2.getRecordId()));
personSet.addAll(books);
return new ArrayList<BookInfoVo>(bookSet);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16/**
* Constructs a new, empty tree set, sorted according to the specified
* comparator. All elements inserted into the set must be <i>mutually
* comparable</i> by the specified comparator: {@code comparator.compare(e1,
* e2)} must not throw a {@code ClassCastException} for any elements
* {@code e1} and {@code e2} in the set. If the user attempts to add
* an element to the set that violates this constraint, the
* {@code add} call will throw a {@code ClassCastException}.
*
* @param comparator the comparator that will be used to order this set.
* If {@code null}, the {@linkplain Comparable natural
* ordering} of the elements will be used.
*/
public TreeSet(Comparator<? super E> comparator) {
this(new TreeMap<>(comparator));
} - 第二种: 炫酷的java8写法]当然也可以根据多个属性去重
1
2
3
4
5
6/*方法二:炫酷的java8写法*/
ArrayList<BookInfoVo> distinctLiost = list.stream()
.collect(
Collectors.collectingAndThen(
Collectors.toCollection(() -> new TreeSet<>(Comparator.comparingLong(BookInfoVo::getRecordId))), ArrayList::new)
);如果没有第一种方法做铺垫,我们很可能一脸懵逼.1
2ArrayList<BookInfoVo> distinctLiost = list.stream().collect(Collectors.collectingAndThen(
Collectors.toCollection(() -> new TreeSet<>(Comparator.comparing(o -> o.getName() + ";" + o.getAuthor()))), ArrayList::new)
其实理解起来也不难:
关键在于Collectors.collectingAndThen( Collectors.toCollection(() -> new TreeSet<>(Comparator.comparingLong(BookInfoVo::getRecordId))), ArrayList::new)的理解,
collectingAndThen 这个方法的意思是: 将收集的结果转换为另一种类型: collectingAndThen,
因此上面的方法可以理解为,把 new TreeSet<>(Comparator.comparingLong(BookInfoVo::getRecordId))这个set转换为 ArrayList,这个结合第一种方法不难理解.
可以看到java8这种写法真是炫酷又强大!!!
java8类型互转
java8类型互转
List< Integer >、int[ ]、Integer[ ]相互转换
[toc]
下文中出现的list、ints、integers分别代表一个列表、一个int数组、一个Integer数组。
它们之间所谓的转化,其实是 复制数据,互不干扰,可以理解为深拷贝。
int[ ] 转 List< Integer >
1 | List<Integer> list = Arrays.stream(ints).boxed().collect(Collectors.toList()); |
Arrays.stream(ints) 之后返回的类型是 IntStream,IntStream 是一个接口继承自 BaseStream,BaseStream 又继承自AutoCloseable。所以我把IntStream看成一个方便对每个整数做操作的数据流。
之后调用了 boxed(),它的作用是对每个整数进行装箱,基本类型流转换为对象流,返回的是 Stream
最后通过collect方法将数据流 (Stream
小结:
- Arrays.stream(ints) 将基本类型数组转换为基本类型流。 int[ ] => IntStream
- .boxed() 将基本类型流转换为对象流。 => Stream< Integer >
- .collect(Collectors.toList()) 将对象流收集为集合。 => List< Integer >
int[ ] [ ] 转 List< List < Integer > >
1 | String temp = Arrays.deepToString(fooArr).replaceAll("\\[","").replaceAll("\\]",""); |
1 | List<List<Integer>> collect2 = Arrays.Stream(ints1).map(ar -> Arrays.stream(ar).boxed().collect(Collectors.toList())).collect(Collectors.toList()); |
int[ ] 转 Integer[ ]
1 | Integer[] integers = Arrays.stream(ints).boxed().toArray(Integer[]::new); |
一样的内容就不重复了,toArray(T[ ] :: new) 方法返回基本类型数组。
小结:
- Arrays.stream(ints) 将基本类型数组转换为基本类型流。 int[ ] => IntStream
- .boxed() 将基本类型流转换为对象流。=> Stream< Integer >
- .toArray(Integer[ ]::new) 将对象流转换为对象数组。=> Integer[ ]
int[ ] [ ]转 Integer[ ] [ ]
1 | Integer[][] integers3 = Arrays.stream(ints1).map(ints -> Arrays.stream(ints).boxed().toArray(Integer[]::new)).toArray(Integer[][]::new); |
Integer[ ] 转 List< Integer >
1 | List<Integer> list = Arrays.asList(integers); |
这个就很简单了,通过Arrays类里的asList方法将数组装换为List。值得注意:
asList 返回的是 Arrays 里的静态私有类 ArrayList,而不是 java.util 里的 ArrayList,它无法自动扩容。
可以用下面2种方法生成可扩容的ArrayList:
1 | List<Integer> list = new ArrayList<>(Arrays.asList(integers)); |
或者
1 | List<Integer> list = new ArrayList<>(); |
Integer[ ] [ ]转 List< List < Integer > >
1 | Integer[][] a = {{1,2,3},{1,2,3}}; |
Integer[ ] 转 int[ ]
1 | int[] ints = Arrays.stream(integers).mapToInt(Integer::valueOf).toArray(); |
map的意思是把每一个元素进行同样的操作。mapToInt的意思是把每一个元素转换为int。mapToInt(Integer::valueOf)方法返回的是IntStream。
小结:
- Arrays.stream(integers) 将对象数组转换为对象流。 Integer[ ] => Stream< Integer >
- .mapToInt(Integer::valueOf) 将对象流转换成基本类型流。=> IntStream
- .toArray() 将基本类型流转换为基本类型数组。 => int[ ]
Integer[ ] [ ]转 int[ ] [ ]
1 | Integer[][] a = {{1,2,3},{1,2,3}}; |
List< Integer > 转 int[ ]
1 | int[] ints = list.stream().mapToInt(Integer::valueOf).toArray(); |
经过上面的说明,相信这里已经很好理解了,直接小结。
小结:
- list.stream() 将列表转换为对象流。List< Integer > => Stream< Integer >
- .mapToInt(Integer::valueOf) 将对象流转换为基本数据类型流。=> IntStream
- .toArray() 将基本数据类型流转换为基本类型数组。=>int[ ]
List< List < Integer > > 转 int[ ] [ ]
1 | List<List<Integer>> lists = ...; |
List< Integer > 转 Integer[ ]
1 | Integer[] integers = list.toArray(new Integer[list.size()]); |
1 | Integer[] integers = list.stream().toArray(Integer[]::new); //不推荐 |
这个也很简单,方法里的参数是一个数组,所以要规定长度。也有无参的方法,但是要进行转型,所以不推荐使用。
List< List < Integer > > 转 Integer[ ] [ ]
1 | List<List<Integer>> lists = ...; |
1 | Integer[][] arrays = lists.stream() // Stream<List<Integer>> |
1 | Integer[][] arrays = lists.stream().map(List::toArray).toArray(Integer[][]::new); |
List< Character >、char[ ]、Character[ ]相互转换
char[ ] 转 List< Character >
1 | char[] chars = {'a', 'b', 'c'}; |
char[ ] [ ] 转 List< List < Character > >
1 | List<List<Character>> collect2 = Arrays.stream(ints1).map(chars1 -> new String(chars1).chars().mapToObj(i -> (char) i).collect(Collectors.toList())).collect(Collectors.toList()); |
char[ ] 转 Character[ ]
1 | char[] chars = {'a', 'b', 'c'}; |
char[ ] [ ]转 Character[ ] [ ]
1 | Character[][] integers3 = Arrays.stream(ints1).map(chars -> new String(chars).chars().mapToObj(i->(char)i).toArray(Character[]::new)).toArray(Character[][]::new); |
Character[ ] 转 List< Character >
1 | List<Character> collect2 = Arrays.stream(characters).collect(Collectors.toList()); |
Character[ ] [ ]转 List< List < Character > >
1 | List<List<Character>> lists = Arrays.stream(a).map(Arrays::asList).collect(Collectors.toList()); |
Character[ ] 转 char[ ]
1 | char[] chars4 = Arrays.stream(characters1).map(String::valueOf).collect(Collectors.joining()).toCharArray(); |
Character[ ] [ ]转 char[ ] [ ]
1 | char[][] ints1 = Arrays.stream(a).map(a1 -> Arrays.stream(a1).map(String::valueOf).collect(Collectors.joining()).toCharArray()).toArray(char[][]::new); |
List< Character > 转 char[ ]
1 | char[] value = characters.stream().map(String::valueOf).collect(Collectors.joining()).toCharArray(); |
1 | Character[] charArr = characters.toArray(new Character[characters.size()]); |
List< List < Character > > 转 char[ ] [ ]
1 | char[][] arrays = lists.stream() // Stream<List<Integer>> |
List< Character > 转 Character[ ]
1 | Character[] characters2 = Arrays.stream(characters1).toArray(Character[]::new); |
List< List < Character > > 转 Character[ ] [ ]
1 | Character[][] integers1 = lists.stream() // Stream<List<Integer>> |
1 | Character[][] integers2 = lists.stream() // Stream<List<Integer>> |
1 | Character[][] integers = lists.stream().map(List::toArray).toArray(Character[][]::new); |
注释书写
注释书写规范
- 一般情况下,源程序有效注释量必须在30%以上。注释的原则是有助于对程序的阅读理解,在该加的地方都加了,注释不宜太多也不能太少,注释语言必须准确、易懂、简洁。可以用注释统计工具来统计。
一、类和接口的注释
1. 类外注释
该注释放在 package 关键字之后,class 或者 interface 关键字之前。
- 说明:方便JavaDoc收集。
示例:
1 | package com.huawei.msg.relay.comm; |
- 类和接口的注释内容:类的注释主要是一句话功能简述、功能详细描述。
格式:1
2
3
4/**
* 〈一句话功能简述〉
* 〈功能详细描述〉
*/ - 描述部分说明该类或者接口的功能、作用、使用方法和注意事项。
示例:1
2
3
4
5/**
* LogManager 类集中控制对日志读写的操作。
* 全部为静态变量和静态方法,对外提供统一接口。分配对应日志类型的读写器,
* 读取或写入符合条件的日志纪录。
*/
2. 类内注释
类属性、公有和保护方法必须写注释。geter、seter方法不用写注释
示例:
1 | /** |
1).成员变量注释内容:成员变量的意义、目的、功能,可能被用到的地方。
2).公有和保护方法注释内容:列出方法的一句话功能 简述、功能详细描述、输入参数、输出参数、返回值、违例等。
格式:
1 | /** |
说明: @exception或throws 列出可能仍出的异常。
示例:
1 | /** |
说明:
1).注释应与其描述的代码相近,对代码的注释应放在其上方或右方(对单条语句的注释)相邻位置,不可放在下面,如放于上方则需与其上面的代码用空行隔开。
2).注释与所描述内容进行同样的缩排。
3).将注释与其上面的代码用空行隔开。
示例:
1 | //注释 |
二、方法与复杂逻辑的注释
对变量的定义和分支语句(条件分支、循环语句等)对复杂的分支必须编写注释,如果时间允许,建议对所有分支语句写注释。
说明:这些语句往往是程序实现某一特定功能的关键,对于维护人员来说,良好的注释帮助更好的理解程序,有时甚至优于看设计文档。
- switch语句
- switch语句下的case语句,如果因为特殊情况需要处理完一个case后进入下一个case处理,必须在该case语句处理完、下一个case语句前加上明确的注释。
- 说明:这样比较清楚程序编写者的意图,有效防止无故遗漏break语句。
- 边写代码边注释
- 修改代码同时修改相应的注释,以保证注释与代码的一致性。不再有用的注释要删除。
- 避免在注释中使用缩写
- 在使用缩写时或之前,应对缩写进行必要的说明,特别是不常用缩写。
- 说明:除非必要,不应在代码或表达中间插入注释,否则容易使代码可理解性变差。
- 通过对函数或过程、变量、结构等正确的命名以及合理地组织代码的结构,使代码成为自注释的。
- 说明:清晰准确的函数、变量等的命名,可增加代码可读性,并减少不必要的注释。
- 在代码的功能、意图层次上进行注释,提供有用、额外的信息。
- 说明:注释的目的是解释代码的目的、功能和采用的方法,提供代码以外的信息,帮助读者理解代码,防止没必要的重复注释信息。
- 示例:如下注释意义不大。
1
2// 如果 receiveFlag 为真
if (receiveFlag) - 而如下的注释则给出了额外有用的信息。
1
2// 如果从连结收到消息
if (receiveFlag)
- 在程序块的结束行右方加注释标记,以表明某程序块的结束。
- 说明:当代码段较长,特别是多重嵌套时,这样做可以使代码更清晰,更便于阅读。
- 示例:参见如下例子。
1
2
3
4
5
6
7
8if (...)
{
program code1
while (index < MAX_INDEX)
{
program code2
} // end of while (index < MAX_INDEX) // 指明该条while语句结束
} // end of if (...) // 指明是哪条if语句结束
- 方法内的单行注释使用 //。
- 说明:调试程序的时候可以方便的使用 /* 。。。*/ 注释掉一长段程序。
- 注释使用中文注释和中文标点,不得用英文写注释。方法和类描述的第一句话尽量使用简洁明了的话概括一下功能,然后加以句号。接下来的部分可以详细描述。
- 说明:JavaDoc工具收集简介的时候使用选取第一句话。
- 顺序实现流程的说明使用1、2、3、4在每个实现步骤部分的代码前面进行注释。
- 示例:如下是对设置属性的流程注释
1
2
3
4//1、 判断输入参数是否有效。
...
// 2、设置本地变量。
...
- 一些复杂的算法代码需要说明。
- 示例:这里主要是对闰年算法的说明。
java //1. 如果能被4整除,是闰年; //2. 如果能被100整除,不是闰年.; //3. 如果能被400整除,是闰年.。星期五, 23. 八月 2019 07:42下午
**
**
Java内存模型2
Java内存模型2
Spring、Netty、Mybatis 等框架的代码中大量运用了 Java 多线程编程技巧。并发编程处理的恰当与否,将直接影响架构的性能。本章通过对 这些框架源码 的分析,结合并发编程的常用技巧,来讲解多线程编程在这些主流框架中的应用。
Java 内存模型
JVM 规范 定义了 Java 内存模型 来屏蔽掉各种操作系统、虚拟机实现厂商和硬件的内存访问差异,以确保 Java 程序 在所有操作系统和平台上能够达到一致的内存访问效果。
工作内存和主内存
Java 内存模型 规定所有的变量都存储在主内存中,每个线程都有自己独立的工作内存,工作内存保存了 对应该线程使用的变量的主内存副本拷贝。线程对这些变量的操作都在自己的工作内存中进行,不能直接操作主内存 和 其他工作内存中存储的变量或者变量副本。线程间的变量传递需通过主内存来完成,三者的关系如下图所示。
Java 内存操作协议
Java 内存模型定义了 8 种操作来完成主内存和工作内存的变量访问,具体如下。
- read:把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
- load:把从主内存中读取的变量值载入工作内存的变量副本中。
- use:把工作内存中一个变量的值传递给 Java 虚拟机执行引擎。
- assign:把从执行引擎接收到的变量的值赋值给工作内存中的变量。
- store:把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作。
- write:工作内存传递过来的变量值放入主内存中。
- lock:把主内存的一个变量标识为某个线程独占的状态。
- unlock:把主内存中 一个处于锁定状态的变量释放出来,被释放后的变量才可以被其他线程锁定。
内存模型三大特性
1、原子性
这个概念与事务中的原子性大概一致,表明此操作是不可分割,不可中断的,要么全部执行,要么全部不执行。 Java 内存模型直接保证的原子性操作包括 read、load、use、assign、store、write、lock、unlock 这八个。
2、可见性
可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java 内存模型 是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是 volatile 变量 都是如此,普通变量与 volatile 变量 的区别是,volatile 的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此,可以说 volatile 保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。除了 volatile 外,synchronized 也提供了可见性,synchronized 的可见性是由 “对一个变量执行 unlock 操作 之前,必须先把此变量同步回主内存中(执行 store、write 操作)” 这条规则获得。
3、有序性
单线程环境下,程序会 “有序的”执行,即:线程内表现为串行语义。但是在多线程环境下,由于指令重排,并发执行的正确性会受到影响。在 Java 中使用 volatile 和 synchronized 关键字,可以保证多线程执行的有序性。volatile 通过加入内存屏障指令来禁止内存的重排序。synchronized 通过加锁,保证同一时刻只有一个线程来执行同步代码。
volatile 的应用
打开 NioEventLoop 的代码中,有一个控制 IO 操作 和 其他任务运行比例的,用 volatile 修饰的 int 类型字段 ioRatio,代码如下。
1 | private volatile int ioRatio = 50; |
这里为什么要用 volatile 修饰呢?我们首先对 volatile 关键字进行说明,然后再结合 Netty 的代码进行分析。
关键字 volatile 是 Java 提供的最轻量级的同步机制,Java 内存模型对 volatile 专门定义了一些特殊的访问规则。下面我们就看它的规则。当一个变量被 volatile 修饰后,它将具备以下两种特性。
- 线程可见性:当一个线程修改了被 volatile 修饰的变量后,无论是否加锁,其他线程都可以立即看到最新的修改(什么叫立即看到最新的修改?感觉这句话太口语化且模糊,搞不太懂!),而普通变量却做不到这点。
- 禁止指令重排序优化:普通的变量仅仅保证在该方法的执行过程中所有依赖赋值结果的地方都能获取正确的结果,而不能保证变量赋值操作的顺序与程序代码的执行顺序一致。举个简单的例子说明下指令重排序优化问题,代码如下。
1 | public class ThreadStopExample { |
我们预期程序会在 3s 后停止,但是实际上它会一直执行下去,原因就是虚拟机对代码进行了指令重排序和优化,优化后的指令如下。
1 | if (!stop) |
workThread 线程 在执行重排序后的代码时,是无法发现 变量 stop 被其它线程修改的,因此无法停止运行。要解决这个问题,只要将 stop 前增加 volatile 修饰符即可。volatile 解决了如下两个问题。第一,主线程对 stop 的修改在 workThread 线程 中可见,也就是说 workThread 线程 立即看到了其他线程对于 stop 变量 的修改。第二,禁止指令重排序,防止因为重排序导致的并发访问逻辑混乱。
一些人认为使用 volatile 可以代替传统锁,提升并发性能,这个认识是错误的。volatile 仅仅解决了可见性的问题,但是它并不能保证互斥性,也就是说多个线程并发修改某个变量时,依旧会产生多线程问题。因此,不能靠 volatile 来完全替代传统的锁。根据经验总结,volatile 最适用的场景是 “ 一个线程写,其他线程读 ”,如果有多个线程并发写操作,仍然需要使用锁或者线程安全的容器或者原子变量来代替。下面我们继续对 Netty 的源码做分析。上面讲到了 ioRatio 被定义成 volatile,下面看看代码为什么要这样定义。
1 | final long ioTime = System.nanoTime() - ioStartTime; |
通过代码分析我们发现,在 NioEventLoop 线程 中,ioRatio 并没有被修改,它是只读操作。既然没有修改,为什么要定义成 volatile 呢?继续看代码,我们发现 NioEventLoop 提供了重新设置 IO 执行时间比例的公共方法。
1 | public void setIoRatio(int ioRatio) { |
首先,NioEventLoop 线程 没有调用该 set 方法,说明调整 IO 执行时间比例 是外部发起的操作,通常是由业务的线程调用该方法,重新设置该参数。这样就形成了一个线程写、一个线程读。根据前面针对 volatile 的应用总结,此时可以使用 volatile 来代替传统的 synchronized 关键字,以提升并发访问的性能。
ThreadLocal 的应用及源码解析
ThreadLocal 又称为线程本地存储区(Thread Local Storage,简称为 TLS),每个线程都有自己的私有的本地存储区域,不同线程之间彼此不能访问对方的 TLS 区域。使用 ThreadLocal 变量 的 set(T value)方法 可以将数据存入 该线程本地存储区,使用 get() 方法 可以获取到之前存入的值。
ThreadLocal 的常见应用
不使用 ThreadLocal。
1 | public class SessionBean { |
上述代码中,session 需要在方法间传递才可以修改和读取,保证线程中各方法操作的是一个。下面看一下使用 ThreadLocal 的代码。
1 | public class SessionBean { |
在方法的内部实现中,直接可以通过 session.get() 获取到当前线程的 session,省掉了参数在方法间传递的环节。
ThreadLocal 的实现原理
一般,类属性中的数据是多个线程共享的,但 ThreadLocal 类型的数据 声明为类属性,却可以为每一个使用它(通过 set(T value)方法)的线程存储 线程私有的数据,通过其源码我们可以发现其中的原理。
1 | public class ThreadLocal<T> { |
ThreadLocal 在 Spring 中的使用
Spring 事务处理的设计与实现中大量使用了 ThreadLocal 类,比如,TransactionSynchronizationManager 维护了一系列的 ThreadLocal 变量,用于存储线程私有的 事务属性及资源。源码如下。
1 | /** |
ThreadLocal 在 Mybatis 中的使用
Mybatis 的 SqlSession 对象 也是各线程私有的资源,所以对其的管理也使用到了 ThreadLocal 类。源码如下。
1 | public class SqlSessionManager implements SqlSessionFactory, SqlSession { |
J.U.C 包的实际应用
线程池 ThreadPoolExecutor
首先通过 ThreadPoolExecutor 的源码 看一下线程池的主要参数及方法。
1 | public class ThreadPoolExecutor extends AbstractExecutorService { |
线程池执行流程,如下图所示。
Executors 提供的 4 种线程池
Executors 类 通过 ThreadPoolExecutor 封装了 4 种常用的线程池:CachedThreadPool,FixedThreadPool,ScheduledThreadPool 和 SingleThreadExecutor。其功能如下。
- CachedThreadPool:用来创建一个几乎可以无限扩大的线程池(最大线程数为 Integer.MAX_VALUE),适用于执行大量短生命周期的异步任务。
- FixedThreadPool:创建一个固定大小的线程池,保证线程数可控,不会造成线程过多,导致系统负载更为严重。
- SingleThreadExecutor:创建一个单线程的线程池,可以保证任务按调用顺序执行。
- ScheduledThreadPool:适用于执行 延时 或者 周期性 任务。
如何配置线程池
- CPU 密集型任务
尽量使用较小的线程池,一般为 CPU 核心数+1。 因为 CPU 密集型任务 使得 CPU 使用率 很高,若开过多的线程数,会造成 CPU 过度切换。 - IO 密集型任务
可以使用稍大的线程池,一般为 2*CPU 核心数。 IO 密集型任务 CPU 使用率 并不高,因此可以让 CPU 在等待 IO 的时候有其他线程去处理别的任务,充分利用 CPU 时间。
线程池的实际应用
Tomcat 在分发 web 请求 时使用了线程池来处理。
BlockingQueue
核心方法
1 | public interface BlockingQueue<E> extends Queue<E> { |
主要实现类
- ArrayBlockingQueue
基于数组的阻塞队列实现,在 ArrayBlockingQueue 内部,维护了一个定长数组,以便缓存队列中的数据对象,这是一个常用的阻塞队列,除了一个定长数组外,ArrayBlockingQueue 内部还保存着两个整形变量,分别标识着队列的头部和尾部在数组中的位置。
ArrayBlockingQueue 在生产者放入数据 和 消费者获取数据时,都是共用同一个锁对象,由此也意味着两者无法真正并行运行,这点尤其不同于 LinkedBlockingQueue。ArrayBlockingQueue 和 LinkedBlockingQueue 间还有一个明显的不同之处在于,前者在插入或删除元素时不会产生或销毁任何额外的对象实例,而后者则会生成一个额外的 Node 对象。这在长时间内需要高效并发地处理大批量数据的系统中,其对于 GC 的影响还是存在一定的区别。而在创建 ArrayBlockingQueue 时,我们还可以控制对象的内部锁是否采用公平锁,默认采用非公平锁。 - LinkedBlockingQueue
基于链表的阻塞队列,同 ArrayListBlockingQueue 类似,其内部也维持着一个数据缓冲队列(该队列由一个链表构成),当生产者往队列中放入一个数据时,队列会从生产者手中获取数据,并缓存在队列内部,而生产者立即返回;只有当队列缓冲区达到最大值缓存容量时(LinkedBlockingQueue 可以通过构造函数指定该值),才会阻塞生产者队列,直到消费者从队列中消费掉一份数据,生产者线程会被唤醒,反之对于消费者这端的处理也基于同样的原理。而 LinkedBlockingQueue 之所以能够高效的处理并发数据,还因为其对于生产者端和消费者端分别采用了独立的锁来控制数据同步,这也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。
需要注意的是,如果构造一个 LinkedBlockingQueue 对象,而没有指定其容量大小,LinkedBlockingQueue 会默认一个类似无限大小的容量(Integer.MAX_VALUE),这样的话,如果生产者的速度一旦大于消费者的速度,也许还没有等到队列满阻塞产生,系统内存就有可能已被消耗殆尽了。 - PriorityBlockingQueue
基于优先级的阻塞队列(优先级的判断通过构造函数传入的 Compator 对象来决定),但需要注意的是 PriorityBlockingQueue 并不会阻塞数据生产者,而只会在没有可消费的数据时,阻塞数据的消费者。因此使用的时候要特别注意,生产者生产数据的速度绝对不能快于消费者消费数据的速度,否则时间一长,会最终耗尽所有的可用堆内存空间。在实现 PriorityBlockingQueue 时,内部控制线程同步的锁采用的是公平锁。
CAS 指令和原子类(应用比较多的就是计数器)
互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能的额外损耗,因此这种同步被称为阻塞同步,它属于一种悲观的并发策略,我们称之为悲观锁。随着硬件和操作系统指令集的发展和优化,产生了非阻塞同步,被称为乐观锁。简单地说,就是先进行操作,操作完成之后再判断操作是否成功,是否有并发问题,如果有则进行失败补偿,如果没有就算操作成功,这样就从根本上避免了同步锁的弊端。
目前,在 Java 中应用最广泛的非阻塞同步就是 CAS。从 JDK1.5 以后,可以使用 CAS 操作,该操作由 sun.misc.Unsafe 类里的 compareAndSwapInt() 和 compareAndSwapLong() 等方法实现。通常情况下 sun.misc.Unsafe 类 对于开发者是不可见的,因此,JDK 提供了很多 CAS 包装类 简化开发者的使用,如 AtomicInteger。使用 Java 自带的 Atomic 原子类,可以避免同步锁带来的并发访问性能降低的问题,减少犯错的机会。
Java泛型类型擦除以及类型擦除带来的问题
Java泛型类型擦除以及类型擦除带来的问题
1.Java泛型的实现方法:类型擦除
大家都知道,Java的泛型是伪泛型,这是因为Java在编译期间,所有的泛型信息都会被擦掉,正确理解泛型概念的首要前提是理解类型擦除。Java的泛型基本上都是在编译器这个层次上实现的,在生成的字节码中是不包含泛型中的类型信息的,使用泛型的时候加上类型参数,在编译器编译的时候会去掉,这个过程成为类型擦除。
如在代码中定义 List<Object>和 List<String>等类型,在编译后都会变成 List,JVM看到的只是List,而由泛型附加的类型信息对JVM是看不到的。Java编译器会在编译时尽可能的发现可能出错的地方,但是仍然无法在运行时刻出现的类型转换异常的情况,类型擦除也是Java的泛型与C++模板机制实现方式之间的重要区别。
1-1.通过两个例子证明Java类型的类型擦除
例1.原始类型相等
1 | public class Test { |
在这个例子中,我们定义了两个 ArrayList数组,不过一个是 ArrayList<String>泛型类型的,只能存储字符串;一个是 ArrayList<Integer>泛型类型的,只能存储整数,最后,我们通过 list1对象和 list2对象的 getClass()方法获取他们的类的信息,最后发现结果为 true。说明泛型类型 String和 Integer都被擦除掉了,只剩下原始类型。
例2.通过反射添加其它类型元素
1 | public class Test { |
在程序中定义了一个 ArrayList泛型类型实例化为 Integer对象,如果直接调用 add()方法,那么只能存储整数数据,不过当我们利用反射调用 add()方法的时候,却可以存储字符串,这说明了 Integer泛型实例在编译之后被擦除掉了,只保留了原始类型。
2.类型擦除后保留的原始类型
在上面,两次提到了原始类型,什么是原始类型?
原始类型 就是擦除去了泛型信息,最后在字节码中的类型变量的真正类型,无论何时定义一个泛型,相应的原始类型都会被自动提供,类型变量擦除,并使用其限定类型(无限定的变量用Object)替换。
例3.原始类型Object
1 | class Pair<t> { |
Pair的原始类型为:
1 | class Pair { |
因为在 Pair<t></t>中,T 是一个无限定的类型变量,所以用 Object替换,其结果就是一个普通的类,如同泛型加入Java语言之前的已经实现的样子。在程序中可以包含不同类型的 Pair,如 Pair<String>或 Pair<Integer>,但是擦除类型后他们的就成为原始的 Pair类型了,原始类型都是 Object。
从上面的例2中,我们也可以明白 ArrayList<Integer>被擦除类型后,原始类型也变为 Object,所以通过反射我们就可以存储字符串了。
如果类型变量有限定,那么原始类型就用第一个边界的类型变量类替换。
比如: Pair这样声明的话
1 | public class Pair<t extends comparable> {} |
那么原始类型就是 Comparable。
要区分原始类型和泛型变量的类型。
在调用泛型方法时,可以指定泛型,也可以不指定泛型。
- 在不指定泛型的情况下,泛型变量的类型为该方法中的几种类型的同一父类的最小级,直到Object
- 在指定泛型的情况下,该方法的几种类型必须是该泛型的实例的类型或者其子类
1 | public class Test { |
其实在泛型类中,不指定泛型的时候,也差不多,只不过这个时候的泛型为 Object,就比如 ArrayList中,如果不指定泛型,那么这个 ArrayList可以存储任意的对象。
例4.Object泛型
1 | public static void main(String[] args) { |
3.类型擦除引起的问题及解决方法
因为种种原因,Java不能实现真正的泛型,只能使用类型擦除来实现伪泛型,这样虽然不会有类型膨胀问题,但是也引起来许多新问题,所以,SUN对这些问题做出了种种限制,避免我们发生各种错误。
3-1.先检查,再编译以及编译的对象和引用传递问题
Q: 既然说类型变量会在编译的时候擦除掉,那为什么我们往 ArrayList 创建的对象中添加整数会报错呢?不是说泛型变量String会在编译的时候变为Object类型吗?为什么不能存别的类型呢?既然类型擦除了,如何保证我们只能使用泛型变量限定的类型呢?
A: Java编译器是通过先检查代码中泛型的类型,然后再进行类型擦除,再进行编译。
例如:
1 | public static void main(String[] args) { |
在上面的程序中,使用 add方法添加一个整型,在IDE中,直接会报错,说明这就是在编译之前的检查,因为如果是在编译之后检查,类型擦除后,原始类型为 Object,是应该允许任意引用类型添加的。可实际上却不是这样的,这恰恰说明了关于泛型变量的使用,是会在编译之前检查的。
那么,这个类型检查是针对谁的呢?我们先看看参数化类型和原始类型的兼容。
以 ArrayList举例子,以前的写法:
1 | ArrayList list = new ArrayList(); |
现在的写法:
1 | ArrayList<String> list = new ArrayList<String>(); |
如果是与以前的代码兼容,各种引用传值之间,必然会出现如下的情况:
1 | ArrayList<String> list1 = new ArrayList(); //第一种情况 |
这样是没有错误的,不过会有个编译时警告。
不过在第一种情况,可以实现与完全使用泛型参数一样的效果,第二种则没有效果。
因为类型检查就是编译时完成的, new ArrayList()只是在内存中开辟了一个存储空间,可以存储任何类型对象,而真正设计类型检查的是它的引用,因为我们是使用它引用 list1来调用它的方法,比如说调用 add方法,所以 list1引用能完成泛型类型的检查。而引用 list2没有使用泛型,所以不行。
举例子:
1 | public class Test { |
通过上面的例子,我们可以明白,类型检查就是针对引用的,谁是一个引用,用这个引用调用泛型方法,就会对这个引用调用的方法进行类型检测,而无关它真正引用的对象。
泛型中参数化类型为什么不考虑继承关系?
在Java中,像下面形式的引用传递是不允许的:
1 | ArrayList<String> list1 = new ArrayList<object>(); //编译错误 ArrayList<Object> |
我们先看第一种情况,将第一种情况拓展成下面的形式:
1 | ArrayList<object> list1 = new ArrayList<object>(); |
实际上,在第4行代码的时候,就会有编译错误。那么,我们先假设它编译没错。那么当我们使用 list2引用用 get()方法取值的时候,返回的都是 String类型的对象(上面提到了,类型检测是根据引用来决定的),可是它里面实际上已经被我们存放了 Object类型的对象,这样就会有 ClassCastException了。所以为了避免这种极易出现的错误,Java不允许进行这样的引用传递。(这也是泛型出现的原因,就是为了解决类型转换的问题,我们不能违背它的初衷)。
再看第二种情况,将第二种情况拓展成下面的形式:
1 | ArrayList<String> list1 = new ArrayList<String>(); |
没错,这样的情况比第一种情况好的多,最起码,在我们用 list2取值的时候不会出现 ClassCastException,因为是从 String转换为 Object。可是,这样做有什么意义呢,泛型出现的原因,就是为了解决类型转换的问题。我们使用了泛型,到头来,还是要自己强转,违背了泛型设计的初衷。所以java不允许这么干。再说,你如果又用 list2往里面 add()新的对象,那么到时候取得时候,我怎么知道我取出来的到底是 String类型的,还是 Object类型的呢?
所以,要格外注意,泛型中的引用传递的问题。
3-2.自动类型转换
因为类型擦除的问题,所以所有的泛型类型变量最后都会被替换为原始类型。
既然都被替换为原始类型,那么为什么我们在获取的时候,不需要进行强制类型转换呢?
看下 ArrayList.get()方法:
1 | public E get(int index) { |
可以看到,在 return之前,会根据泛型变量进行强转。假设泛型类型变量为 Date,虽然泛型信息会被擦除掉,但是会将 (E) elementData[index],编译为 (Date)elementData[index]。所以我们不用自己进行强转。当存取一个泛型域时也会自动插入强制类型转换。假设 Pair类的 value域是 public的,那么表达式:
1 | Date Date = pair.value; |
也会自动地在结果字节码中插入强制类型转换。
3-3.类型擦除与多态的冲突和解决方法
现在有这样一个泛型类:
1 | class Pair<t> { |
然后我们想要一个子类继承它。
1 | class DateInter extends Pair<Date> { |
在这个子类中,我们设定父类的泛型类型为 Pair<Date>,在子类中,我们覆盖了父类的两个方法,我们的原意是这样的:将父类的泛型类型限定为 Date,那么父类里面的两个方法的参数都为 Date类型。
1 | public Date getValue() { |
所以,我们在子类中重写这两个方法一点问题也没有,实际上,从他们的 @Override标签中也可以看到,一点问题也没有,实际上是这样的吗?
分析:实际上,类型擦除后,父类的的泛型类型全部变为了原始类型 Object,所以父类编译之后会变成下面的样子:
1 | class Pair { |
再看子类的两个重写的方法的类型:
1 | @Override |
先来分析 setValue方法,父类的类型是 Object,而子类的类型是 Date,参数类型不一样,这如果是在普通的继承关系中,根本就不会是重写,而是重载。
我们在一个main方法测试一下:
1 | public static void main(String[] args) throws ClassNotFoundException { |
如果是重载,那么子类中两个 setValue方法,一个是参数 Object类型,一个是 Date类型,可是我们发现,根本就没有这样的一个子类继承自父类的Object类型参数的方法。所以说,确实是重写了,而不是重载了。
为什么会这样呢?
原因是这样的,我们传入父类的泛型类型是 Date, Pair<Date>,我们的本意是将泛型类变为如下:
1 | class Pair { |
然后在子类中重写参数类型为Date的那两个方法,实现继承中的多态。
可是由于种种原因,虚拟机并不能将泛型类型变为 Date,只能将类型擦除掉,变为原始类型 Object。这样,我们的本意是进行重写,实现多态。可是类型擦除后,只能变为了重载。这样,类型擦除就和多态有了冲突。JVM知道你的本意吗?知道!!!可是它能直接实现吗,不能!!!如果真的不能的话,那我们怎么去重写我们想要的 Date类型参数的方法啊。
于是JVM采用了一个特殊的方法,来完成这项功能,那就是桥方法。
首先,我们用 javap -c className的方式反编译下 DateInter子类的字节码,结果如下:
1 | class com.tao.test.DateInter extends com.tao.test.Pair<java.util.Date> { |
从编译的结果来看,我们本意重写 setValue和 getValue方法的子类,竟然有4个方法,其实不用惊奇,最后的两个方法,就是编译器自己生成的桥方法。可以看到桥方法的参数类型都是Object,也就是说,子类中真正覆盖父类两个方法的就是这两个我们看不到的桥方法。而打在我们自己定义的 setvalue和 getValue方法上面的 @Override只不过是假象。而桥方法的内部实现,就只是去调用我们自己重写的那两个方法。
所以,虚拟机巧妙的使用了桥方法,来解决了类型擦除和多态的冲突。
不过,要提到一点,这里面的 setValue和 getValue这两个桥方法的意义又有不同。
setValue方法是为了解决类型擦除与多态之间的冲突。
而 getValue却有普遍的意义,怎么说呢,如果这是一个普通的继承关系:
那么父类的 setValue方法如下:
1 | public Object getValue() { |
而子类重写的方法是:
1 | public Date getValue() { |
其实这在普通的类继承中也是普遍存在的重写,这就是协变。
关于协变:。。。。。。
并且,还有一点也许会有疑问,子类中的桥方法 Object getValue()和 Date getValue()是同 时存在的,可是如果是常规的两个方法,他们的方法签名是一样的,也就是说虚拟机根本不能分别这两个方法。如果是我们自己编写Java代码,这样的代码是无法通过编译器的检查的,但是虚拟机却是允许这样做的,因为虚拟机通过参数类型和返回类型来确定一个方法,所以编译器为了实现泛型的多态允许自己做这个看起来”不合法”的事情,然后交给虚拟器去区别。
3-4.泛型类型变量不能是基本数据类型
不能用类型参数替换基本类型。就比如,没有 ArrayList<double>,只有 ArrayList<Double>。因为当类型擦除后, ArrayList的原始类型变为 Object,但是 Object类型不能存储 double值,只能引用 Double的值。
3-5.编译时集合的instanceof
1 | ArrayList<String> arrayList = new ArrayList<String>(); |
因为类型擦除之后, ArrayList<String>只剩下原始类型,泛型信息 String不存在了。
那么,编译时进行类型查询的时候使用下面的方法是错误的
1 | if( arrayList instanceof ArrayList<String>) |
3-6.泛型在静态方法和静态类中的问题
泛型类中的静态方法和静态变量不可以使用泛型类所声明的泛型类型参数
举例说明:
1 | public class Test2<T> { |
因为泛型类中的泛型参数的实例化的时候确定,而静态变量和静态方法不需要使用对象来调用。对象都没有创建,如何确定这个泛型参数是何种类型,所以当然是错误的。
但是要注意区分下面的一种情况:
1 | public class Test2<T> { |
因为这是一个泛型方法,在泛型方法中使用的T是自己在方法中定义的 T,而不是泛型类中的T。
java 名词
java 名词
“吃人”的那些Java名词:对象、引用、堆、栈️
经验都是慢慢积累的,天才不多| 第170篇
记得中学的课本上,有一篇名为《狂人日记》课文;那时候根本理解不了鲁迅写这篇文章要表达的中心思想,只觉得满篇的“吃人”令人心情压抑;老师在讲台上慷慨激昂的讲,大多数的同学同我一样,在课本面前“痴痴”的发呆。
作为一个有着8年Java编程经验的IT老兵,说起来很惭愧,我被Java当中的四五个名词一直困扰着:对象、引用、堆、栈、堆栈(栈可同堆栈,因此是四个名词,也是五个名词)。每次我看到这几个名词,都隐隐约约觉得自己在被一只无形的大口慢慢地吞噬,只剩下满地的衣服碎屑(为什么不是骨头,因为骨头也好吃)。
十几年后,再读《狂人日记》,恍然如梦:
鲁迅先生以狂人的口吻,再现了动乱时期下中国人的精神状态,视角新颖,文笔细腻又不乏辛辣之味。
当时的中国,混乱成了主色调。以清廷和孔教为主的封建旧思想还在潜移默化地影响着人们的思想,与此同时以革命和新思潮为主的现代思想已经开始了对大众灵魂的洗涤和冲击。
最近,和沉默王二技术交流群(120926808)的群友们交流后,Java中那四五个会吃人的名词:对象、引用、堆、栈、堆栈,似乎在脑海中也清晰了起来,尽管疑惑有时候仍然会在阴云密布时跑出来——正鉴于此,这篇文章恰好做一下归纳。
一、对象和引用
在Java中,尽管一切都可以看做是对象,但计算机操作的并非对象本身,而是对象的引用。 这话乍眼一看,似懂非懂。究竟什么是对象,什么又是引用呢?
先来看对象的定义:按照通俗的说法,每个对象都是某个类(class)的一个实例(instance)。那么,实例化的过程怎么描述呢?来看代码(类是String):
1 | new String("我是对象张三"); |
在Java中,实例化指的就是通过关键字“new”来创建对象的过程。以上代码在运行时就会创建两个对象——“我是对象张三”和”我是对象李四”;现在,该怎么操作他们呢?
我们都去过公园,见过几个大爷,他们很有一番本领——个个都能把风筝飞得老高老高,徒留我们眼馋的份!风筝飞那么高,没办法直接用手拽着飞啊,全要靠一根长长的看不见的结实的绳子来牵引!操作Java对象也是这个理,得有一根绳——也就是接下来要介绍的“引用”(我们肉眼也常常看不见它)。
1 | String zhangsan, lisi; |
这三行代码该怎么理解呢?
先来看第一行代码:String zhangsan, lisi;——声明了两个变量zhangsan和lisi,他们的类型为String。
①、歧义:zhangsan和lisi此时被称为引用。
你也许听过这样一句古文:“神之于形,犹利之于刀;未闻刀没而利存,岂容形亡而神在?”这是无神论者范缜(zhen)的名言,大致的意思就是:灵魂对于肉体来说,就像刀刃对于刀身;从没听说过刀身都没了刀刃还存在,那么怎么可能允许肉体死亡了而灵魂还在呢?
“引用”之于对象,就好比刀刃之于刀身,对象还没有创建,又怎么存在对象的“引用”呢?
如果zhangsan和lisi此时不能被称为“引用”,那么他们是什么呢?答案很简单,就是变量啊!(鄙人理解)
②、误解:zhangsan和lisi此时的默认值为null。
应该说zhangsan和lisi此时的值为undefined——借用JavaScript的关键字;也就是未定义;或者应该是一个新的关键字uninitialized——未初始化。但不管是undefined还是uninitialized,都与null不同。
既然没有初始化,zhangsan和lisi此时就不能被使用。假如强行使用的话,编译器就会报错,提醒zhangsan和lisi还没有出生(初始化);见下图。
如果把zhangsan和lisi初始化为null,编译器是认可的(见下图);由此可见,zhangsan和lisi此时的默认值不为null。
再来看第二行代码:zhangsan = new String("我是对象张三");——创建“我是对象张三”的String类对象,并将其赋值给zhangsan这个变量。
此时,zhangsan就是”我是对象张三”的引用;“=”操作符赋予了zhangsan这样神圣的权利。
第三行代码lisi = new String("我是对象李四");和第二行代码zhangsan = new String("我是对象张三");同理。
现在,我可以下这样一个结论了——对象是通过new关键字创建的;引用是依赖于对象的;=操作符把对象赋值给了引用。
我们再来看这样一段代码:
1 | String zhangsan, lisi; |
当zhangsan = lisi;执行过后,zhangsan就不再是”我是对象张三”的引用了;zhangsan和lisi指向了同一个对象(”我是对象李四”);因此,你知道System.out.println(zhangsan == lisi);打印的是false还是true了吗?
二、堆、栈、堆栈
谁来告诉我,为什么有很多地方(书、博客等等)把栈叫做堆栈,把堆栈叫做栈?搞得我都头晕目眩了——绕着门柱估计转了80圈,不晕才怪!
我查了一下金山词霸,结果如下:
我的天呐,更晕了,有没有!怎么才能不晕呢?我这里有几招武功秘籍,你们尽管拿去一睹为快:
1)以后再看到堆、栈、堆栈三个在一起打牌的时候,直接把“堆栈”踢出去;这仨人不适合在一起玩,因为堆和栈才是老相好;你“堆栈”来这插一脚算怎么回事;这世界上只存在“堆、栈”或者“堆栈”(标点符号很重要哦)。
2)堆是在程序运行时在内存中申请的空间(可理解为动态的过程);切记,不是在编译时;因此,Java中的对象就放在这里,这样做的好处就是:
当需要一个对象时,只需要通过new关键字写一行代码即可,当执行这行代码时,会自动在内存的“堆”区分配空间——这样就很灵活。
另外,需要记住,堆遵循“先进后出”的规则。就好像,一个和尚去挑了一担水,然后把一担水装缸里面,等到他口渴的时候他再用瓢舀出来喝。请放肆地打开你的脑洞脑补一下这个流程:缸底的水是先进去的,但后出来的。所以,我建议这位和尚在缸上贴个标签——保质期90天,过期饮用,后果自负!
还是记不住,看下图:
不好意思,这是鼎,不是缸,将就一下哈
3)栈,又名堆栈(简直了,完全不符合程序员的思维啊,我们陈许愿习惯说一就是一,说二就是二嘛),能够和处理器(CPU,也就是脑子)直接关联,因此访问速度更快;举个十分不恰当的例子哈——眼睛相对嘴巴是离脑子近的一方,因此,你可以一目十行,但绝对做不到一开口就读十行字,哪怕十个字也做不到。
既然访问速度快,要好好利用啊!Java就把对象的引用放在栈里。为什么呢?因为引用的使用频率高吗?
不是的,因为Java在编译程序时,必须明确的知道存储在栈里的东西的生命周期,否则就没法释放旧的内存来开辟新的内存空间存放引用——空间就那么大,前浪要把后浪拍死在沙滩上啊。
现在清楚堆、栈和堆栈了吧?
三、特殊的“对象”
先来看《Java编程思想》中的一段话:
在程序设计中经常用到一系列类型,他们需要特殊对待。之所以特殊对待,是因为new将对象存储于“堆”中,故用new创建一个对象──特别小、简单的变量,往往不是很有效。因此,不用new来创建这类变量,而是创建一个并非是引用的变量,这个变量直接存储值,并置于栈中,因此更加高效。
在Java中,这些基本类型有:boolean、char、byte、short、int、long、float、double和void;还有与之对应的包装器:Boolean、Character、Byte、Short、Integer、Long、Float、Double和Void;他们之间涉及到装箱和拆箱,我们有机会再聊。
看两行简单的代码:
1 | int a = 3; |
这两行代码在编译的时候是什么样子呢?
编译器当然是先处理int a = 3;,不然还能跳过吗?编译器在处理int a = 3;时在栈中创建了一个变量为a的内存空间,然后查找有没有字面值为3的地址,没找到,就开辟一个存放3这个字面值的地址,然后将a指向3的地址。
编译器忙完了int a = 3;,就来接着处理int b = 3;;在创建完b的变量后,由于栈中已经有3这个字面值,就将b直接指向3的地址;就不需要再开辟新的空间了。
依据上面的概述,我们假设在定义完a与b的值后,再令a=4,此时b是等于3呢,还是4呢?
思考一下,再看答案哈。
答案揭晓:当编译器遇到a = 4;时,它会重新搜索栈中是否有4的字面值,如果没有,重新开辟地址存放4的值;如果已经有了,则直接将a指向4这个地址;因此a值的改变不会影响到b的值哦。
最后,留个作业吧,下面这段代码在运行时会输出什么呢?
1 | public class Test1 { |
