StackOverflow 周报 - 这些高关注的问题你是否都会
我从 Stack Overflow 上找的了一些高关注度且高赞的问题。这些问题可能平时我们遇不到,但既然是高关注的问题和高点赞的回答说明是被大家普遍认可的,如果我们提前学到了以后不管工作中还是面试中处理起来就会更得心应手。本篇文章是第一周的内容,一共 5 个题目。我每天都会在公众号发一篇,你如果觉得这个系列对你有价值,欢迎文末关注我的公众号。
DAY1. 复合运算符中的强制转换
今天讨论的问题是“符合运算符中的强制转换”。以 += 为例,我编写了如下代码,你可以先考虑下为什么会出现下面这种情况。
int i = 5; long j = 10; i += j; //正常 i = i+j; //报错,Incompatible types.
这个问题可以从 “Java 语言手册” 中找到答案,原文如下:
A compound assignment expression of the form E1 op= E2 is equivalent to E1 = (T) ((E1) op (E2)), where T is the type of E1, except that E1 is evaluated only once.
翻译一下:形如 E1 op= E2 的复合赋值表达式等价于 E1 = (T)((E1) op (E2)), 其中,T 是 E1 的类型。所以,回到本例,i+j 的结果会强制转换成 int 再赋值给 i。
其实验证也比较容易,我们看下编译后的 .class 文件就知道做了什么处理。
从 .class 文件可以看出,有两处强制转换。第一处是 i+j 时,由于 j 是 long 类型,因此 i 进行类型提升,强转为 long, 这个过程我们比较熟悉。第二处是我们今天讨论的内容,i+j 的结果强转成了 int 类型。
这里面我们还可以在进一步思考,因为在这个例子中强转可能会导致计算结果溢出,那你可以想想为什么 Java 设计的时候不让它报错呢?
我的猜想是这样的,假设遇到这种情况报错,我们看看会有什么样的后果。比如在 byte 或者 short 类型中使用 += 运算符。
byte b = 1; b += 1;
按照我们的假设,这里就会报错,因为 i+1 返回的 int 类型。然而实际应用场景中这种代码很常见,因此,假设成立的话,将会严重影响复合赋值运算符的应用范围,最终设计出来可能就是一个比较鸡肋的东西。所以,为了普适性只能把判断交给用户,让用户来保障使用复合赋值运算符不会发生溢出。我们平时应用时一定要注意这个潜在的风险。
DAY2. 生成随机数你用对了吗
在 Java 中如何生成一个随机数?如果你的答案是 Random 类,那就有必要继续向下看了。Java 7 之前使用 Random 类生成随机数,Java 7 之后的标准做法是使用 ThreadLocalRandom 类,代码如下:
ThreadLocalRandom.current().nextInt();
既然 Java 7 要引入一个新的类取代之前的 Random 类,说明之前生成随机数的方式存在一定的问题,下面就结合源码简单介绍一下这两个类的区别。
Random 类是线程安全的,如果多线程同时使用一个 Random 实例生成随机数,那么就会共享同一个随机种子,从而存在并发问题导致性能下降,下面看看 next(int bits) 方法的源码:
protected int next(int bits) { long oldseed, nextseed; AtomicLong seed = this.seed; do { oldseed = seed.get(); nextseed = (oldseed * multiplier + addend) & mask; } while (!seed.compareAndSet(oldseed, nextseed)); return (int)(nextseed >>> (48 - bits)); }
看到代码并不复杂,其中,随机种子 seed 是 AtomicLong 类型的,并且使用 CAS 方式更新种子。
接下来再看看 ThreadLocalRandom 类,多线程调用 ThreadLocalRandom.current() 返回的是同一个 ThreadLocalRandom 实例,但它并不存在多线程同步的问题。看下它更新种子的代码:
final long nextSeed() { Thread t; long r; // read and update per-thread seed UNSAFE.putLong(t = Thread.currentThread(), SEED, r = UNSAFE.getLong(t, SEED) + GAMMA); return r; }
可以看到,这里面不存在线程同步的代码。猜测代码中使用了Thread.currentThread() 达到了 ThreadLocal 的目的,因此不存在线程安全的问题。使用 ThreadLocalRandom 还有个好处是不需要自己 new 对象,使用起来更方便。如果你的项目是 Java 7+ 并且仍在使用 Random 生成随机数,那么建议你切换成 ThreadLocalRandom。由于它继承了 Random 类,因此不会对你现有的代码造成很大的影响。
DAY3. InputStream转String有多少种方法
Java 中如果要将 InputStream 转成 String,你能想到多少种方法?
String str = "测试"; InputStream inputStream = new ByteArrayInputStream(str.getBytes());
1. 使用 ByteArrayOutputStream 循环读取
/** 1. 使用 ByteArrayOutputStream 循环读取 */ BufferedInputStream bis = new BufferedInputStream(inputStream); ByteArrayOutputStream buf = new ByteArrayOutputStream(); int tmpRes = bis.read(); while(tmpRes != -1) { buf.write((byte) tmpRes); tmpRes = bis.read(); } System.out.println(buf.toString());
2. 使用 InputStreamReader 批量读取
/** 2. 使用 InputStreamReader 批量读取 */ final char[] buffer = new char[1024]; final StringBuilder out = new StringBuilder(); Reader in = new InputStreamReader(inputStream); for (; ; ) { int rsz = in.read(buffer, 0, buffer.length); if (rsz < 0) { break; } out.append(buffer, 0, rsz); } System.out.println(out.toString());
3. 使用 JDK Scanner
/** 3. 使用 JDK Scanner */ Scanner s = new Scanner(inputStream).useDelimiter("\\A"); String result = s.hasNext() ? s.next() : ""; System.out.println(result);
4. 使用 Java 8 Stream API
/** 4. 使用 Java 8 Stream API */ result = new BufferedReader(new InputStreamReader(inputStream)) .lines().collect(Collectors.joining("\n")); System.out.println(result);
5. 使用 IOUtils StringWriter
/** 5. 使用 IOUtils StringWriter */ StringWriter stringWriter = new StringWriter(); IOUtils.copy(inputStream, stringWriter); System.out.println(stringWriter.toString());
6. 使用 IOUtils.toString 一步到位
/** 6. 使用 IOUtils.toString 一步到位 */ System.out.println(IOUtils.toString(inputStream));
这里我们用了 6 种方式实现,实际还会有更多的方法。简单总结一下这几个方法。
第一种和第二种方法使用原始的循环读取,代码量比较大。第三和第四种方法使用了 JDK 封装好的 API 可以明显减少代码量, 同时 Stream API 可以让我们将代码写成一行,更方便书写。最后使用 IOUtils 工具类(commons-io 库), 听名字就知道是专门做 IO 用的,它也提供了两种方式,第五种框架提供了更加开放,灵活的方式叫做 copy 方法,也就是说除了 copy 到 String 还可以 copy 到其他地方。第六种就完全的定制化,就是专门用来转 String 的,当然定制化的结果就是不灵活,但对于单纯转 String 这个需求来说却是最方便、最省事的。其实我们平时编程也是一样,对于一个产品需求有时候不需要暴露太多的开放性的选择,针对需求提供一个简单粗暴的实现方式也许是最佳选择。
最后补充一句,我们平时可以多关注框架,用到的时候直接拿过来省时省力,减少代码量。当然有兴趣的话我们也可以深入学习框架内部的设计和实现。
DAY4. 面试官:写个内存泄漏的例子
我们都是知道 Java 自带垃圾回收机制,内存泄漏这事好像跟 Java 程序员关系不大。所以,写 Java 程序一般会比 C/C++ 程序轻松一些。记得前领导写 C++ 代码时说过一句话,“写 C++ 程序一定会漏的,只不过是能不能被发现而已”。所以看来 C/C++ 程序员还是比较苦逼的,虽然他们经常鄙视 Java 程序员,哈哈~~。
尽管 Java 程序出现出现内存泄漏的可能性较少,但不代表不会出现。如果你哪天去面试,面试官让你用 Java 写一个内存泄漏的例子,你有思路吗?下面我就举一个内存泄漏的例子。
public final class ClassLoaderLeakExample { static volatile boolean running = true; /** * 1. main 函数,逻辑比较简单只是创建一个 LongRunningThread 线程,并接受停止的指令 */ public static void main(String[] args) throws Exception { Thread thread = new LongRunningThread(); try { thread.start(); System.out.println("Running, press any key to stop."); System.in.read(); } finally { running = false; thread.join(); } } /** * 2. 定义 LongRunningThread 线程,该线程做的事情比较简单,每隔 100ms 调用 loadAndDiscard 方法 */ static final class LongRunningThread extends Thread { @Override public void run() { while(running) { try { loadAndDiscard(); } catch (Throwable ex) { ex.printStackTrace(); } try { Thread.sleep(100); } catch (InterruptedException ex) { System.out.println("Caught InterruptedException, shutting down."); running = false; } } } } /** * 3. 定义一个 class loader - ChildOnlyClassLoader,它在我们的例子中至关重要。 * ChildOnlyClassLoader 专门用来装载 LoadedInChildClassLoader 类, * 逻辑比较简单,读取 LoadedInChildClassLoader 类的 .class 文件,返回类对象。 */ static final class ChildOnlyClassLoader extends ClassLoader { ChildOnlyClassLoader() { super(ClassLoaderLeakExample.class.getClassLoader()); } @Override protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { if (!LoadedInChildClassLoader.class.getName().equals(name)) { return super.loadClass(name, resolve); } try { Path path = Paths.get(LoadedInChildClassLoader.class.getName() + ".class"); byte[] classBytes = Files.readAllBytes(path); Class<?> c = defineClass(name, classBytes, 0, classBytes.length); if (resolve) { resolveClass(c); } return c; } catch (IOException ex) { throw new ClassNotFoundException("Could not load " + name, ex); } } } /** * 4. 编写 loadAndDiscard 方法的代码,也就是在 LongRunningThread 线程中被调用的方法。 * 该方法创建 ChildOnlyClassLoader 对象,用来装载 LoadedInChildClassLoader 类,将结果赋值给 childClass 变量, * childClass 调用 newInstance 方法来创建 LoadedInChildClassLoader 对象。 * 每次调用 loadAndDiscard 方法,都会加载一次 LoadedInChildClassLoader 类并创建其对象。 */ static void loadAndDiscard() throws Exception { ClassLoader childClassLoader = new ChildOnlyClassLoader(); Class<?> childClass = Class.forName( LoadedInChildClassLoader.class.getName(), true, childClassLoader); childClass.newInstance(); } /** * 5. 定义 LoadedInChildClassLoader 类 * 该类中定义了一个 moreBytesToLeak 字节数组,初始大小比较大是为了尽快模拟出内存泄漏的结果。 * 在类的构造方法调用 threadLocal 的 set 方法存储对象本身的引用。 */ public static final class LoadedInChildClassLoader { static final byte[] moreBytesToLeak = new byte[1024 * 1024 * 10]; private static final ThreadLocal<LoadedInChildClassLoader> threadLocal = new ThreadLocal<>(); public LoadedInChildClassLoader() { threadLocal.set(this); } } }
这是完整的例子, 可以按照注释中的序号的顺序阅读代码。最后运行代码,在 ClassLoaderLeakExample 类所在的目录下执行以下命令
javac ClassLoaderLeakExample.java
java -cp . ClassLoaderLeakExample
运行后会打印 "Running, press any key to stop." 等一分钟左右就会报内存不足的错误 "java.lang.OutOfMemoryError: Java heap space" 。
简单梳理一下逻辑,loadAndDiscard 方法会不断地被调用,每次被调用在该方法中都会加载一次 LoadedInChildClassLoader 类,每加载一次类就会创建一个新的threadLocal 和 moreBytesToLeak 属性。虽然创建的 LoadedInChildClassLoader 对象是局部变量,但退出 loadAndDiscard 方法后该对象仍然不会被回收,因为 threadLocal 保存了该对象的引用,对象保存了对类的引用,而类保存了对类加载器的引用,类加载器反过来保存对它已加载的类的引用。因此虽然退出 loadAndDiscard 方法,该对象对我们不可见了,但是它永远不会被回收。随着每次加载的类越来越多,创建的 moreBytesToLeak 越来越多并且内存得不到清理,会导致 OutOfMemory 错误。
为了对比你可以去掉自定义类加载器这个参数,loadAndDiscard 方法中的代码修改如下:
Class<?> childClass = Class.forName( LoadedInChildClassLoader.class.getName(), true, childClassLoader); //改为: Class<?> childClass = Class.forName( LoadedInChildClassLoader.class.getName());
再运行就不会出现 OOM 的错误。修改之后,无论 loadAndDiscard 方法被调用多少次都只会加载一次 LoadedInChildClassLoader 类,也就是说只有一个 threadLocal 和 moreBytesToLeak 属性。当再次创建 LoadedInChildClassLoader 对象时,threadLocal 会设置成当前的对象,之前 set 的对象就没有任何变量引用它,因此之前的对象会被回收。
DAY5. 为什么密码用 char[] 存储而不用String
周五,放松一下。一起来看一个无需写代码的问题“为什么 Java 程序中用 char[] 保存密码而不用 String”。既然提到密码,我们用脚指头想想也知道肯定是出于安全性的考虑。具体的是为什么呢?我这里提供两点答案供你参考。
先说第一点,也是最重要的一点。String 存储的字符串是不可变的,也就是说用它存储密码后,这块内存是无法被人为改变的。并且只能等 GC 将其清除。如果有其他进程恶意将内存 dump 下来,就可能会造成密码泄露。
然而使用 char[] 存储密码对我们来说就是可控的,我们可以在任何时候将 char[] 的内容设置为空或者其他无意义的字符,从而保证密码不会长期驻留内存。相对使用 String 存储密码来说更加安全。
再说说第二点,假设我们在程序中无意地将密码打印到日志中了。如果使用 String 存储密码将会被明文输出,而使用 char[] 存储密码只会输出地址不会泄露密码。
这两点都是从安全性的角度出发。
第一点更侧重防止密码驻留内存不安全,第二点则侧重防止密码驻留外存。虽然第二点发生的概率比较低,但也给了我们一个新的视角。
以上便是 Stack Overflow 的第一周周报,希望对你有用,后续会继续更新,如果想看日更内容欢迎关注公众号。
欢迎关注公众号「渡码」,分享更多高质量内容