mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 22:23:45 +08:00
mod
This commit is contained in:
523
极客时间专栏/Java业务开发常见错误100例/加餐/31 | 加餐1:带你吃透课程中Java 8的那些重要知识点(一).md
Normal file
523
极客时间专栏/Java业务开发常见错误100例/加餐/31 | 加餐1:带你吃透课程中Java 8的那些重要知识点(一).md
Normal file
@@ -0,0 +1,523 @@
|
||||
<audio id="audio" title="31 | 加餐1:带你吃透课程中Java 8的那些重要知识点(一)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d1/02/d18d0d701acdb2459e3eeb9644acae02.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。
|
||||
|
||||
Java 8是目前最常用的JDK版本,在增强代码可读性、简化代码方面,相比Java 7增加了很多功能,比如Lambda、Stream流操作、并行流(ParallelStream)、Optional可空类型、新日期时间类型等。
|
||||
|
||||
这个课程中的所有案例,都充分使用了Java 8的各种特性来简化代码。这也就意味着,如果你不了解这些特性的话,理解课程内的Demo可能会有些困难。因此,我将这些特性,单独拎了出来组成了两篇加餐。由于后面有单独一节课去讲Java 8的日期时间类型,所以这里就不赘述了。
|
||||
|
||||
## 如何在项目中用上Lambda表达式和Stream操作?
|
||||
|
||||
Java 8的特性有很多,除了这两篇加餐外,我再给你推荐一本全面介绍Java 8的书,叫《Java实战(第二版)》。此外,有同学在留言区问,怎么把Lambda表达式和Stream操作运用到项目中。其实,业务代码中可以使用这些特性的地方有很多。
|
||||
|
||||
这里,为了帮助你学习,并把这些特性用到业务开发中,我有三个小建议。
|
||||
|
||||
第一,从List的操作开始,先尝试把遍历List来筛选数据和转换数据的操作,使用Stream的filter和map实现,这是Stream最常用、最基本的两个API。你可以重点看看接下来两节的内容来入门。
|
||||
|
||||
第二,使用高级的IDE来写代码,以此找到可以利用Java 8语言特性简化代码的地方。比如,对于IDEA,我们可以把匿名类型使用Lambda替换的检测规则,设置为Error级别严重程度:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/67/77/6707ccf4415c2d8715ed2529cfdec877.png" alt="">
|
||||
|
||||
这样运行IDEA的Inspect Code的功能,可以在Error级别的错误中看到这个问题,引起更多关注,帮助我们建立使用Lambda表达式的习惯:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/50/e4/5062b3ef6ec57ccde0f3f4b182811be4.png" alt="">
|
||||
|
||||
第三,如果你不知道如何把匿名类转换为Lambda表达式,可以借助IDE来重构:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/e7/5a55c4284e4b10f659b7bcf0129cbde7.png" alt="">
|
||||
|
||||
反过来,如果你在学习课程内案例时,如果感觉阅读Lambda表达式和Stream API比较吃力,同样可以借助IDE把Java 8的写法转换为使用循环的写法:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/98/8a/98828a36d6bb7b7972a647b37a64f08a.png" alt="">
|
||||
|
||||
或者是把Lambda表达式替换为匿名类:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ee/7c/ee9401683b19e57462cb2574c285d67c.png" alt="">
|
||||
|
||||
## Lambda表达式
|
||||
|
||||
Lambda表达式的初衷是,进一步简化匿名类的语法(不过实现上,Lambda表达式并不是匿名类的语法糖),使Java走向函数式编程。对于匿名类,虽然没有类名,但还是要给出方法定义。这里有个例子,分别使用匿名类和Lambda表达式创建一个线程打印字符串:
|
||||
|
||||
```
|
||||
//匿名类
|
||||
new Thread(new Runnable(){
|
||||
@Override
|
||||
public void run(){
|
||||
System.out.println("hello1");
|
||||
}
|
||||
}).start();
|
||||
//Lambda表达式
|
||||
new Thread(() -> System.out.println("hello2")).start();
|
||||
|
||||
```
|
||||
|
||||
那么,Lambda表达式如何匹配Java的类型系统呢?
|
||||
|
||||
答案就是,函数式接口。
|
||||
|
||||
函数式接口是一种只有单一抽象方法的接口,使用@FunctionalInterface来描述,可以隐式地转换成 Lambda 表达式。使用Lambda表达式来实现函数式接口,不需要提供类名和方法定义,通过一行代码提供函数式接口的实例,就可以让函数成为程序中的头等公民,可以像普通数据一样作为参数传递,而不是作为一个固定的类中的固定方法。
|
||||
|
||||
那,函数式接口到底是什么样的呢?java.util.function包中定义了各种函数式接口。比如,用于提供数据的Supplier接口,就只有一个get抽象方法,没有任何入参、有一个返回值:
|
||||
|
||||
```
|
||||
@FunctionalInterface
|
||||
public interface Supplier<T> {
|
||||
|
||||
/**
|
||||
* Gets a result.
|
||||
*
|
||||
* @return a result
|
||||
*/
|
||||
T get();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以使用Lambda表达式或方法引用,来得到Supplier接口的实例:
|
||||
|
||||
```
|
||||
//使用Lambda表达式提供Supplier接口实现,返回OK字符串
|
||||
Supplier<String> stringSupplier = ()->"OK";
|
||||
//使用方法引用提供Supplier接口实现,返回空字符串
|
||||
Supplier<String> supplier = String::new;
|
||||
|
||||
```
|
||||
|
||||
这样,是不是很方便?为了帮你掌握函数式接口及其用法,我再举几个使用Lambda表达式或方法引用来构建函数的例子:
|
||||
|
||||
```
|
||||
//Predicate接口是输入一个参数,返回布尔值。我们通过and方法组合两个Predicate条件,判断是否值大于0并且是偶数
|
||||
Predicate<Integer> positiveNumber = i -> i > 0;
|
||||
Predicate<Integer> evenNumber = i -> i % 2 == 0;
|
||||
assertTrue(positiveNumber.and(evenNumber).test(2));
|
||||
|
||||
//Consumer接口是消费一个数据。我们通过andThen方法组合调用两个Consumer,输出两行abcdefg
|
||||
Consumer<String> println = System.out::println;
|
||||
println.andThen(println).accept("abcdefg");
|
||||
|
||||
//Function接口是输入一个数据,计算后输出一个数据。我们先把字符串转换为大写,然后通过andThen组合另一个Function实现字符串拼接
|
||||
Function<String, String> upperCase = String::toUpperCase;
|
||||
Function<String, String> duplicate = s -> s.concat(s);
|
||||
assertThat(upperCase.andThen(duplicate).apply("test"), is("TESTTEST"));
|
||||
|
||||
//Supplier是提供一个数据的接口。这里我们实现获取一个随机数
|
||||
Supplier<Integer> random = ()->ThreadLocalRandom.current().nextInt();
|
||||
System.out.println(random.get());
|
||||
|
||||
//BinaryOperator是输入两个同类型参数,输出一个同类型参数的接口。这里我们通过方法引用获得一个整数加法操作,通过Lambda表达式定义一个减法操作,然后依次调用
|
||||
BinaryOperator<Integer> add = Integer::sum;
|
||||
BinaryOperator<Integer> subtraction = (a, b) -> a - b;
|
||||
assertThat(subtraction.apply(add.apply(1, 2), 3), is(0));
|
||||
|
||||
```
|
||||
|
||||
Predicate、Function等函数式接口,还使用default关键字实现了几个默认方法。这样一来,它们既可以满足函数式接口只有一个抽象方法,又能为接口提供额外的功能:
|
||||
|
||||
```
|
||||
@FunctionalInterface
|
||||
public interface Function<T, R> {
|
||||
R apply(T t);
|
||||
default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
|
||||
Objects.requireNonNull(before);
|
||||
return (V v) -> apply(before.apply(v));
|
||||
}
|
||||
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
|
||||
Objects.requireNonNull(after);
|
||||
return (T t) -> after.apply(apply(t));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
很明显,Lambda表达式给了我们复用代码的更多可能性:我们可以把一大段逻辑中变化的部分抽象出函数式接口,由外部方法提供函数实现,重用方法内的整体逻辑处理。
|
||||
|
||||
不过需要注意的是,在自定义函数式接口之前,可以先确认下[java.util.function包](https://docs.oracle.com/javase/8/docs/api/java/util/function/package-summary.html)中的43个标准函数式接口是否能满足需求,我们要尽可能重用这些接口,因为使用大家熟悉的标准接口可以提高代码的可读性。
|
||||
|
||||
## 使用Java 8简化代码
|
||||
|
||||
这一部分,我会通过几个具体的例子,带你感受一下使用Java 8简化代码的三个重要方面:
|
||||
|
||||
- 使用Stream简化集合操作;
|
||||
- 使用Optional简化判空逻辑;
|
||||
- JDK8结合Lambda和Stream对各种类的增强。
|
||||
|
||||
### 使用Stream简化集合操作
|
||||
|
||||
Lambda表达式可以帮我们用简短的代码实现方法的定义,给了我们复用代码的更多可能性。利用这个特性,我们可以把集合的投影、转换、过滤等操作抽象成通用的接口,然后通过Lambda表达式传入其具体实现,这也就是Stream操作。
|
||||
|
||||
我们看一个具体的例子。这里有一段20行左右的代码,实现了如下的逻辑:
|
||||
|
||||
- 把整数列表转换为Point2D列表;
|
||||
- 遍历Point2D列表过滤出Y轴>1的对象;
|
||||
- 计算Point2D点到原点的距离;
|
||||
- 累加所有计算出的距离,并计算距离的平均值。
|
||||
|
||||
```
|
||||
private static double calc(List<Integer> ints) {
|
||||
//临时中间集合
|
||||
List<Point2D> point2DList = new ArrayList<>();
|
||||
for (Integer i : ints) {
|
||||
point2DList.add(new Point2D.Double((double) i % 3, (double) i / 3));
|
||||
}
|
||||
//临时变量,纯粹是为了获得最后结果需要的中间变量
|
||||
double total = 0;
|
||||
int count = 0;
|
||||
|
||||
for (Point2D point2D : point2DList) {
|
||||
//过滤
|
||||
if (point2D.getY() > 1) {
|
||||
//算距离
|
||||
double distance = point2D.distance(0, 0);
|
||||
total += distance;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
//注意count可能为0的可能
|
||||
return count >0 ? total / count : 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
现在,我们可以使用Stream配合Lambda表达式来简化这段代码。简化后一行代码就可以实现这样的逻辑,更重要的是代码可读性更强了,通过方法名就可以知晓大概是在做什么事情。比如:
|
||||
|
||||
- map方法传入的是一个Function,可以实现对象转换;
|
||||
- filter方法传入一个Predicate,实现对象的布尔判断,只保留返回true的数据;
|
||||
- mapToDouble用于把对象转换为double;
|
||||
- 通过average方法返回一个OptionalDouble,代表可能包含值也可能不包含值的可空double。
|
||||
|
||||
下面的第三行代码,就实现了上面方法的所有工作:
|
||||
|
||||
```
|
||||
List<Integer> ints = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8);
|
||||
double average = calc(ints);
|
||||
double streamResult = ints.stream()
|
||||
.map(i -> new Point2D.Double((double) i % 3, (double) i / 3))
|
||||
.filter(point -> point.getY() > 1)
|
||||
.mapToDouble(point -> point.distance(0, 0))
|
||||
.average()
|
||||
.orElse(0);
|
||||
//如何用一行代码来实现,比较一下可读性
|
||||
assertThat(average, is(streamResult));
|
||||
|
||||
```
|
||||
|
||||
到这里,你可能会问了,OptionalDouble又是怎么回事儿?
|
||||
|
||||
### 有关Optional可空类型
|
||||
|
||||
其实,类似OptionalDouble、OptionalInt、OptionalLong等,是服务于基本类型的可空对象。此外,Java8还定义了用于引用类型的Optional类。使用Optional,不仅可以避免使用Stream进行级联调用的空指针问题;更重要的是,它提供了一些实用的方法帮我们避免判空逻辑。
|
||||
|
||||
如下是一些例子,演示了如何使用Optional来避免空指针,以及如何使用它的fluent API简化冗长的if-else判空逻辑:
|
||||
|
||||
```
|
||||
@Test(expected = IllegalArgumentException.class)
|
||||
public void optional() {
|
||||
//通过get方法获取Optional中的实际值
|
||||
assertThat(Optional.of(1).get(), is(1));
|
||||
//通过ofNullable来初始化一个null,通过orElse方法实现Optional中无数据的时候返回一个默认值
|
||||
assertThat(Optional.ofNullable(null).orElse("A"), is("A"));
|
||||
//OptionalDouble是基本类型double的Optional对象,isPresent判断有无数据
|
||||
assertFalse(OptionalDouble.empty().isPresent());
|
||||
//通过map方法可以对Optional对象进行级联转换,不会出现空指针,转换后还是一个Optional
|
||||
assertThat(Optional.of(1).map(Math::incrementExact).get(), is(2));
|
||||
//通过filter实现Optional中数据的过滤,得到一个Optional,然后级联使用orElse提供默认值
|
||||
assertThat(Optional.of(1).filter(integer -> integer % 2 == 0).orElse(null), is(nullValue()));
|
||||
//通过orElseThrow实现无数据时抛出异常
|
||||
Optional.empty().orElseThrow(IllegalArgumentException::new);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我把Optional类的常用方法整理成了一张图,你可以对照案例再复习一下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c8/52/c8a901bb16b9fca07ae0fc8bb222b252.jpg" alt="">
|
||||
|
||||
### Java 8类对于函数式API的增强
|
||||
|
||||
除了Stream之外,Java 8中有很多类也都实现了函数式的功能。
|
||||
|
||||
比如,要通过HashMap实现一个缓存的操作,在Java 8之前我们可能会写出这样的getProductAndCache方法:先判断缓存中是否有值;如果没有值,就从数据库搜索取值;最后,把数据加入缓存。
|
||||
|
||||
```
|
||||
private Map<Long, Product> cache = new ConcurrentHashMap<>();
|
||||
|
||||
private Product getProductAndCache(Long id) {
|
||||
Product product = null;
|
||||
//Key存在,返回Value
|
||||
if (cache.containsKey(id)) {
|
||||
product = cache.get(id);
|
||||
} else {
|
||||
//不存在,则获取Value
|
||||
//需要遍历数据源查询获得Product
|
||||
for (Product p : Product.getData()) {
|
||||
if (p.getId().equals(id)) {
|
||||
product = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
//加入ConcurrentHashMap
|
||||
if (product != null)
|
||||
cache.put(id, product);
|
||||
}
|
||||
return product;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void notcoolCache() {
|
||||
getProductAndCache(1L);
|
||||
getProductAndCache(100L);
|
||||
|
||||
System.out.println(cache);
|
||||
assertThat(cache.size(), is(1));
|
||||
assertTrue(cache.containsKey(1L));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
而在Java 8中,我们利用ConcurrentHashMap的computeIfAbsent方法,用一行代码就可以实现这样的繁琐操作:
|
||||
|
||||
```
|
||||
private Product getProductAndCacheCool(Long id) {
|
||||
return cache.computeIfAbsent(id, i -> //当Key不存在的时候提供一个Function来代表根据Key获取Value的过程
|
||||
Product.getData().stream()
|
||||
.filter(p -> p.getId().equals(i)) //过滤
|
||||
.findFirst() //找第一个,得到Optional<Product>
|
||||
.orElse(null)); //如果找不到Product,则使用null
|
||||
}
|
||||
|
||||
@Test
|
||||
public void coolCache()
|
||||
{
|
||||
getProductAndCacheCool(1L);
|
||||
getProductAndCacheCool(100L);
|
||||
|
||||
System.out.println(cache);
|
||||
assertThat(cache.size(), is(1));
|
||||
assertTrue(cache.containsKey(1L));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
computeIfAbsent方法在逻辑上相当于:
|
||||
|
||||
```
|
||||
if (map.get(key) == null) {
|
||||
V newValue = mappingFunction.apply(key);
|
||||
if (newValue != null)
|
||||
map.put(key, newValue);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
又比如,利用Files.walk返回一个Path的流,通过两行代码就能实现递归搜索+grep的操作。整个逻辑是:递归搜索文件夹,查找所有的.java文件;然后读取文件每一行内容,用正则表达式匹配public class关键字;最后输出文件名和这行内容。
|
||||
|
||||
```
|
||||
@Test
|
||||
public void filesExample() throws IOException {
|
||||
//无限深度,递归遍历文件夹
|
||||
try (Stream<Path> pathStream = Files.walk(Paths.get("."))) {
|
||||
pathStream.filter(Files::isRegularFile) //只查普通文件
|
||||
.filter(FileSystems.getDefault().getPathMatcher("glob:**/*.java")::matches) //搜索java源码文件
|
||||
.flatMap(ThrowingFunction.unchecked(path ->
|
||||
Files.readAllLines(path).stream() //读取文件内容,转换为Stream<List>
|
||||
.filter(line -> Pattern.compile("public class").matcher(line).find()) //使用正则过滤带有public class的行
|
||||
.map(line -> path.getFileName() + " >> " + line))) //把这行文件内容转换为文件名+行
|
||||
.forEach(System.out::println); //打印所有的行
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
输出结果如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/84/12/84349a90ef4aaf30032d0a8f64ab4512.png" alt="">
|
||||
|
||||
我再和你分享一个小技巧吧。因为Files.readAllLines方法会抛出一个受检异常(IOException),所以我使用了一个自定义的函数式接口,用ThrowingFunction包装这个方法,把受检异常转换为运行时异常,让代码更清晰:
|
||||
|
||||
```
|
||||
@FunctionalInterface
|
||||
public interface ThrowingFunction<T, R, E extends Throwable> {
|
||||
static <T, R, E extends Throwable> Function<T, R> unchecked(ThrowingFunction<T, R, E> f) {
|
||||
return t -> {
|
||||
try {
|
||||
return f.apply(t);
|
||||
} catch (Throwable e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
R apply(T t) throws E;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果用Java 7实现类似逻辑的话,大概需要几十行代码,你可以尝试下。
|
||||
|
||||
## 并行流
|
||||
|
||||
前面我们看到的Stream操作都是串行Stream,操作只是在一个线程中执行,此外Java 8还提供了并行流的功能:通过parallel方法,一键把Stream转换为并行操作提交到线程池处理。
|
||||
|
||||
比如,如下代码通过线程池来并行消费处理1到100:
|
||||
|
||||
```
|
||||
IntStream.rangeClosed(1,100).parallel().forEach(i->{
|
||||
System.out.println(LocalDateTime.now() + " : " + i);
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException e) { }
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
并行流不确保执行顺序,并且因为每次处理耗时1秒,所以可以看到在8核机器上,数组是按照8个一组1秒输出一次:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f1/d6/f114d98aa2530c3f7e91b06aaa4ee1d6.png" alt="">
|
||||
|
||||
在这个课程中,有很多类似使用threadCount个线程对某个方法总计执行taskCount次操作的案例,用于演示并发情况下的多线程问题或多线程处理性能。除了会用到并行流,我们有时也会使用线程池或直接使用线程进行类似操作。为了方便你对比各种实现,这里我一次性给出实现此类操作的五种方式。
|
||||
|
||||
为了测试这五种实现方式,我们设计一个场景:使用20个线程(threadCount)以并行方式总计执行10000次(taskCount)操作。因为单个任务单线程执行需要10毫秒(任务代码如下),也就是每秒吞吐量是100个操作,那20个线程QPS是2000,执行完10000次操作最少耗时5秒。
|
||||
|
||||
```
|
||||
private void increment(AtomicInteger atomicInteger) {
|
||||
atomicInteger.incrementAndGet();
|
||||
try {
|
||||
TimeUnit.MILLISECONDS.sleep(10);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
现在我们测试一下这五种方式,是否都可以利用更多的线程并行执行操作。
|
||||
|
||||
第一种方式是使用线程。直接把任务按照线程数均匀分割,分配到不同的线程执行,使用CountDownLatch来阻塞主线程,直到所有线程都完成操作。这种方式,需要我们自己分割任务:
|
||||
|
||||
```
|
||||
private int thread(int taskCount, int threadCount) throws InterruptedException {
|
||||
//总操作次数计数器
|
||||
AtomicInteger atomicInteger = new AtomicInteger();
|
||||
//使用CountDownLatch来等待所有线程执行完成
|
||||
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
|
||||
//使用IntStream把数字直接转为Thread
|
||||
IntStream.rangeClosed(1, threadCount).mapToObj(i -> new Thread(() -> {
|
||||
//手动把taskCount分成taskCount份,每一份有一个线程执行
|
||||
IntStream.rangeClosed(1, taskCount / threadCount).forEach(j -> increment(atomicInteger));
|
||||
//每一个线程处理完成自己那部分数据之后,countDown一次
|
||||
countDownLatch.countDown();
|
||||
})).forEach(Thread::start);
|
||||
//等到所有线程执行完成
|
||||
countDownLatch.await();
|
||||
//查询计数器当前值
|
||||
return atomicInteger.get();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第二种方式是,使用Executors.newFixedThreadPool来获得固定线程数的线程池,使用execute提交所有任务到线程池执行,最后关闭线程池等待所有任务执行完成:
|
||||
|
||||
```
|
||||
private int threadpool(int taskCount, int threadCount) throws InterruptedException {
|
||||
//总操作次数计数器
|
||||
AtomicInteger atomicInteger = new AtomicInteger();
|
||||
//初始化一个线程数量=threadCount的线程池
|
||||
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
|
||||
//所有任务直接提交到线程池处理
|
||||
IntStream.rangeClosed(1, taskCount).forEach(i -> executorService.execute(() -> increment(atomicInteger)));
|
||||
//提交关闭线程池申请,等待之前所有任务执行完成
|
||||
executorService.shutdown();
|
||||
executorService.awaitTermination(1, TimeUnit.HOURS);
|
||||
//查询计数器当前值
|
||||
return atomicInteger.get();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第三种方式是,使用ForkJoinPool而不是普通线程池执行任务。
|
||||
|
||||
ForkJoinPool和传统的ThreadPoolExecutor区别在于,前者对于n并行度有n个独立队列,后者是共享队列。如果有大量执行耗时比较短的任务,ThreadPoolExecutor的单队列就可能会成为瓶颈。这时,使用ForkJoinPool性能会更好。
|
||||
|
||||
因此,ForkJoinPool更适合大任务分割成许多小任务并行执行的场景,而ThreadPoolExecutor适合许多独立任务并发执行的场景。
|
||||
|
||||
在这里,我们先自定义一个具有指定并行数的ForkJoinPool,再通过这个ForkJoinPool并行执行操作:
|
||||
|
||||
```
|
||||
private int forkjoin(int taskCount, int threadCount) throws InterruptedException {
|
||||
//总操作次数计数器
|
||||
AtomicInteger atomicInteger = new AtomicInteger();
|
||||
//自定义一个并行度=threadCount的ForkJoinPool
|
||||
ForkJoinPool forkJoinPool = new ForkJoinPool(threadCount);
|
||||
//所有任务直接提交到线程池处理
|
||||
forkJoinPool.execute(() -> IntStream.rangeClosed(1, taskCount).parallel().forEach(i -> increment(atomicInteger)));
|
||||
//提交关闭线程池申请,等待之前所有任务执行完成
|
||||
forkJoinPool.shutdown();
|
||||
forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
|
||||
//查询计数器当前值
|
||||
return atomicInteger.get();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第四种方式是,直接使用并行流,并行流使用公共的ForkJoinPool,也就是ForkJoinPool.commonPool()。
|
||||
|
||||
公共的ForkJoinPool默认的并行度是CPU核心数-1,原因是对于CPU绑定的任务分配超过CPU个数的线程没有意义。由于并行流还会使用主线程执行任务,也会占用一个CPU核心,所以公共ForkJoinPool的并行度即使-1也能用满所有CPU核心。
|
||||
|
||||
这里,我们通过配置强制指定(增大)了并行数,但因为使用的是公共ForkJoinPool,所以可能会存在干扰,你可以回顾下[第3讲](https://time.geekbang.org/column/article/210337)有关线程池混用产生的问题:
|
||||
|
||||
```
|
||||
private int stream(int taskCount, int threadCount) {
|
||||
//设置公共ForkJoinPool的并行度
|
||||
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", String.valueOf(threadCount));
|
||||
//总操作次数计数器
|
||||
AtomicInteger atomicInteger = new AtomicInteger();
|
||||
//由于我们设置了公共ForkJoinPool的并行度,直接使用parallel提交任务即可
|
||||
IntStream.rangeClosed(1, taskCount).parallel().forEach(i -> increment(atomicInteger));
|
||||
//查询计数器当前值
|
||||
return atomicInteger.get();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第五种方式是,使用CompletableFuture来实现。CompletableFuture.runAsync方法可以指定一个线程池,一般会在使用CompletableFuture的时候用到:
|
||||
|
||||
```
|
||||
private int completableFuture(int taskCount, int threadCount) throws InterruptedException, ExecutionException {
|
||||
//总操作次数计数器
|
||||
AtomicInteger atomicInteger = new AtomicInteger();
|
||||
//自定义一个并行度=threadCount的ForkJoinPool
|
||||
ForkJoinPool forkJoinPool = new ForkJoinPool(threadCount);
|
||||
//使用CompletableFuture.runAsync通过指定线程池异步执行任务
|
||||
CompletableFuture.runAsync(() -> IntStream.rangeClosed(1, taskCount).parallel().forEach(i -> increment(atomicInteger)), forkJoinPool).get();
|
||||
//查询计数器当前值
|
||||
return atomicInteger.get();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上面这五种方法都可以实现类似的效果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/77/cc/77c42149013fd82c18d39b5e0d0292cc.png" alt="">
|
||||
|
||||
可以看到,这5种方式执行完10000个任务的耗时都在5.4秒到6秒之间。这里的结果只是证明并行度的设置是有效的,并不是性能比较。
|
||||
|
||||
如果你的程序对性能要求特别敏感,建议通过性能测试根据场景决定适合的模式。一般而言,使用线程池(第二种)和直接使用并行流(第四种)的方式在业务代码中比较常用。但需要注意的是,我们通常会重用线程池,而不会像Demo中那样在业务逻辑中直接声明新的线程池,等操作完成后再关闭。
|
||||
|
||||
**另外需要注意的是,在上面的例子中我们一定是先运行stream方法再运行forkjoin方法,对公共ForkJoinPool默认并行度的修改才能生效。**
|
||||
|
||||
这是因为ForkJoinPool类初始化公共线程池是在静态代码块里,加载类时就会进行的,如果forkjoin方法中先使用了ForkJoinPool,即便stream方法中设置了系统属性也不会起作用。因此我的建议是,设置ForkJoinPool公共线程池默认并行度的操作,应该放在应用启动时设置。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我和你简单介绍了Java 8中最重要的几个功能,包括Lambda表达式、Stream流式操作、Optional可空对象、并行流操作。这些特性,可以帮助我们写出简单易懂、可读性更强的代码。特别是使用Stream的链式方法,可以用一行代码完成之前几十行代码的工作。
|
||||
|
||||
因为Stream的API非常多,使用方法也是千变万化,因此我会在下一讲和你详细介绍Stream API的一些使用细节。
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. 检查下代码中是否有使用匿名类,以及通过遍历List进行数据过滤、转换和聚合的代码,看看能否使用Lambda表达式和Stream来重新实现呢?
|
||||
1. 对于并行流部分的并行消费处理1到100的例子,如果把forEach替换为forEachOrdered,你觉得会发生什么呢?
|
||||
|
||||
关于Java 8,你还有什么使用心得吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。
|
||||
419
极客时间专栏/Java业务开发常见错误100例/加餐/32 | 加餐2:带你吃透课程中Java 8的那些重要知识点(二).md
Normal file
419
极客时间专栏/Java业务开发常见错误100例/加餐/32 | 加餐2:带你吃透课程中Java 8的那些重要知识点(二).md
Normal file
@@ -0,0 +1,419 @@
|
||||
<audio id="audio" title="32 | 加餐2:带你吃透课程中Java 8的那些重要知识点(二)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/60/4c/60f75bbd9b617b4e4991117b1803e64c.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。
|
||||
|
||||
上一讲的几个例子中,其实都涉及了Stream API的最基本使用方法。今天,我会与你详细介绍复杂、功能强大的Stream API。
|
||||
|
||||
Stream流式操作,用于对集合进行投影、转换、过滤、排序等,更进一步地,这些操作能链式串联在一起使用,类似于SQL语句,可以大大简化代码。可以说,Stream操作是Java 8中最重要的内容,也是这个课程大部分代码都会用到的操作。
|
||||
|
||||
我先说明下,有些案例可能不太好理解,建议你对着代码逐一到源码中查看Stream操作的方法定义,以及JDK中的代码注释。
|
||||
|
||||
## Stream操作详解
|
||||
|
||||
为了方便你理解Stream的各种操作,以及后面的案例,我先把这节课涉及的Stream操作汇总到了一张图中。你可以先熟悉一下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/44/04/44a6f4cb8b413ef62c40a272cb474104.jpg" alt="">
|
||||
|
||||
在接下来的讲述中,我会围绕订单场景,给出如何使用Stream的各种API完成订单的统计、搜索、查询等功能,和你一起学习Stream流式操作的各种方法。你可以结合代码中的注释理解案例,也可以自己运行源码观察输出。
|
||||
|
||||
我们先定义一个订单类、一个订单商品类和一个顾客类,用作后续Demo代码的数据结构:
|
||||
|
||||
```
|
||||
//订单类
|
||||
@Data
|
||||
public class Order {
|
||||
private Long id;
|
||||
private Long customerId;//顾客ID
|
||||
private String customerName;//顾客姓名
|
||||
private List<OrderItem> orderItemList;//订单商品明细
|
||||
private Double totalPrice;//总价格
|
||||
private LocalDateTime placedAt;//下单时间
|
||||
}
|
||||
//订单商品类
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class OrderItem {
|
||||
private Long productId;//商品ID
|
||||
private String productName;//商品名称
|
||||
private Double productPrice;//商品价格
|
||||
private Integer productQuantity;//商品数量
|
||||
}
|
||||
//顾客类
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class Customer {
|
||||
private Long id;
|
||||
private String name;//顾客姓名
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这里,我们有一个orders字段保存了一些模拟数据,类型是List<order>。这里,我就不贴出生成模拟数据的代码了。这不会影响你理解后面的代码,你也可以自己下载源码阅读。</order>
|
||||
|
||||
### 创建流
|
||||
|
||||
要使用流,就要先创建流。创建流一般有五种方式:
|
||||
|
||||
- 通过stream方法把List或数组转换为流;
|
||||
- 通过Stream.of方法直接传入多个元素构成一个流;
|
||||
- 通过Stream.iterate方法使用迭代的方式构造一个无限流,然后使用limit限制流元素个数;
|
||||
- 通过Stream.generate方法从外部传入一个提供元素的Supplier来构造无限流,然后使用limit限制流元素个数;
|
||||
- 通过IntStream或DoubleStream构造基本类型的流。
|
||||
|
||||
```
|
||||
//通过stream方法把List或数组转换为流
|
||||
@Test
|
||||
public void stream()
|
||||
{
|
||||
Arrays.asList("a1", "a2", "a3").stream().forEach(System.out::println);
|
||||
Arrays.stream(new int[]{1, 2, 3}).forEach(System.out::println);
|
||||
}
|
||||
|
||||
//通过Stream.of方法直接传入多个元素构成一个流
|
||||
@Test
|
||||
public void of()
|
||||
{
|
||||
String[] arr = {"a", "b", "c"};
|
||||
Stream.of(arr).forEach(System.out::println);
|
||||
Stream.of("a", "b", "c").forEach(System.out::println);
|
||||
Stream.of(1, 2, "a").map(item -> item.getClass().getName()).forEach(System.out::println);
|
||||
}
|
||||
|
||||
//通过Stream.iterate方法使用迭代的方式构造一个无限流,然后使用limit限制流元素个数
|
||||
@Test
|
||||
public void iterate()
|
||||
{
|
||||
Stream.iterate(2, item -> item * 2).limit(10).forEach(System.out::println);
|
||||
Stream.iterate(BigInteger.ZERO, n -> n.add(BigInteger.TEN)).limit(10).forEach(System.out::println);
|
||||
}
|
||||
|
||||
//通过Stream.generate方法从外部传入一个提供元素的Supplier来构造无限流,然后使用limit限制流元素个数
|
||||
@Test
|
||||
public void generate()
|
||||
{
|
||||
Stream.generate(() -> "test").limit(3).forEach(System.out::println);
|
||||
Stream.generate(Math::random).limit(10).forEach(System.out::println);
|
||||
}
|
||||
|
||||
//通过IntStream或DoubleStream构造基本类型的流
|
||||
@Test
|
||||
public void primitive()
|
||||
{
|
||||
//演示IntStream和DoubleStream
|
||||
IntStream.range(1, 3).forEach(System.out::println);
|
||||
IntStream.range(0, 3).mapToObj(i -> "x").forEach(System.out::println);
|
||||
|
||||
IntStream.rangeClosed(1, 3).forEach(System.out::println);
|
||||
DoubleStream.of(1.1, 2.2, 3.3).forEach(System.out::println);
|
||||
|
||||
//各种转换,后面注释代表了输出结果
|
||||
System.out.println(IntStream.of(1, 2).toArray().getClass()); //class [I
|
||||
System.out.println(Stream.of(1, 2).mapToInt(Integer::intValue).toArray().getClass()); //class [I
|
||||
System.out.println(IntStream.of(1, 2).boxed().toArray().getClass()); //class [Ljava.lang.Object;
|
||||
System.out.println(IntStream.of(1, 2).asDoubleStream().toArray().getClass()); //class [D
|
||||
System.out.println(IntStream.of(1, 2).asLongStream().toArray().getClass()); //class [J
|
||||
|
||||
//注意基本类型流和装箱后的流的区别
|
||||
Arrays.asList("a", "b", "c").stream() // Stream<String>
|
||||
.mapToInt(String::length) // IntStream
|
||||
.asLongStream() // LongStream
|
||||
.mapToDouble(x -> x / 10.0) // DoubleStream
|
||||
.boxed() // Stream<Double>
|
||||
.mapToLong(x -> 1L) // LongStream
|
||||
.mapToObj(x -> "") // Stream<String>
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### filter
|
||||
|
||||
filter方法可以实现过滤操作,类似SQL中的where。我们可以使用一行代码,通过filter方法实现查询所有订单中最近半年金额大于40的订单,通过连续叠加filter方法进行多次条件过滤:
|
||||
|
||||
```
|
||||
//最近半年的金额大于40的订单
|
||||
orders.stream()
|
||||
.filter(Objects::nonNull) //过滤null值
|
||||
.filter(order -> order.getPlacedAt().isAfter(LocalDateTime.now().minusMonths(6))) //最近半年的订单
|
||||
.filter(order -> order.getTotalPrice() > 40) //金额大于40的订单
|
||||
.forEach(System.out::println);
|
||||
|
||||
```
|
||||
|
||||
如果不使用Stream的话,必然需要一个中间集合来收集过滤后的结果,而且所有的过滤条件会堆积在一起,代码冗长且不易读。
|
||||
|
||||
### map
|
||||
|
||||
map操作可以做转换(或者说投影),类似SQL中的select。为了对比,我用两种方式统计订单中所有商品的数量,前一种是通过两次遍历实现,后一种是通过两次mapToLong+sum方法实现:
|
||||
|
||||
```
|
||||
//计算所有订单商品数量
|
||||
//通过两次遍历实现
|
||||
LongAdder longAdder = new LongAdder();
|
||||
orders.stream().forEach(order ->
|
||||
order.getOrderItemList().forEach(orderItem -> longAdder.add(orderItem.getProductQuantity())));
|
||||
|
||||
//使用两次mapToLong+sum方法实现
|
||||
assertThat(longAdder.longValue(), is(orders.stream().mapToLong(order ->
|
||||
order.getOrderItemList().stream()
|
||||
.mapToLong(OrderItem::getProductQuantity).sum()).sum()));
|
||||
|
||||
```
|
||||
|
||||
显然,后一种方式无需中间变量longAdder,更直观。
|
||||
|
||||
这里再补充一下,使用for循环生成数据,是我们平时常用的操作,也是这个课程会大量用到的。现在,我们可以用一行代码使用IntStream配合mapToObj替代for循环来生成数据,比如生成10个Product元素构成List:
|
||||
|
||||
```
|
||||
//把IntStream通过转换Stream<Project>
|
||||
System.out.println(IntStream.rangeClosed(1,10)
|
||||
.mapToObj(i->new Product((long)i, "product"+i, i*100.0))
|
||||
.collect(toList()));
|
||||
|
||||
```
|
||||
|
||||
### flatMap
|
||||
|
||||
接下来,我们看看flatMap展开或者叫扁平化操作,相当于map+flat,通过map把每一个元素替换为一个流,然后展开这个流。
|
||||
|
||||
比如,我们要统计所有订单的总价格,可以有两种方式:
|
||||
|
||||
- 直接通过原始商品列表的商品个数*商品单价统计的话,可以先把订单通过flatMap展开成商品清单,也就是把Order替换为Stream<orderitem>,然后对每一个OrderItem用mapToDouble转换获得商品总价,最后进行一次sum求和;</orderitem>
|
||||
- 利用flatMapToDouble方法把列表中每一项展开替换为一个DoubleStream,也就是直接把每一个订单转换为每一个商品的总价,然后求和。
|
||||
|
||||
```
|
||||
//直接展开订单商品进行价格统计
|
||||
System.out.println(orders.stream()
|
||||
.flatMap(order -> order.getOrderItemList().stream())
|
||||
.mapToDouble(item -> item.getProductQuantity() * item.getProductPrice()).sum());
|
||||
|
||||
//另一种方式flatMap+mapToDouble=flatMapToDouble
|
||||
System.out.println(orders.stream()
|
||||
.flatMapToDouble(order ->
|
||||
order.getOrderItemList()
|
||||
.stream().mapToDouble(item -> item.getProductQuantity() * item.getProductPrice()))
|
||||
.sum());
|
||||
|
||||
```
|
||||
|
||||
这两种方式可以得到相同的结果,并无本质区别。
|
||||
|
||||
### sorted
|
||||
|
||||
sorted操作可以用于行内排序的场景,类似SQL中的order by。比如,要实现大于50元订单的按价格倒序取前5,可以通过Order::getTotalPrice方法引用直接指定需要排序的依据字段,通过reversed()实现倒序:
|
||||
|
||||
```
|
||||
//大于50的订单,按照订单价格倒序前5
|
||||
orders.stream().filter(order -> order.getTotalPrice() > 50)
|
||||
.sorted(comparing(Order::getTotalPrice).reversed())
|
||||
.limit(5)
|
||||
.forEach(System.out::println);
|
||||
|
||||
```
|
||||
|
||||
### distinct
|
||||
|
||||
distinct操作的作用是去重,类似SQL中的distinct。比如下面的代码实现:
|
||||
|
||||
- 查询去重后的下单用户。使用map从订单提取出购买用户,然后使用distinct去重。
|
||||
- 查询购买过的商品名。使用flatMap+map提取出订单中所有的商品名,然后使用distinct去重。
|
||||
|
||||
```
|
||||
//去重的下单用户
|
||||
System.out.println(orders.stream().map(order -> order.getCustomerName()).distinct().collect(joining(",")));
|
||||
|
||||
//所有购买过的商品
|
||||
System.out.println(orders.stream()
|
||||
.flatMap(order -> order.getOrderItemList().stream())
|
||||
.map(OrderItem::getProductName)
|
||||
.distinct().collect(joining(",")));
|
||||
|
||||
```
|
||||
|
||||
### skip & limit
|
||||
|
||||
skip和limit操作用于分页,类似MySQL中的limit。其中,skip实现跳过一定的项,limit用于限制项总数。比如下面的两段代码:
|
||||
|
||||
- 按照下单时间排序,查询前2个订单的顾客姓名和下单时间;
|
||||
- 按照下单时间排序,查询第3和第4个订单的顾客姓名和下单时间。
|
||||
|
||||
```
|
||||
//按照下单时间排序,查询前2个订单的顾客姓名和下单时间
|
||||
orders.stream()
|
||||
.sorted(comparing(Order::getPlacedAt))
|
||||
.map(order -> order.getCustomerName() + "@" + order.getPlacedAt())
|
||||
.limit(2).forEach(System.out::println);
|
||||
//按照下单时间排序,查询第3和第4个订单的顾客姓名和下单时间
|
||||
orders.stream()
|
||||
.sorted(comparing(Order::getPlacedAt))
|
||||
.map(order -> order.getCustomerName() + "@" + order.getPlacedAt())
|
||||
.skip(2).limit(2).forEach(System.out::println);
|
||||
|
||||
```
|
||||
|
||||
### collect
|
||||
|
||||
collect是收集操作,对流进行终结(终止)操作,把流导出为我们需要的数据结构。“终结”是指,导出后,无法再串联使用其他中间操作,比如filter、map、flatmap、sorted、distinct、limit、skip。
|
||||
|
||||
在Stream操作中,collect是最复杂的终结操作,比较简单的终结操作还有forEach、toArray、min、max、count、anyMatch等,我就不再展开了,你可以查询[JDK文档](https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html),搜索terminal operation或intermediate operation。
|
||||
|
||||
接下来,我通过6个案例,来演示下几种比较常用的collect操作:
|
||||
|
||||
- 第一个案例,实现了字符串拼接操作,生成一定位数的随机字符串。
|
||||
- 第二个案例,通过Collectors.toSet静态方法收集为Set去重,得到去重后的下单用户,再通过Collectors.joining静态方法实现字符串拼接。
|
||||
- 第三个案例,通过Collectors.toCollection静态方法获得指定类型的集合,比如把List<order>转换为LinkedList<order>。</order></order>
|
||||
- 第四个案例,通过Collectors.toMap静态方法将对象快速转换为Map,Key是订单ID、Value是下单用户名。
|
||||
- 第五个案例,通过Collectors.toMap静态方法将对象转换为Map。Key是下单用户名,Value是下单时间,一个用户可能多次下单,所以直接在这里进行了合并,只获取最近一次的下单时间。
|
||||
- 第六个案例,使用Collectors.summingInt方法对商品数量求和,再使用Collectors.averagingInt方法对结果求平均值,以统计所有订单平均购买的商品数量。
|
||||
|
||||
```
|
||||
//生成一定位数的随机字符串
|
||||
System.out.println(random.ints(48, 122)
|
||||
.filter(i -> (i < 57 || i > 65) && (i < 90 || i > 97))
|
||||
.mapToObj(i -> (char) i)
|
||||
.limit(20)
|
||||
.collect(StringBuilder::new, StringBuilder::append, StringBuilder::append)
|
||||
.toString());
|
||||
|
||||
//所有下单的用户,使用toSet去重后实现字符串拼接
|
||||
System.out.println(orders.stream()
|
||||
.map(order -> order.getCustomerName()).collect(toSet())
|
||||
.stream().collect(joining(",", "[", "]")));
|
||||
|
||||
//用toCollection收集器指定集合类型
|
||||
System.out.println(orders.stream().limit(2).collect(toCollection(LinkedList::new)).getClass());
|
||||
|
||||
//使用toMap获取订单ID+下单用户名的Map
|
||||
orders.stream()
|
||||
.collect(toMap(Order::getId, Order::getCustomerName))
|
||||
.entrySet().forEach(System.out::println);
|
||||
|
||||
//使用toMap获取下单用户名+最近一次下单时间的Map
|
||||
orders.stream()
|
||||
.collect(toMap(Order::getCustomerName, Order::getPlacedAt, (x, y) -> x.isAfter(y) ? x : y))
|
||||
.entrySet().forEach(System.out::println);
|
||||
|
||||
//订单平均购买的商品数量
|
||||
System.out.println(orders.stream().collect(averagingInt(order ->
|
||||
order.getOrderItemList().stream()
|
||||
.collect(summingInt(OrderItem::getProductQuantity)))));
|
||||
|
||||
```
|
||||
|
||||
可以看到,这6个操作使用Stream方式一行代码就可以实现,但使用非Stream方式实现的话,都需要几行甚至十几行代码。
|
||||
|
||||
有关Collectors类的一些常用静态方法,我总结到了一张图中,你可以再整理一下思路:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/de/5af5ba60d7af2c8780b69bc6c71cf3de.png" alt="">
|
||||
|
||||
其中,groupBy和partitionBy比较复杂,我和你举例介绍。
|
||||
|
||||
### groupBy
|
||||
|
||||
groupBy是分组统计操作,类似SQL中的group by子句。它和后面介绍的partitioningBy都是特殊的收集器,同样也是终结操作。分组操作比较复杂,为帮你理解得更透彻,我准备了8个案例:
|
||||
|
||||
- 第一个案例,按照用户名分组,使用Collectors.counting方法统计每个人的下单数量,再按照下单数量倒序输出。
|
||||
- 第二个案例,按照用户名分组,使用Collectors.summingDouble方法统计订单总金额,再按总金额倒序输出。
|
||||
- 第三个案例,按照用户名分组,使用两次Collectors.summingInt方法统计商品采购数量,再按总数量倒序输出。
|
||||
- 第四个案例,统计被采购最多的商品。先通过flatMap把订单转换为商品,然后把商品名作为Key、Collectors.summingInt作为Value分组统计采购数量,再按Value倒序获取第一个Entry,最后查询Key就得到了售出最多的商品。
|
||||
- 第五个案例,同样统计采购最多的商品。相比第四个案例排序Map的方式,这次直接使用Collectors.maxBy收集器获得最大的Entry。
|
||||
- 第六个案例,按照用户名分组,统计用户下的金额最高的订单。Key是用户名,Value是Order,直接通过Collectors.maxBy方法拿到金额最高的订单,然后通过collectingAndThen实现Optional.get的内容提取,最后遍历Key/Value即可。
|
||||
- 第七个案例,根据下单年月分组统计订单ID列表。Key是格式化成年月后的下单时间,Value直接通过Collectors.mapping方法进行了转换,把订单列表转换为订单ID构成的List。
|
||||
- 第八个案例,根据下单年月+用户名两次分组统计订单ID列表,相比上一个案例多了一次分组操作,第二次分组是按照用户名进行分组。
|
||||
|
||||
```
|
||||
//按照用户名分组,统计下单数量
|
||||
System.out.println(orders.stream().collect(groupingBy(Order::getCustomerName, counting()))
|
||||
.entrySet().stream().sorted(Map.Entry.<String, Long>comparingByValue().reversed()).collect(toList()));
|
||||
|
||||
//按照用户名分组,统计订单总金额
|
||||
System.out.println(orders.stream().collect(groupingBy(Order::getCustomerName, summingDouble(Order::getTotalPrice)))
|
||||
.entrySet().stream().sorted(Map.Entry.<String, Double>comparingByValue().reversed()).collect(toList()));
|
||||
|
||||
//按照用户名分组,统计商品采购数量
|
||||
System.out.println(orders.stream().collect(groupingBy(Order::getCustomerName,
|
||||
summingInt(order -> order.getOrderItemList().stream()
|
||||
.collect(summingInt(OrderItem::getProductQuantity)))))
|
||||
.entrySet().stream().sorted(Map.Entry.<String, Integer>comparingByValue().reversed()).collect(toList()));
|
||||
|
||||
//统计最受欢迎的商品,倒序后取第一个
|
||||
orders.stream()
|
||||
.flatMap(order -> order.getOrderItemList().stream())
|
||||
.collect(groupingBy(OrderItem::getProductName, summingInt(OrderItem::getProductQuantity)))
|
||||
.entrySet().stream()
|
||||
.sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
|
||||
.map(Map.Entry::getKey)
|
||||
.findFirst()
|
||||
.ifPresent(System.out::println);
|
||||
|
||||
//统计最受欢迎的商品的另一种方式,直接利用maxBy
|
||||
orders.stream()
|
||||
.flatMap(order -> order.getOrderItemList().stream())
|
||||
.collect(groupingBy(OrderItem::getProductName, summingInt(OrderItem::getProductQuantity)))
|
||||
.entrySet().stream()
|
||||
.collect(maxBy(Map.Entry.comparingByValue()))
|
||||
.map(Map.Entry::getKey)
|
||||
.ifPresent(System.out::println);
|
||||
|
||||
//按照用户名分组,选用户下的总金额最大的订单
|
||||
orders.stream().collect(groupingBy(Order::getCustomerName, collectingAndThen(maxBy(comparingDouble(Order::getTotalPrice)), Optional::get)))
|
||||
.forEach((k, v) -> System.out.println(k + "#" + v.getTotalPrice() + "@" + v.getPlacedAt()));
|
||||
|
||||
//根据下单年月分组,统计订单ID列表
|
||||
System.out.println(orders.stream().collect
|
||||
(groupingBy(order -> order.getPlacedAt().format(DateTimeFormatter.ofPattern("yyyyMM")),
|
||||
mapping(order -> order.getId(), toList()))));
|
||||
|
||||
//根据下单年月+用户名两次分组,统计订单ID列表
|
||||
System.out.println(orders.stream().collect
|
||||
(groupingBy(order -> order.getPlacedAt().format(DateTimeFormatter.ofPattern("yyyyMM")),
|
||||
groupingBy(order -> order.getCustomerName(),
|
||||
mapping(order -> order.getId(), toList())))));
|
||||
|
||||
```
|
||||
|
||||
如果不借助Stream转换为普通的Java代码,实现这些复杂的操作可能需要几十行代码。
|
||||
|
||||
### partitionBy
|
||||
|
||||
partitioningBy用于分区,分区是特殊的分组,只有true和false两组。比如,我们把用户按照是否下单进行分区,给partitioningBy方法传入一个Predicate作为数据分区的区分,输出是Map<Boolean, List<T>>:
|
||||
|
||||
```
|
||||
public static <T>
|
||||
Collector<T, ?, Map<Boolean, List<T>>> partitioningBy(Predicate<? super T> predicate) {
|
||||
return partitioningBy(predicate, toList());
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
测试一下,partitioningBy配合anyMatch,可以把用户分为下过订单和没下过订单两组:
|
||||
|
||||
```
|
||||
//根据是否有下单记录进行分区
|
||||
System.out.println(Customer.getData().stream().collect(
|
||||
partitioningBy(customer -> orders.stream().mapToLong(Order::getCustomerId)
|
||||
.anyMatch(id -> id == customer.getId()))));
|
||||
|
||||
```
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我用了大量的篇幅和案例,和你展开介绍了Stream中很多具体的流式操作方法。有些案例可能不太好理解,我建议你对着代码逐一到源码中查看这些操作的方法定义,以及JDK中的代码注释。
|
||||
|
||||
最后,我建议你思考下,在日常工作中还会使用SQL统计哪些信息,这些SQL是否也可以用Stream来改写呢?Stream的API博大精深,但其中又有规律可循。这其中的规律主要就是,理清楚这些API传参的函数式接口定义,就能搞明白到底是需要我们提供数据、消费数据、还是转换数据等。那,掌握Stream的方法便是,多测试多练习,以强化记忆、加深理解。
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. 使用Stream可以非常方便地对List做各种操作,那有没有什么办法可以实现在整个过程中观察数据变化呢?比如,我们进行filter+map操作,如何观察filter后map的原始数据呢?
|
||||
1. Collectors类提供了很多现成的收集器,那我们有没有办法实现自定义的收集器呢?比如,实现一个MostPopularCollector,来得到List中出现次数最多的元素,满足下面两个测试用例:
|
||||
|
||||
```
|
||||
assertThat(Stream.of(1, 1, 2, 2, 2, 3, 4, 5, 5).collect(new MostPopularCollector<>()).get(), is(2));
|
||||
assertThat(Stream.of('a', 'b', 'c', 'c', 'c', 'd').collect(new MostPopularCollector<>()).get(), is('c'));
|
||||
|
||||
```
|
||||
|
||||
关于Java 8,你还有什么使用心得吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。
|
||||
158
极客时间专栏/Java业务开发常见错误100例/加餐/33 | 加餐3:定位应用问题,排错套路很重要.md
Normal file
158
极客时间专栏/Java业务开发常见错误100例/加餐/33 | 加餐3:定位应用问题,排错套路很重要.md
Normal file
@@ -0,0 +1,158 @@
|
||||
<audio id="audio" title="33 | 加餐3:定位应用问题,排错套路很重要" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a3/89/a386559811a46f95445534120cc39889.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。
|
||||
|
||||
咱们这个课程已经更新13讲了,感谢各位同学一直在坚持学习,并在评论区留下了很多高质量的留言。这些留言,有的是分享自己曾经踩的坑,有的是对课后思考题的详细解答,还有的是提出了非常好的问题,进一步丰富了这个课程的内容。
|
||||
|
||||
有同学说,这个课程的案例非常实用,都是工作中会遇到的。正如我在开篇词中所说,这个课程涉及的100个案例、约130个小坑,有40%来自于我经历过或者是见过的200多个线上生产事故,剩下的60%来自于我开发业务项目,以及日常审核别人的代码发现的问题。确实,我在整理这些案例上花费了很多精力,也特别感谢各位同学的认可,更希望你们能继续坚持学习,继续在评论区和我交流。
|
||||
|
||||
也有同学反馈,排查问题的思路很重要,希望自己遇到问题时,也能够从容、高效地定位到根因。因此,今天这一讲,我就与你说说我在应急排错方面积累的心得。这都是我多年担任技术负责人和架构师自己总结出来的,希望对你有所帮助。当然了,也期待你能留言与我说说,自己平时的排错套路。
|
||||
|
||||
## 在不同环境排查问题,有不同的方式
|
||||
|
||||
要说排查问题的思路,我们首先得明白是在什么环境排错。
|
||||
|
||||
- 如果是在自己的开发环境排查问题,那你几乎可以使用任何自己熟悉的工具来排查,甚至可以进行单步调试。只要问题能重现,排查就不会太困难,最多就是把程序调试到JDK或三方类库内部进行分析。
|
||||
- 如果是在测试环境排查问题,相比开发环境少的是调试,不过你可以使用JDK自带的jvisualvm或阿里的[Arthas](https://github.com/alibaba/arthas),附加到远程的JVM进程排查问题。另外,测试环境允许造数据、造压力模拟我们需要的场景,因此遇到偶发问题时,我们可以尝试去造一些场景让问题更容易出现,方便测试。
|
||||
- 如果是在生产环境排查问题,往往比较难:一方面,生产环境权限管控严格,一般不允许调试工具从远程附加进程;另一方面,生产环境出现问题要求以恢复为先,难以留出充足的时间去慢慢排查问题。但,因为生产环境的流量真实、访问量大、网络权限管控严格、环境复杂,因此更容易出问题,也是出问题最多的环境。
|
||||
|
||||
接下来,我就与你详细说说,如何在生产环境排查问题吧。
|
||||
|
||||
## 生产问题的排查很大程度依赖监控
|
||||
|
||||
其实,排查问题就像在破案,生产环境出现问题时,因为要尽快恢复应用,就不可能保留完整现场用于排查和测试。因此,是否有充足的信息可以了解过去、还原现场就成了破案的关键。这里说的信息,主要就是日志、监控和快照。
|
||||
|
||||
日志就不用多说了,主要注意两点:
|
||||
|
||||
- 确保错误、异常信息可以被完整地记录到文件日志中;
|
||||
- 确保生产上程序的日志级别是INFO以上。记录日志要使用合理的日志优先级,DEBUG用于开发调试、INFO用于重要流程信息、WARN用于需要关注的问题、ERROR用于阻断流程的错误。
|
||||
|
||||
对于监控,在生产环境排查问题时,首先就需要开发和运维团队做好充足的监控,而且是多个层次的监控。
|
||||
|
||||
- 主机层面,对CPU、内存、磁盘、网络等资源做监控。如果应用部署在虚拟机或Kubernetes集群中,那么除了对物理机做基础资源监控外,还要对虚拟机或Pod做同样的监控。监控层数取决于应用的部署方案,有一层OS就要做一层监控。
|
||||
- 网络层面,需要监控专线带宽、交换机基本情况、网络延迟。
|
||||
- 所有的中间件和存储都要做好监控,不仅仅是监控进程对CPU、内存、磁盘IO、网络使用的基本指标,更重要的是监控组件内部的一些重要指标。比如,著名的监控工具Prometheus,就提供了大量的[exporter](https://prometheus.io/docs/instrumenting/exporters/)来对接各种中间件和存储系统。
|
||||
- 应用层面,需要监控JVM进程的类加载、内存、GC、线程等常见指标(比如使用[Micrometer](https://micrometer.io/)来做应用监控),此外还要确保能够收集、保存应用日志、GC日志。
|
||||
|
||||
我们再来看看快照。这里的“快照”是指,应用进程在某一时刻的快照。通常情况下,我们会为生产环境的Java应用设置-XX:+HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath=…这2个JVM参数,用于在出现OOM时保留堆快照。这个课程中,我们也多次使用MAT工具来分析堆快照。
|
||||
|
||||
了解过去、还原现场后,接下来我们就看看定位问题的套路。
|
||||
|
||||
## 分析定位问题的套路
|
||||
|
||||
定位问题,首先要定位问题出在哪个层次上。比如,是Java应用程序自身的问题还是外部因素导致的问题。我们可以先查看程序是否有异常,异常信息一般比较具体,可以马上定位到大概的问题方向;如果是一些资源消耗型的问题可能不会有异常,我们可以通过指标监控配合显性问题点来定位。
|
||||
|
||||
一般情况下,程序的问题来自以下三个方面。
|
||||
|
||||
第一,程序发布后的Bug,回滚后可以立即解决。这类问题的排查,可以回滚后再慢慢分析版本差异。
|
||||
|
||||
第二,外部因素,比如主机、中间件或数据库的问题。这类问题的排查方式,按照主机层面的问题、中间件或存储(统称组件)的问题分为两类。
|
||||
|
||||
主机层面的问题,可以使用工具排查:
|
||||
|
||||
- CPU相关问题,可以使用top、vmstat、pidstat、ps等工具排查;
|
||||
- 内存相关问题,可以使用free、top、ps、vmstat、cachestat、sar等工具排查;
|
||||
- IO相关问题,可以使用lsof、iostat、pidstat、sar、iotop、df、du等工具排查;
|
||||
- 网络相关问题,可以使用ifconfig、ip、nslookup、dig、ping、tcpdump、iptables等工具排查。
|
||||
|
||||
组件的问题,可以从以下几个方面排查:
|
||||
|
||||
- 排查组件所在主机是否有问题;
|
||||
- 排查组件进程基本情况,观察各种监控指标;
|
||||
- 查看组件的日志输出,特别是错误日志;
|
||||
- 进入组件控制台,使用一些命令查看其运作情况。
|
||||
|
||||
第三,因为系统资源不够造成系统假死的问题,通常需要先通过重启和扩容解决问题,之后再进行分析,不过最好能留一个节点作为现场。系统资源不够,一般体现在CPU使用高、内存泄漏或OOM的问题、IO问题、网络相关问题这四个方面。
|
||||
|
||||
对于CPU使用高的问题,如果现场还在,具体的分析流程是:
|
||||
|
||||
- 首先,在Linux服务器上运行top -Hp pid命令,来查看进程中哪个线程CPU使用高;
|
||||
- 然后,输入大写的P将线程按照 CPU 使用率排序,并把明显占用CPU的线程ID转换为16进制;
|
||||
- 最后,在jstack命令输出的线程栈中搜索这个线程ID,定位出问题的线程当时的调用栈。
|
||||
|
||||
如果没有条件直接在服务器上运行top命令的话,我们可以用采样的方式定位问题:间隔固定秒数(比如10秒)运行一次jstack命令,采样几次后,对比采样得出哪些线程始终处于运行状态,分析出问题的线程。
|
||||
|
||||
如果现场没有了,我们可以通过排除法来分析。CPU使用高,一般是由下面的因素引起的:
|
||||
|
||||
- 突发压力。这类问题,我们可以通过应用之前的负载均衡的流量或日志量来确认,诸如Nginx等反向代理都会记录URL,可以依靠代理的Access Log进行细化定位,也可以通过监控观察JVM线程数的情况。压力问题导致CPU使用高的情况下,如果程序的各资源使用没有明显不正常,之后可以通过压测+Profiler(jvisualvm就有这个功能)进一步定位热点方法;如果资源使用不正常,比如产生了几千个线程,就需要考虑调参。
|
||||
- GC。这种情况,我们可以通过JVM监控GC相关指标、GC Log进行确认。如果确认是GC的压力,那么内存使用也很可能会不正常,需要按照内存问题分析流程做进一步分析。
|
||||
- 程序中死循环逻辑或不正常的处理流程。这类问题,我们可以结合应用日志分析。一般情况下,应用执行过程中都会产生一些日志,可以重点关注日志量异常部分。
|
||||
|
||||
对于内存泄露或OOM的问题,最简单的分析方式,就是堆转储后使用MAT分析。堆转储,包含了堆现场全貌和线程栈信息,一般观察支配树图、直方图就可以马上看到占用大量内存的对象,可以快速定位到内存相关问题。这一点我们会在[第5篇加餐](https://time.geekbang.org/column/article/230534)中详细介绍。
|
||||
|
||||
需要注意的是,Java进程对内存的使用不仅仅是堆区,还包括线程使用的内存(线程个数*每一个线程的线程栈)和元数据区。每一个内存区都可能产生OOM,可以结合监控观察线程数、已加载类数量等指标分析。另外,我们需要注意看一下,JVM参数的设置是否有明显不合理的地方,限制了资源使用。
|
||||
|
||||
IO相关的问题,除非是代码问题引起的资源不释放等问题,否则通常都不是由Java进程内部因素引发的。
|
||||
|
||||
网络相关的问题,一般也是由外部因素引起的。对于连通性问题,结合异常信息通常比较容易定位;对于性能或瞬断问题,可以先尝试使用ping等工具简单判断,如果不行再使用tcpdump或Wireshark来分析。
|
||||
|
||||
## 分析和定位问题需要注意的九个点
|
||||
|
||||
有些时候,我们分析和定位问题时,会陷入误区或是找不到方向。遇到这种情况,你可以借鉴下我的九个心得。
|
||||
|
||||
**第一,考虑“鸡”和“蛋”的问题。**比如,发现业务逻辑执行很慢且线程数增多的情况时,我们需要考虑两种可能性:
|
||||
|
||||
- 一是,程序逻辑有问题或外部依赖慢,使得业务逻辑执行慢,在访问量不变的情况下需要更多的线程数来应对。比如,10TPS的并发原先一次请求1s可以执行完成,10个线程可以支撑;现在执行完成需要10s,那就需要100个线程。
|
||||
- 二是,有可能是请求量增大了,使得线程数增多,应用本身的CPU资源不足,再加上上下文切换问题导致处理变慢了。
|
||||
|
||||
出现问题的时候,我们需要结合内部表现和入口流量一起看,确认这里的“慢”到底是根因还是结果。
|
||||
|
||||
**第二,考虑通过分类寻找规律。**在定位问题没有头绪的时候,我们可以尝试总结规律。
|
||||
|
||||
比如,我们有10台应用服务器做负载均衡,出问题时可以通过日志分析是否是均匀分布的,还是问题都出现在1台机器。又比如,应用日志一般会记录线程名称,出问题时我们可以分析日志是否集中在某一类线程上。再比如,如果发现应用开启了大量TCP连接,通过netstat我们可以分析出主要集中连接到哪个服务。
|
||||
|
||||
如果能总结出规律,很可能就找到了突破点。
|
||||
|
||||
**第三,分析问题需要根据调用拓扑来,不能想当然。**比如,我们看到Nginx返回502错误,一般可以认为是下游服务的问题导致网关无法完成请求转发。对于下游服务,不能想当然就认为是我们的Java程序,比如在拓扑上可能Nginx代理的是Kubernetes的Traefik Ingress,链路是Nginx->Traefik->应用,如果一味排查Java程序的健康情况,那么始终不会找到根因。
|
||||
|
||||
又比如,我们虽然使用了Spring Cloud Feign来进行服务调用,出现连接超时也不一定就是服务端的问题,有可能是客户端通过URL来调用服务端,并不是通过Eureka的服务发现实现的客户端负载均衡。换句话说,客户端连接的是Nginx代理而不是直接连接应用,客户端连接服务出现的超时,其实是Nginx代理宕机所致。
|
||||
|
||||
**第四,考虑资源限制类问题。**观察各种曲线指标,如果发现曲线慢慢上升然后稳定在一个水平线上,那么一般就是资源达到了限制或瓶颈。
|
||||
|
||||
比如,在观察网络带宽曲线的时候,如果发现带宽上升到120MB左右不动了,那么很可能就是打满了1GB的网卡或传输带宽。又比如,观察到数据库活跃连接数上升到10个就不动了,那么很可能是连接池打满了。观察监控一旦看到任何这样的曲线,都要引起重视。
|
||||
|
||||
**第五,考虑资源相互影响。**CPU、内存、IO和网络,这四类资源就像人的五脏六腑,是相辅相成的,一个资源出现了明显的瓶颈,很可能会引起其他资源的连锁反应。
|
||||
|
||||
比如,内存泄露后对象无法回收会造成大量Full GC,此时CPU会大量消耗在GC上从而引起CPU使用增加。又比如,我们经常会把数据缓存在内存队列中进行异步IO处理,网络或磁盘出现问题时,就很可能会引起内存的暴涨。因此,出问题的时候,我们要考虑到这一点,以避免误判。
|
||||
|
||||
**第六,排查网络问题要考虑三个方面,到底是客户端问题,还是服务端问题,还是传输问题。**比如,出现数据库访问慢的现象,可能是客户端的原因,连接池不够导致连接获取慢、GC停顿、CPU占满等;也可能是传输环节的问题,包括光纤、防火墙、路由表设置等问题;也可能是真正的服务端问题,需要逐一排查来进行区分。
|
||||
|
||||
服务端慢一般可以看到MySQL出慢日志,传输慢一般可以通过ping来简单定位,排除了这两个可能,并且仅仅是部分客户端出现访问慢的情况,就需要怀疑是客户端本身的问题。对于第三方系统、服务或存储访问出现慢的情况,不能完全假设是服务端的问题。
|
||||
|
||||
**第七,快照类工具和趋势类工具需要结合使用。**比如,jstat、top、各种监控曲线是趋势类工具,可以让我们观察各个指标的变化情况,定位大概的问题点;而jstack和分析堆快照的MAT是快照类工具,用于详细分析某一时刻应用程序某一个点的细节。
|
||||
|
||||
一般情况下,我们会先使用趋势类工具来总结规律,再使用快照类工具来分析问题。如果反过来可能就会误判,因为快照类工具反映的只是一个瞬间程序的情况,不能仅仅通过分析单一快照得出结论,如果缺少趋势类工具的帮助,那至少也要提取多个快照来对比。
|
||||
|
||||
**第八,不要轻易怀疑监控。**我曾看过一个空难事故的分析,飞行员在空中发现仪表显示飞机所有油箱都处于缺油的状态,他第一时间的怀疑是油表出现故障了,始终不愿意相信是真的缺油,结果飞行不久后引擎就断油熄火了。同样地,在应用出现问题时,我们会查看各种监控系统,但有些时候我们宁愿相信自己的经验,也不相信监控图表的显示。这可能会导致我们完全朝着错误的方向来排查问题。
|
||||
|
||||
如果你真的怀疑是监控系统有问题,可以看一下这套监控系统对于不出问题的应用显示是否正常,如果正常那就应该相信监控而不是自己的经验。
|
||||
|
||||
**第九,如果因为监控缺失等原因无法定位到根因的话,相同问题就有再出现的风险**,需要做好三项工作:
|
||||
|
||||
- 做好日志、监控和快照补漏工作,下次遇到问题时可以定位根因;
|
||||
- 针对问题的症状做好实时报警,确保出现问题后可以第一时间发现;
|
||||
- 考虑做一套热备的方案,出现问题后可以第一时间切换到热备系统快速解决问题,同时又可以保留老系统的现场。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我和你总结分享了分析生产环境问题的套路。
|
||||
|
||||
第一,分析问题一定是需要依据的,靠猜是猜不出来的,需要提前做好基础监控的建设。监控的话,需要在基础运维层、应用层、业务层等多个层次进行。定位问题的时候,我们同样需要参照多个监控层的指标表现综合分析。
|
||||
|
||||
第二,定位问题要先对原因进行大致分类,比如是内部问题还是外部问题、CPU相关问题还是内存相关问题、仅仅是A接口的问题还是整个应用的问题,然后再去进一步细化探索,一定是从大到小来思考问题;在追查问题遇到瓶颈的时候,我们可以先退出细节,再从大的方面捋一下涉及的点,再重新来看问题。
|
||||
|
||||
第三,分析问题很多时候靠的是经验,很难找到完整的方法论。遇到重大问题的时候,往往也需要根据直觉来第一时间找到最有可能的点,这里甚至有运气成分。我还和你分享了我的九条经验,建议你在平时解决问题的时候多思考、多总结,提炼出更多自己分析问题的套路和拿手工具。
|
||||
|
||||
最后,值得一提的是,定位到问题原因后,我们要做好记录和复盘。每一次故障和问题都是宝贵的资源,复盘不仅仅是记录问题,更重要的是改进。复盘时,我们需要做到以下四点:
|
||||
|
||||
- 记录完整的时间线、处理措施、上报流程等信息;
|
||||
- 分析问题的根本原因;
|
||||
- 给出短、中、长期改进方案,包括但不限于代码改动、SOP、流程,并记录跟踪每一个方案进行闭环;
|
||||
- 定期组织团队回顾过去的故障。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. 如果你现在打开一个App后发现首页展示了一片空白,那这到底是客户端兼容性的问题,还是服务端的问题呢?如果是服务端的问题,又如何进一步细化定位呢?你有什么分析思路吗?
|
||||
1. 对于分析定位问题,你会做哪些监控或是使用哪些工具呢?
|
||||
|
||||
你有没有遇到过什么花了很长时间才定位到的,或是让你印象深刻的问题或事故呢?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。
|
||||
524
极客时间专栏/Java业务开发常见错误100例/加餐/34 | 加餐4:分析定位Java问题,一定要用好这些工具(一).md
Normal file
524
极客时间专栏/Java业务开发常见错误100例/加餐/34 | 加餐4:分析定位Java问题,一定要用好这些工具(一).md
Normal file
@@ -0,0 +1,524 @@
|
||||
<audio id="audio" title="34 | 加餐4:分析定位Java问题,一定要用好这些工具(一)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ba/35/ba5d4704e07cf01be6a394dc45bbda35.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。今天,我要和你分享的内容是分析定位Java问题常用的一些工具。
|
||||
|
||||
到这里,我们的课程更新17讲了,已经更新过半了。在学习过程中,你会发现我在介绍各种坑的时候,并不是直接给出问题的结论,而是通过工具来亲眼看到问题。
|
||||
|
||||
为什么这么做呢?因为我始终认为,遇到问题尽量不要去猜,一定要眼见为实。只有通过日志、监控或工具真正看到问题,然后再回到代码中进行比对确认,我们才能认为是找到了根本原因。
|
||||
|
||||
你可能一开始会比较畏惧使用复杂的工具去排查问题,又或者是打开了工具感觉无从下手,但是随着实践越来越多,对Java程序和各种框架的运作越来越熟悉,你会发现使用这些工具越来越顺手。
|
||||
|
||||
其实呢,工具只是我们定位问题的手段,要用好工具主要还是得对程序本身的运作有大概的认识,这需要长期的积累。
|
||||
|
||||
因此,我会通过两篇加餐,和你分享4个案例,分别展示使用JDK自带的工具来排查JVM参数配置问题、使用Wireshark来分析网络问题、通过MAT来分析内存问题,以及使用Arthas来分析CPU使用高的问题。这些案例也只是冰山一角,你可以自己再通过些例子进一步学习和探索。
|
||||
|
||||
在今天这篇加餐中,我们就先学习下如何使用JDK自带工具、Wireshark来分析和定位Java程序的问题吧。
|
||||
|
||||
## 使用JDK自带工具查看JVM情况
|
||||
|
||||
JDK自带了很多命令行甚至是图形界面工具,帮助我们查看JVM的一些信息。比如,在我的机器上运行ls命令,可以看到JDK 8提供了非常多的工具或程序:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/22/bd/22456d9186a4f36f83209168b782dbbd.png" alt="">
|
||||
|
||||
接下来,我会与你介绍些常用的监控工具。你也可以先通过下面这张图了解下各种工具的基本作用:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b4/0d/b4e8ab0a76a8665879e0fc13964ebc0d.jpg" alt="">
|
||||
|
||||
为了测试这些工具,我们先来写一段代码:启动10个死循环的线程,每个线程分配一个10MB左右的字符串,然后休眠10秒。可以想象到,这个程序会对GC造成压力。
|
||||
|
||||
```
|
||||
//启动10个线程
|
||||
IntStream.rangeClosed(1, 10).mapToObj(i -> new Thread(() -> {
|
||||
while (true) {
|
||||
//每一个线程都是一个死循环,休眠10秒,打印10M数据
|
||||
String payload = IntStream.rangeClosed(1, 10000000)
|
||||
.mapToObj(__ -> "a")
|
||||
.collect(Collectors.joining("")) + UUID.randomUUID().toString();
|
||||
try {
|
||||
TimeUnit.SECONDS.sleep(10);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
System.out.println(payload.length());
|
||||
}
|
||||
})).forEach(Thread::start);
|
||||
|
||||
|
||||
TimeUnit.HOURS.sleep(1);
|
||||
|
||||
```
|
||||
|
||||
修改pom.xml,配置spring-boot-maven-plugin插件打包的Java程序的main方法类:
|
||||
|
||||
```
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<mainClass>org.geekbang.time.commonmistakes.troubleshootingtools.jdktool.CommonMistakesApplication
|
||||
</mainClass>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
```
|
||||
|
||||
然后使用java -jar启动进程,设置JVM参数,让堆最小最大都是1GB:
|
||||
|
||||
```
|
||||
java -jar common-mistakes-0.0.1-SNAPSHOT.jar -Xms1g -Xmx1g
|
||||
|
||||
```
|
||||
|
||||
完成这些准备工作后,我们就可以使用JDK提供的工具,来观察分析这个测试程序了。
|
||||
|
||||
### jps
|
||||
|
||||
首先,使用jps得到Java进程列表,这会比使用ps来的方便:
|
||||
|
||||
```
|
||||
➜ ~ jps
|
||||
12707
|
||||
22261 Launcher
|
||||
23864 common-mistakes-0.0.1-SNAPSHOT.jar
|
||||
15608 RemoteMavenServer36
|
||||
23243 Main
|
||||
23868 Jps
|
||||
22893 KotlinCompileDaemon
|
||||
|
||||
```
|
||||
|
||||
### jinfo
|
||||
|
||||
然后,可以使用jinfo打印JVM的各种参数:
|
||||
|
||||
```
|
||||
➜ ~ jinfo 23864
|
||||
Java System Properties:
|
||||
#Wed Jan 29 12:49:47 CST 2020
|
||||
...
|
||||
user.name=zhuye
|
||||
path.separator=\:
|
||||
os.version=10.15.2
|
||||
java.runtime.name=Java(TM) SE Runtime Environment
|
||||
file.encoding=UTF-8
|
||||
java.vm.name=Java HotSpot(TM) 64-Bit Server VM
|
||||
...
|
||||
|
||||
|
||||
VM Flags:
|
||||
-XX:CICompilerCount=4 -XX:ConcGCThreads=2 -XX:G1ConcRefinementThreads=8 -XX:G1HeapRegionSize=1048576 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=268435456 -XX:MarkStackSize=4194304 -XX:MaxHeapSize=4294967296 -XX:MaxNewSize=2576351232 -XX:MinHeapDeltaBytes=1048576 -XX:NonNMethodCodeHeapSize=5835340 -XX:NonProfiledCodeHeapSize=122911450 -XX:ProfiledCodeHeapSize=122911450 -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC
|
||||
|
||||
|
||||
VM Arguments:
|
||||
java_command: common-mistakes-0.0.1-SNAPSHOT.jar -Xms1g -Xmx1g
|
||||
java_class_path (initial): common-mistakes-0.0.1-SNAPSHOT.jar
|
||||
Launcher Type: SUN_STANDARD
|
||||
|
||||
```
|
||||
|
||||
查看第15行和19行可以发现,**我们设置JVM参数的方式不对,-Xms1g和-Xmx1g这两个参数被当成了Java程序的启动参数**,整个JVM目前最大内存是4GB左右,而不是1GB。
|
||||
|
||||
因此,当我们怀疑JVM的配置很不正常的时候,要第一时间使用工具来确认参数。除了使用工具确认JVM参数外,你也可以打印VM参数和程序参数:
|
||||
|
||||
```
|
||||
System.out.println("VM options");
|
||||
System.out.println(ManagementFactory.getRuntimeMXBean().getInputArguments().stream().collect(Collectors.joining(System.lineSeparator())));
|
||||
System.out.println("Program arguments");
|
||||
System.out.println(Arrays.stream(args).collect(Collectors.joining(System.lineSeparator())));
|
||||
|
||||
```
|
||||
|
||||
把JVM参数放到-jar之前,重新启动程序,可以看到如下输出,从输出也可以确认这次JVM参数的配置正确了:
|
||||
|
||||
```
|
||||
➜ target git:(master) ✗ java -Xms1g -Xmx1g -jar common-mistakes-0.0.1-SNAPSHOT.jar test
|
||||
VM options
|
||||
-Xms1g
|
||||
-Xmx1g
|
||||
Program arguments
|
||||
test
|
||||
|
||||
```
|
||||
|
||||
### jvisualvm
|
||||
|
||||
然后,启动另一个重量级工具jvisualvm观察一下程序,可以在概述面板再次确认JVM参数设置成功了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4d/e4/4d8a600072b0b1aea3943dee584c72e4.png" alt="">
|
||||
|
||||
继续观察监视面板可以看到,JVM的GC活动基本是10秒发生一次,堆内存在250MB到900MB之间波动,活动线程数是22。我们可以在监视面板看到JVM的基本情况,也可以直接在这里进行手动GC和堆Dump操作:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5b/02/5be531e51f6e49d5511d419c90b29302.png" alt="">
|
||||
|
||||
### jconsole
|
||||
|
||||
如果希望看到各个内存区的GC曲线图,可以使用jconsole观察。jconsole也是一个综合性图形界面监控工具,比jvisualvm更方便的一点是,可以用曲线的形式监控各种数据,包括MBean中的属性值:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6b/12/6b4c08d384eea532842d386638dddb12.png" alt="">
|
||||
|
||||
### jstat
|
||||
|
||||
同样,如果没有条件使用图形界面(毕竟在Linux服务器上,我们主要使用命令行工具),又希望看到GC趋势的话,我们可以使用jstat工具。
|
||||
|
||||
jstat工具允许以固定的监控频次输出JVM的各种监控指标,比如使用-gcutil输出GC和内存占用汇总信息,每隔5秒输出一次,输出100次,可以看到Young GC比较频繁,而Full GC基本10秒一次:
|
||||
|
||||
```
|
||||
➜ ~ jstat -gcutil 23940 5000 100
|
||||
S0 S1 E O M CCS YGC YGCT FGC FGCT CGC CGCT GCT
|
||||
0.00 100.00 0.36 87.63 94.30 81.06 539 14.021 33 3.972 837 0.976 18.968
|
||||
0.00 100.00 0.60 69.51 94.30 81.06 540 14.029 33 3.972 839 0.978 18.979
|
||||
0.00 0.00 0.50 99.81 94.27 81.03 548 14.143 34 4.002 840 0.981 19.126
|
||||
0.00 100.00 0.59 70.47 94.27 81.03 549 14.177 34 4.002 844 0.985 19.164
|
||||
0.00 100.00 0.57 99.85 94.32 81.09 550 14.204 34 4.002 845 0.990 19.196
|
||||
0.00 100.00 0.65 77.69 94.32 81.09 559 14.469 36 4.198 847 0.993 19.659
|
||||
0.00 100.00 0.65 77.69 94.32 81.09 559 14.469 36 4.198 847 0.993 19.659
|
||||
0.00 100.00 0.70 35.54 94.32 81.09 567 14.763 37 4.378 853 1.001 20.142
|
||||
0.00 100.00 0.70 41.22 94.32 81.09 567 14.763 37 4.378 853 1.001 20.142
|
||||
0.00 100.00 1.89 96.76 94.32 81.09 574 14.943 38 4.487 859 1.007 20.438
|
||||
0.00 100.00 1.39 39.20 94.32 81.09 575 14.946 38 4.487 861 1.010 20.442
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
其中,S0表示Survivor0区占用百分比,S1表示Survivor1区占用百分比,E表示Eden区占用百分比,O表示老年代占用百分比,M表示元数据区占用百分比,YGC表示年轻代回收次数,YGCT表示年轻代回收耗时,FGC表示老年代回收次数,FGCT表示老年代回收耗时。
|
||||
|
||||
|
||||
jstat命令的参数众多,包含-class、-compiler、-gc等。Java 8、Linux/Unix平台jstat工具的完整介绍,你可以查看[这里](https://docs.oracle.com/javase/8/docs/technotes/tools/#monitor)。jstat定时输出的特性,可以方便我们持续观察程序的各项指标。
|
||||
|
||||
继续来到线程面板可以看到,大量以Thread开头的线程基本都是有节奏的10秒运行一下,其他时间都在休眠,和我们的代码逻辑匹配:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/85/7a1616295b4ec51c56437d2a92652185.png" alt="">
|
||||
|
||||
点击面板的线程Dump按钮,可以查看线程瞬时的线程栈:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0d/00/0ddcd3348d1c8b0bba16736f9221a900.png" alt="">
|
||||
|
||||
### jstack
|
||||
|
||||
通过命令行工具jstack,也可以实现抓取线程栈的操作:
|
||||
|
||||
```
|
||||
➜ ~ jstack 23940
|
||||
2020-01-29 13:08:15
|
||||
Full thread dump Java HotSpot(TM) 64-Bit Server VM (11.0.3+12-LTS mixed mode):
|
||||
|
||||
...
|
||||
|
||||
"main" #1 prio=5 os_prio=31 cpu=440.66ms elapsed=574.86s tid=0x00007ffdd9800000 nid=0x2803 waiting on condition [0x0000700003849000]
|
||||
java.lang.Thread.State: TIMED_WAITING (sleeping)
|
||||
at java.lang.Thread.sleep(java.base@11.0.3/Native Method)
|
||||
at java.lang.Thread.sleep(java.base@11.0.3/Thread.java:339)
|
||||
at java.util.concurrent.TimeUnit.sleep(java.base@11.0.3/TimeUnit.java:446)
|
||||
at org.geekbang.time.commonmistakes.troubleshootingtools.jdktool.CommonMistakesApplication.main(CommonMistakesApplication.java:41)
|
||||
at jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(java.base@11.0.3/Native Method)
|
||||
at jdk.internal.reflect.NativeMethodAccessorImpl.invoke(java.base@11.0.3/NativeMethodAccessorImpl.java:62)
|
||||
at jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(java.base@11.0.3/DelegatingMethodAccessorImpl.java:43)
|
||||
at java.lang.reflect.Method.invoke(java.base@11.0.3/Method.java:566)
|
||||
at org.springframework.boot.loader.MainMethodRunner.run(MainMethodRunner.java:48)
|
||||
at org.springframework.boot.loader.Launcher.launch(Launcher.java:87)
|
||||
at org.springframework.boot.loader.Launcher.launch(Launcher.java:51)
|
||||
at org.springframework.boot.loader.JarLauncher.main(JarLauncher.java:52)
|
||||
|
||||
"Thread-1" #13 prio=5 os_prio=31 cpu=17851.77ms elapsed=574.41s tid=0x00007ffdda029000 nid=0x9803 waiting on condition [0x000070000539d000]
|
||||
java.lang.Thread.State: TIMED_WAITING (sleeping)
|
||||
at java.lang.Thread.sleep(java.base@11.0.3/Native Method)
|
||||
at java.lang.Thread.sleep(java.base@11.0.3/Thread.java:339)
|
||||
at java.util.concurrent.TimeUnit.sleep(java.base@11.0.3/TimeUnit.java:446)
|
||||
at org.geekbang.time.commonmistakes.troubleshootingtools.jdktool.CommonMistakesApplication.lambda$null$1(CommonMistakesApplication.java:33)
|
||||
at org.geekbang.time.commonmistakes.troubleshootingtools.jdktool.CommonMistakesApplication$$Lambda$41/0x00000008000a8c40.run(Unknown Source)
|
||||
at java.lang.Thread.run(java.base@11.0.3/Thread.java:834)
|
||||
|
||||
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
抓取后可以使用类似[fastthread](https://fastthread.io/)这样的在线分析工具来分析线程栈。
|
||||
|
||||
### jcmd
|
||||
|
||||
最后,我们来看一下Java HotSpot虚拟机的NMT功能。
|
||||
|
||||
通过NMT,我们可以观察细粒度内存使用情况,设置-XX:NativeMemoryTracking=summary/detail可以开启NMT功能,开启后可以使用jcmd工具查看NMT数据。
|
||||
|
||||
我们重新启动一次程序,这次加上JVM参数以detail方式开启NMT:
|
||||
|
||||
```
|
||||
-Xms1g -Xmx1g -XX:ThreadStackSize=256k -XX:NativeMemoryTracking=detail
|
||||
|
||||
```
|
||||
|
||||
在这里,我们还增加了-XX:ThreadStackSize参数,并将其值设置为256k,也就是期望把线程栈设置为256KB。我们通过NMT观察一下设置是否成功。
|
||||
|
||||
启动程序后执行如下jcmd命令,以概要形式输出NMT结果。可以看到,**当前有32个线程,线程栈总共保留了差不多4GB左右的内存**。我们明明配置线程栈最大256KB啊,为什么会出现4GB这么夸张的数字呢,到底哪里出了问题呢?
|
||||
|
||||
```
|
||||
➜ ~ jcmd 24404 VM.native_memory summary
|
||||
24404:
|
||||
|
||||
|
||||
Native Memory Tracking:
|
||||
|
||||
|
||||
Total: reserved=6635310KB, committed=5337110KB
|
||||
- Java Heap (reserved=1048576KB, committed=1048576KB)
|
||||
(mmap: reserved=1048576KB, committed=1048576KB)
|
||||
|
||||
|
||||
- Class (reserved=1066233KB, committed=15097KB)
|
||||
(classes #902)
|
||||
(malloc=9465KB #908)
|
||||
(mmap: reserved=1056768KB, committed=5632KB)
|
||||
|
||||
|
||||
- Thread (reserved=4209797KB, committed=4209797KB)
|
||||
(thread #32)
|
||||
(stack: reserved=4209664KB, committed=4209664KB)
|
||||
(malloc=96KB #165)
|
||||
(arena=37KB #59)
|
||||
|
||||
|
||||
- Code (reserved=249823KB, committed=2759KB)
|
||||
(malloc=223KB #730)
|
||||
(mmap: reserved=249600KB, committed=2536KB)
|
||||
|
||||
|
||||
- GC (reserved=48700KB, committed=48700KB)
|
||||
(malloc=10384KB #135)
|
||||
(mmap: reserved=38316KB, committed=38316KB)
|
||||
|
||||
|
||||
- Compiler (reserved=186KB, committed=186KB)
|
||||
(malloc=56KB #105)
|
||||
(arena=131KB #7)
|
||||
|
||||
|
||||
- Internal (reserved=9693KB, committed=9693KB)
|
||||
(malloc=9661KB #2585)
|
||||
(mmap: reserved=32KB, committed=32KB)
|
||||
|
||||
|
||||
- Symbol (reserved=2021KB, committed=2021KB)
|
||||
(malloc=1182KB #334)
|
||||
(arena=839KB #1)
|
||||
|
||||
|
||||
- Native Memory Tracking (reserved=85KB, committed=85KB)
|
||||
(malloc=5KB #53)
|
||||
(tracking overhead=80KB)
|
||||
|
||||
|
||||
- Arena Chunk (reserved=196KB, committed=196KB)
|
||||
(malloc=196KB)
|
||||
|
||||
```
|
||||
|
||||
重新以VM.native_memory detail参数运行jcmd:
|
||||
|
||||
```
|
||||
jcmd 24404 VM.native_memory detail
|
||||
|
||||
```
|
||||
|
||||
可以看到,**有16个可疑线程,每一个线程保留了262144KB内存,也就是256MB**(通过下图红框可以看到,使用关键字262144KB for Thread Stack from搜索到了16个结果):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f2/6b/f24869cbd1190c508e085c9f3400d06b.png" alt="">
|
||||
|
||||
其实,ThreadStackSize参数的单位是KB,**所以我们如果要设置线程栈256KB,那么应该设置256而不是256k**。重新设置正确的参数后,使用jcmd再次验证下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d7/c9/d7228ec216003d31064698e7e16c81c9.png" alt="">
|
||||
|
||||
除了用于查看NMT外,jcmd还有许多功能。我们可以通过help,看到它的所有功能:
|
||||
|
||||
```
|
||||
jcmd 24781 help
|
||||
|
||||
```
|
||||
|
||||
对于其中每一种功能,我们都可以进一步使用help来查看介绍。比如,使用GC.heap_info命令可以打印Java堆的一些信息:
|
||||
|
||||
```
|
||||
jcmd 24781 help GC.heap_info
|
||||
|
||||
```
|
||||
|
||||
除了jps、jinfo、jcmd、jstack、jstat、jconsole、jvisualvm外,JDK中还有一些工具,你可以通过[官方文档](https://docs.oracle.com/javase/8/docs/technotes/tools/)查看完整介绍。
|
||||
|
||||
## 使用Wireshark分析SQL批量插入慢的问题
|
||||
|
||||
我之前遇到过这样一个案例:有一个数据导入程序需要导入大量的数据,开发同学就想到了使用Spring JdbcTemplate的批量操作功能进行数据批量导入,但是发现性能非常差,和普通的单条SQL执行性能差不多。
|
||||
|
||||
我们重现下这个案例。启动程序后,首先创建一个testuser表,其中只有一列name,然后使用JdbcTemplate的batchUpdate方法,批量插入10000条记录到testuser表:
|
||||
|
||||
```
|
||||
@SpringBootApplication
|
||||
@Slf4j
|
||||
public class BatchInsertAppliation implements CommandLineRunner {
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(BatchInsertApplication.class, args);
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
//初始化表
|
||||
jdbcTemplate.execute("drop table IF EXISTS `testuser`;");
|
||||
jdbcTemplate.execute("create TABLE `testuser` (\n" +
|
||||
" `id` bigint(20) NOT NULL AUTO_INCREMENT,\n" +
|
||||
" `name` varchar(255) NOT NULL,\n" +
|
||||
" PRIMARY KEY (`id`)\n" +
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
|
||||
long begin = System.currentTimeMillis();
|
||||
String sql = "INSERT INTO `testuser` (`name`) VALUES (?)";
|
||||
//使用JDBC批量更新
|
||||
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
|
||||
@Override
|
||||
public void setValues(PreparedStatement preparedStatement, int i) throws SQLException {
|
||||
//第一个参数(索引从1开始),也就是name列赋值
|
||||
preparedStatement.setString(1, "usera" + i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getBatchSize() {
|
||||
//批次大小为10000
|
||||
return 10000;
|
||||
}
|
||||
});
|
||||
log.info("took : {} ms", System.currentTimeMillis() - begin);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
执行程序后可以看到,1万条数据插入耗时26秒:
|
||||
|
||||
```
|
||||
[14:44:19.094] [main] [INFO ] [o.g.t.c.t.network.BatchInsertApplication:52 ] - took : 26144 ms
|
||||
|
||||
```
|
||||
|
||||
其实,对于批量操作,我们希望程序可以把多条insert SQL语句合并成一条,或至少是一次性提交多条语句到数据库,以减少和MySQL交互次数、提高性能。那么,我们的程序是这样运作的吗?
|
||||
|
||||
我在[加餐3](https://time.geekbang.org/column/article/221982)中提到一条原则,“分析问题一定是需要依据的,靠猜是猜不出来的”。现在,我们就使用网络分析工具Wireshark来分析一下这个案例,眼见为实。
|
||||
|
||||
首先,我们可以在[这里](https://www.wireshark.org/download.html)下载Wireshark,启动后选择某个需要捕获的网卡。由于我们连接的是本地的MySQL,因此选择loopback回环网卡:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d7/9b/d7c3cc2d997990d0c4b94f72f1679c9b.png" alt="">
|
||||
|
||||
然后,Wireshark捕捉这个网卡的所有网络流量。我们可以在上方的显示过滤栏输入tcp.port == 6657,来过滤出所有6657端口的TCP请求(因为我们是通过6657端口连接MySQL的)。
|
||||
|
||||
可以看到,程序运行期间和MySQL有大量交互。因为Wireshark直接把TCP数据包解析为了MySQL协议,所以下方窗口可以直接显示MySQL请求的SQL查询语句。**我们看到,testuser表的每次insert操作,插入的都是一行记录**:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bc/a2/bcb987cab3cccf4d8729cfe44f01a2a2.png" alt="">
|
||||
|
||||
如果列表中的Protocol没有显示MySQL的话,你可以手动点击Analyze菜单的Decode As菜单,然后加一条规则,把6657端口设置为MySQL协议:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6a/f2/6ae982e2013cf1c60300332068b58cf2.png" alt="">
|
||||
|
||||
这就说明,我们的程序并不是在做批量插入操作,和普通的单条循环插入没有区别。调试程序进入ClientPreparedStatement类,可以看到执行批量操作的是executeBatchInternal方法。executeBatchInternal方法的源码如下:
|
||||
|
||||
```
|
||||
@Override
|
||||
protected long[] executeBatchInternal() throws SQLException {
|
||||
synchronized (checkClosed().getConnectionMutex()) {
|
||||
if (this.connection.isReadOnly()) {
|
||||
throw new SQLException(Messages.getString("PreparedStatement.25") + Messages.getString("PreparedStatement.26"),
|
||||
MysqlErrorNumbers.SQL_STATE_ILLEGAL_ARGUMENT);
|
||||
}
|
||||
if (this.query.getBatchedArgs() == null || this.query.getBatchedArgs().size() == 0) {
|
||||
return new long[0];
|
||||
}
|
||||
// we timeout the entire batch, not individual statements
|
||||
int batchTimeout = getTimeoutInMillis();
|
||||
setTimeoutInMillis(0);
|
||||
resetCancelledState();
|
||||
try {
|
||||
statementBegins();
|
||||
clearWarnings();
|
||||
if (!this.batchHasPlainStatements && this.rewriteBatchedStatements.getValue()) {
|
||||
if (((PreparedQuery<?>) this.query).getParseInfo().canRewriteAsMultiValueInsertAtSqlLevel()) {
|
||||
return executeBatchedInserts(batchTimeout);
|
||||
}
|
||||
if (!this.batchHasPlainStatements && this.query.getBatchedArgs() != null
|
||||
&& this.query.getBatchedArgs().size() > 3 /* cost of option setting rt-wise */) {
|
||||
return executePreparedBatchAsMultiStatement(batchTimeout);
|
||||
}
|
||||
}
|
||||
return executeBatchSerially(batchTimeout);
|
||||
} finally {
|
||||
this.query.getStatementExecuting().set(false);
|
||||
clearBatch();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
注意第18行,判断了rewriteBatchedStatements参数是否为true,是才会开启批量的优化。优化方式有2种:
|
||||
|
||||
- 如果有条件的话,优先把insert语句优化为一条语句,也就是executeBatchedInserts方法;
|
||||
- 如果不行的话,再尝试把insert语句优化为多条语句一起提交,也就是executePreparedBatchAsMultiStatement方法。
|
||||
|
||||
到这里就明朗了,实现批量提交优化的关键,在于rewriteBatchedStatements参数。我们修改连接字符串,并将其值设置为true:
|
||||
|
||||
```
|
||||
spring.datasource.url=jdbc:mysql://localhost:6657/common_mistakes?characterEncoding=UTF-8&useSSL=false&rewriteBatchedStatements=true
|
||||
|
||||
```
|
||||
|
||||
重新按照之前的步骤打开Wireshark验证,可以看到:
|
||||
|
||||
- 这次insert SQL语句被拼接成了一条语句(如第二个红框所示);
|
||||
- 这个TCP包因为太大被分割成了11个片段传输,#699请求是最后一个片段,其实际内容是insert语句的最后一部分内容(如第一和第三个红框显示)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3b/bc/3b7406c96a90e454a00e3c8ba82ecfbc.png" alt="">
|
||||
|
||||
为了查看整个TCP连接的所有数据包,你可以在请求上点击右键,选择Follow->TCP Stream:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5b/c2/5b18a8c6c227df50ad493f5aa546f9c2.png" alt="">
|
||||
|
||||
打开后可以看到,从MySQL认证开始到insert语句的所有数据包的内容:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e1/5a/e154da637a2b44a65f9257beb842575a.png" alt="">
|
||||
|
||||
查看最开始的握手数据包可以发现,TCP的最大分段大小(MSS)是16344字节,而我们的MySQL超长insert的数据一共138933字节,因此被分成了11段传输,其中最大的一段是16332字节,低于MSS要求的16344字节。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3e/9e/3e66a004fd4b7dba14047751a57e089e.png" alt="">
|
||||
|
||||
最后可以看到插入1万条数据仅耗时253毫秒,性能提升了100倍:
|
||||
|
||||
```
|
||||
[20:19:30.185] [main] [INFO ] [o.g.t.c.t.network.BatchInsertApplication:52 ] - took : 253 ms
|
||||
|
||||
```
|
||||
|
||||
虽然我们一直在使用MySQL,但我们很少会考虑MySQL Connector Java是怎么和MySQL交互的,实际发送给MySQL的SQL语句又是怎样的。有没有感觉到,MySQL协议其实并不遥远,我们完全可以使用Wireshark来观察、分析应用程序与MySQL交互的整个流程。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我就使用JDK自带工具查看JVM情况、使用Wireshark分析SQL批量插入慢的问题,和你展示了一些工具及其用法。
|
||||
|
||||
首先,JDK自带的一些监控和故障诊断工具中,有命令行工具也有图形工具。其中,命令行工具更适合在服务器上使用,图形界面工具用于本地观察数据更直观。为了帮助你用好这些工具,我们带你使用这些工具,分析了程序错误设置JVM参数的两个问题,并且观察了GC工作的情况。
|
||||
|
||||
然后,我们使用Wireshark分析了MySQL批量insert操作慢的问题。我们看到,通过Wireshark分析网络包可以让一切变得如此透明。因此,学好Wireshark,对我们排查C/S网络程序的Bug或性能问题,会有非常大的帮助。
|
||||
|
||||
比如,遇到诸如Connection reset、Broken pipe等网络问题的时候,你可以利用Wireshark来定位问题,观察客户端和服务端之间到底出了什么问题。
|
||||
|
||||
此外,如果你需要开发网络程序的话,Wireshark更是分析协议、确认程序是否正确实现的必备工具。
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. JDK中还有一个jmap工具,我们会使用jmap -dump命令来进行堆转储。那么,这条命令和jmap -dump:live有什么区别呢?你能否设计一个实验,来证明下它们的区别呢?
|
||||
1. 你有没有想过,客户端是如何和MySQL进行认证的呢?你能否对照[MySQL的文档](https://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::Handshake),使用Wireshark观察分析这一过程呢?
|
||||
|
||||
在平时工作中,你还会使用什么工具来分析排查Java应用程序的问题呢?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。
|
||||
283
极客时间专栏/Java业务开发常见错误100例/加餐/35 | 加餐5:分析定位Java问题,一定要用好这些工具(二).md
Normal file
283
极客时间专栏/Java业务开发常见错误100例/加餐/35 | 加餐5:分析定位Java问题,一定要用好这些工具(二).md
Normal file
@@ -0,0 +1,283 @@
|
||||
<audio id="audio" title="35 | 加餐5:分析定位Java问题,一定要用好这些工具(二)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/07/c0/07f593c7a0cf0e1ce8ddab21b56528c0.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。
|
||||
|
||||
在[上一篇加餐](https://time.geekbang.org/column/article/224816)中,我们介绍了使用JDK内置的一些工具、网络抓包工具Wireshark去分析、定位Java程序的问题。很多同学看完这一讲,留言反馈说是“打开了一片新天地,之前没有关注过JVM”“利用JVM工具发现了生产OOM的原因”。
|
||||
|
||||
其实,工具正是帮助我们深入到框架和组件内部,了解其运作方式和原理的重要抓手。所以,我们一定要用好它们。
|
||||
|
||||
今天,我继续和你介绍如何使用JVM堆转储的工具MAT来分析OOM问题,以及如何使用全能的故障诊断工具Arthas来分析、定位高CPU问题。
|
||||
|
||||
## 使用MAT分析OOM问题
|
||||
|
||||
对于排查OOM问题、分析程序堆内存使用情况,最好的方式就是分析堆转储。
|
||||
|
||||
堆转储,包含了堆现场全貌和线程栈信息(Java 6 Update 14开始包含)。我们在上一篇加餐中看到,使用jstat等工具虽然可以观察堆内存使用情况的变化,但是对程序内到底有多少对象、哪些是大对象还一无所知,也就是说只能看到问题但无法定位问题。而堆转储,就好似得到了病人在某个瞬间的全景核磁影像,可以拿着慢慢分析。
|
||||
|
||||
Java的OutOfMemoryError是比较严重的问题,需要分析出根因,所以对生产应用一般都会这样设置JVM参数,方便发生OOM时进行堆转储:
|
||||
|
||||
```
|
||||
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=.
|
||||
|
||||
```
|
||||
|
||||
上一篇加餐中我们提到的jvisualvm工具,同样可以进行一键堆转储后,直接打开这个dump查看。但是,jvisualvm的堆转储分析功能并不是很强大,只能查看类使用内存的直方图,无法有效跟踪内存使用的引用关系,所以我更推荐使用Eclipse的Memory Analyzer(也叫做MAT)做堆转储的分析。你可以点击[这个链接](https://www.eclipse.org/mat/),下载MAT。
|
||||
|
||||
使用MAT分析OOM问题,一般可以按照以下思路进行:
|
||||
|
||||
1. 通过支配树功能或直方图功能查看消耗内存最大的类型,来分析内存泄露的大概原因;
|
||||
1. 查看那些消耗内存最大的类型、详细的对象明细列表,以及它们的引用链,来定位内存泄露的具体点;
|
||||
1. 配合查看对象属性的功能,可以脱离源码看到对象的各种属性的值和依赖关系,帮助我们理清程序逻辑和参数;
|
||||
1. 辅助使用查看线程栈来看OOM问题是否和过多线程有关,甚至可以在线程栈看到OOM最后一刻出现异常的线程。
|
||||
|
||||
比如,我手头有一个OOM后得到的转储文件java_pid29569.hprof,现在要使用MAT的直方图、支配树、线程栈、OQL等功能来分析此次OOM的原因。
|
||||
|
||||
首先,用MAT打开后先进入的是概览信息界面,可以看到整个堆是437.6MB:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/63/61/63ecdaf5ff7ac431f0d05661855b2e61.png" alt="">
|
||||
|
||||
那么,这437.6MB都是什么对象呢?
|
||||
|
||||
如图所示,工具栏的第二个按钮可以打开直方图,直方图按照类型进行分组,列出了每个类有多少个实例,以及占用的内存。可以看到,char[]字节数组占用内存最多,对象数量也很多,结合第二位的String类型对象数量也很多,大概可以猜出(String使用char[]作为实际数据存储)程序可能是被字符串占满了内存,导致OOM。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0b/b9/0b3ca076b31a2d571a47c64d622b0db9.png" alt="">
|
||||
|
||||
我们继续分析下,到底是不是这样呢。
|
||||
|
||||
在char[]上点击右键,选择List objects->with incoming references,就可以列出所有的char[]实例,以及每个char[]的整个引用关系链:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f1/a3/f162fb9c6505dc9a8f1ea9900437ada3.png" alt="">
|
||||
|
||||
随机展开一个char[],如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/dd/ac/dd4cb44ad54edee3a51f56a646c5f2ac.png" alt="">
|
||||
|
||||
接下来,我们按照红色框中的引用链来查看,尝试找到这些大char[]的来源:
|
||||
|
||||
- 在①处看到,这些char[]几乎都是10000个字符、占用20000字节左右(char是UTF-16,每一个字符占用2字节);
|
||||
- 在②处看到,char[]被String的value字段引用,说明char[]来自字符串;
|
||||
- 在③处看到,String被ArrayList的elementData字段引用,说明这些字符串加入了一个ArrayList中;
|
||||
- 在④处看到,ArrayList又被FooService的data字段引用,这个ArrayList整个RetainedHeap列的值是431MB。
|
||||
|
||||
Retained Heap(深堆)代表对象本身和对象关联的对象占用的内存,Shallow Heap(浅堆)代表对象本身占用的内存。比如,我们的FooService中的data这个ArrayList对象本身只有16字节,但是其所有关联的对象占用了431MB内存。这些就可以说明,肯定有哪里在不断向这个List中添加String数据,导致了OOM。
|
||||
|
||||
左侧的蓝色框可以查看每一个实例的内部属性,图中显示FooService有一个data属性,类型是ArrayList。
|
||||
|
||||
如果我们希望看到字符串完整内容的话,可以右键选择Copy->Value,把值复制到剪贴板或保存到文件中:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cc/8f/cc1d53eb9570582da415c1aec5cc228f.png" alt="">
|
||||
|
||||
这里,我们复制出的是10000个字符a(下图红色部分可以看到)。对于真实案例,查看大字符串、大数据的实际内容对于识别数据来源,有很大意义:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7b/a0/7b3198574113fecdd2a7de8cde8994a0.png" alt="">
|
||||
|
||||
看到这些,我们已经基本可以还原出真实的代码是怎样的了。
|
||||
|
||||
其实,我们之前使用直方图定位FooService,已经走了些弯路。你可以点击工具栏中第三个按钮(下图左上角的红框所示)进入支配树界面(有关支配树的具体概念参考[这里](https://help.eclipse.org/2020-03/index.jsp?topic=%2Forg.eclipse.mat.ui.help%2Fconcepts%2Fdominatortree.html&resultof%3D%2522%2564%256f%256d%2569%256e%2561%2574%256f%2572%2522%2520%2522%2564%256f%256d%2569%256e%2522%2520%2522%2574%2572%2565%2565%2522%2520))。这个界面会按照对象保留的Retained Heap倒序直接列出占用内存最大的对象。
|
||||
|
||||
可以看到,第一位就是FooService,整个路径是FooSerice->ArrayList->Object[]->String->char[](蓝色框部分),一共有21523个字符串(绿色方框部分):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/57/7adafa4178a4c72f8621b7eb49ee2757.png" alt="">
|
||||
|
||||
这样,我们就从内存角度定位到FooService是根源了。那么,OOM的时候,FooService是在执行什么逻辑呢?
|
||||
|
||||
为解决这个问题,我们可以点击工具栏的第五个按钮(下图红色框所示)。打开线程视图,首先看到的就是一个名为main的线程(Name列),展开后果然发现了FooService:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3a/ce/3a2c3d159e1599d906cc428d812cccce.png" alt="">
|
||||
|
||||
先执行的方法先入栈,所以线程栈最上面是线程当前执行的方法,逐一往下看能看到整个调用路径。因为我们希望了解FooService.oom()方法,看看是谁在调用它,它的内部又调用了谁,所以选择以FooService.oom()方法(蓝色框)为起点来分析这个调用栈。
|
||||
|
||||
往下看整个绿色框部分,oom()方法被OOMApplication的run方法调用,而这个run方法又被SpringAppliction.callRunner方法调用。看到参数中的CommandLineRunner你应该能想到,OOMApplication其实是实现了CommandLineRunner接口,所以是SpringBoot应用程序启动后执行的。
|
||||
|
||||
以FooService为起点往上看,从紫色框中的Collectors和IntPipeline,你大概也可以猜出,这些字符串是由Stream操作产生的。再往上看,可以发现在StringBuilder的append操作的时候,出现了OutOfMemoryError异常(黑色框部分),说明这这个线程抛出了OOM异常。
|
||||
|
||||
我们看到,整个程序是Spring Boot应用程序,那么FooService是不是Spring的Bean呢,又是不是单例呢?如果能分析出这点的话,就更能确认是因为反复调用同一个FooService的oom方法,然后导致其内部的ArrayList不断增加数据的。
|
||||
|
||||
点击工具栏的第四个按钮(如下图红框所示),来到OQL界面。在这个界面,我们可以使用类似SQL的语法,在dump中搜索数据(你可以直接在MAT帮助菜单搜索OQL Syntax,来查看OQL的详细语法)。
|
||||
|
||||
比如,输入如下语句搜索FooService的实例:
|
||||
|
||||
```
|
||||
SELECT * FROM org.geekbang.time.commonmistakes.troubleshootingtools.oom.FooService
|
||||
|
||||
```
|
||||
|
||||
可以看到只有一个实例,然后我们通过List objects功能搜索引用FooService的对象:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/19/43/1973846815bd9d78f85bef05b499e843.png" alt="">
|
||||
|
||||
得到以下结果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/07/a8/07e1216a6cc93bd146535b5809649ea8.png" alt="">
|
||||
|
||||
可以看到,一共两处引用:
|
||||
|
||||
- 第一处是,OOMApplication使用了FooService,这个我们已经知道了。
|
||||
- 第二处是一个ConcurrentHashMap。可以看到,这个HashMap是DefaultListableBeanFactory的singletonObjects字段,可以证实FooService是Spring容器管理的单例的Bean。
|
||||
|
||||
你甚至可以在这个HashMap上点击右键,选择Java Collections->Hash Entries功能,来查看其内容:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ce/5f/ce4020b8f63db060a94fd039314b2d5f.png" alt="">
|
||||
|
||||
这样就列出了所有的Bean,可以在Value上的Regex进一步过滤。输入FooService后可以看到,类型为FooService的Bean只有一个,其名字是fooService:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/02/1a/023141fb717704cde9a57c5be6118d1a.png" alt="">
|
||||
|
||||
到现在为止,我们虽然没看程序代码,但是已经大概知道程序出现OOM的原因和大概的调用栈了。我们再贴出程序来对比一下,果然和我们看到得一模一样:
|
||||
|
||||
```
|
||||
@SpringBootApplication
|
||||
public class OOMApplication implements CommandLineRunner {
|
||||
@Autowired
|
||||
FooService fooService;
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(OOMApplication.class, args);
|
||||
}
|
||||
@Override
|
||||
public void run(String... args) throws Exception {
|
||||
//程序启动后,不断调用Fooservice.oom()方法
|
||||
while (true) {
|
||||
fooService.oom();
|
||||
}
|
||||
}
|
||||
}
|
||||
@Component
|
||||
public class FooService {
|
||||
List<String> data = new ArrayList<>();
|
||||
public void oom() {
|
||||
//往同一个ArrayList中不断加入大小为10KB的字符串
|
||||
data.add(IntStream.rangeClosed(1, 10_000)
|
||||
.mapToObj(__ -> "a")
|
||||
.collect(Collectors.joining("")));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
到这里,我们使用MAT工具从对象清单、大对象、线程栈等视角,分析了一个OOM程序的堆转储。可以发现,有了堆转储,几乎相当于拿到了应用程序的源码+当时那一刻的快照,OOM的问题无从遁形。
|
||||
|
||||
## 使用Arthas分析高CPU问题
|
||||
|
||||
[Arthas](https://alibaba.github.io/arthas/)是阿里开源的Java诊断工具,相比JDK内置的诊断工具,要更人性化,并且功能强大,可以实现许多问题的一键定位,而且可以一键反编译类查看源码,甚至是直接进行生产代码热修复,实现在一个工具内快速定位和修复问题的一站式服务。今天,我就带你使用Arthas定位一个CPU使用高的问题,系统学习下这个工具的使用。
|
||||
|
||||
首先,下载并启动Arthas:
|
||||
|
||||
```
|
||||
curl -O https://alibaba.github.io/arthas/arthas-boot.jar
|
||||
java -jar arthas-boot.jar
|
||||
|
||||
```
|
||||
|
||||
启动后,直接找到我们要排查的JVM进程,然后可以看到Arthas附加进程成功:
|
||||
|
||||
```
|
||||
[INFO] arthas-boot version: 3.1.7
|
||||
[INFO] Found existing java process, please choose one and hit RETURN.
|
||||
* [1]: 12707
|
||||
[2]: 30724 org.jetbrains.jps.cmdline.Launcher
|
||||
[3]: 30725 org.geekbang.time.commonmistakes.troubleshootingtools.highcpu.HighCPUApplication
|
||||
[4]: 24312 sun.tools.jconsole.JConsole
|
||||
[5]: 26328 org.jetbrains.jps.cmdline.Launcher
|
||||
[6]: 24106 org.netbeans.lib.profiler.server.ProfilerServer
|
||||
3
|
||||
[INFO] arthas home: /Users/zhuye/.arthas/lib/3.1.7/arthas
|
||||
[INFO] Try to attach process 30725
|
||||
[INFO] Attach process 30725 success.
|
||||
[INFO] arthas-client connect 127.0.0.1 3658
|
||||
,---. ,------. ,--------.,--. ,--. ,---. ,---.
|
||||
/ O \ | .--. ''--. .--'| '--' | / O \ ' .-'
|
||||
| .-. || '--'.' | | | .--. || .-. |`. `-.
|
||||
| | | || |\ \ | | | | | || | | |.-' |
|
||||
`--' `--'`--' '--' `--' `--' `--'`--' `--'`-----'
|
||||
|
||||
wiki https://alibaba.github.io/arthas
|
||||
tutorials https://alibaba.github.io/arthas/arthas-tutorials
|
||||
version 3.1.7
|
||||
pid 30725
|
||||
time 2020-01-30 15:48:33
|
||||
|
||||
```
|
||||
|
||||
输出help命令,可以看到所有支持的命令列表。今天,我们会用到dashboard、thread、jad、watch、ognl命令,来定位这个HighCPUApplication进程。你可以通过[官方文档](https://alibaba.github.io/arthas/commands.html),查看这些命令的完整介绍:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/47/73/47b2abc1c3a8c0670a60c6ed74761873.png" alt="">
|
||||
|
||||
dashboard命令用于整体展示进程所有线程、内存、GC等情况,其输出如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ce/4c/ce59c22389ba95104531e46edd9afa4c.png" alt="">
|
||||
|
||||
可以看到,CPU高并不是GC引起的,占用CPU较多的线程有8个,其中7个是ForkJoinPool.commonPool。学习过[加餐1](https://time.geekbang.org/column/article/212374)的话,你应该就知道了,ForkJoinPool.commonPool是并行流默认使用的线程池。所以,此次CPU高的问题,应该出现在某段并行流的代码上。
|
||||
|
||||
接下来,要查看最繁忙的线程在执行的线程栈,可以使用thread -n命令。这里,我们查看下最忙的8个线程:
|
||||
|
||||
```
|
||||
thread -n 8
|
||||
|
||||
```
|
||||
|
||||
输出如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/96/00/96cca0708e211ea7f7de413d40c72c00.png" alt="">
|
||||
|
||||
可以看到,由于这些线程都在处理MD5的操作,所以占用了大量CPU资源。我们希望分析出代码中哪些逻辑可能会执行这个操作,所以需要从方法栈上找出我们自己写的类,并重点关注。
|
||||
|
||||
由于主线程也参与了ForkJoinPool的任务处理,因此我们可以通过主线程的栈看到需要重点关注org.geekbang.time.commonmistakes.troubleshootingtools.highcpu.HighCPUApplication类的doTask方法。
|
||||
|
||||
接下来,使用jad命令直接对HighCPUApplication类反编译:
|
||||
|
||||
```
|
||||
jad org.geekbang.time.commonmistakes.troubleshootingtools.highcpu.HighCPUApplication
|
||||
|
||||
```
|
||||
|
||||
可以看到,调用路径是main->task()->doTask(),当doTask方法接收到的int参数等于某个常量的时候,会进行1万次的MD5操作,这就是耗费CPU的来源。那么,这个魔法值到底是多少呢?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/45/e5/4594c58363316d8ff69178d7a341d5e5.png" alt="">
|
||||
|
||||
你可能想到了,通过jad命令继续查看User类即可。这里因为是Demo,所以我没有给出很复杂的逻辑。在业务逻辑很复杂的代码中,判断逻辑不可能这么直白,我们可能还需要分析出doTask的“慢”会慢在什么入参上。
|
||||
|
||||
这时,我们可以使用watch命令来观察方法入参。如下命令,表示需要监控耗时超过100毫秒的doTask方法的入参,并且输出入参,展开2层入参参数:
|
||||
|
||||
```
|
||||
watch org.geekbang.time.commonmistakes.troubleshootingtools.highcpu.HighCPUApplication doTask '{params}' '#cost>100' -x 2
|
||||
|
||||
```
|
||||
|
||||
可以看到,所有耗时较久的doTask方法的入参都是0,意味着User.ADMN_ID常量应该是0。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/04/3a/04e7a4e54c09052ab937f184ab31e03a.png" alt="">
|
||||
|
||||
最后,我们使用ognl命令来运行一个表达式,直接查询User类的ADMIN_ID静态字段来验证是不是这样,得到的结果果然是0:
|
||||
|
||||
```
|
||||
[arthas@31126]$ ognl '@org.geekbang.time.commonmistakes.troubleshootingtools.highcpu.User@ADMIN_ID'
|
||||
@Integer[0]
|
||||
|
||||
```
|
||||
|
||||
需要额外说明的是,由于monitor、trace、watch等命令是通过字节码增强技术来实现的,会在指定类的方法中插入一些切面来实现数据统计和观测,因此诊断结束要执行shutdown来还原类或方法字节码,然后退出Arthas。
|
||||
|
||||
在这个案例中,我们通过Arthas工具排查了高CPU的问题:
|
||||
|
||||
- 首先,通过dashboard + thread命令,基本可以在几秒钟内一键定位问题,找出消耗CPU最多的线程和方法栈;
|
||||
- 然后,直接jad反编译相关代码,来确认根因;
|
||||
- 此外,如果调用入参不明确的话,可以使用watch观察方法入参,并根据方法执行时间来过滤慢请求的入参。
|
||||
|
||||
可见,使用Arthas来定位生产问题根本用不着原始代码,也用不着通过增加日志来帮助我们分析入参,一个工具即可完成定位问题、分析问题的全套流程。
|
||||
|
||||
对于应用故障分析,除了阿里Arthas之外,还可以关注去哪儿的[Bistoury工具](https://github.com/qunarcorp/bistoury),其提供了可视化界面,并且可以针对多台机器进行管理,甚至提供了在线断点调试等功能,模拟IDE的调试体验。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
最后,我再和你分享一个案例吧。
|
||||
|
||||
有一次开发同学遇到一个OOM问题,通过查监控、查日志、查调用链路排查了数小时也无法定位问题,但我拿到堆转储文件后,直接打开支配树图一眼就看到了可疑点。Mybatis每次查询都查询出了几百万条数据,通过查看线程栈马上可以定位到出现Bug的方法名,然后来到代码果然发现因为参数条件为null导致了全表查询,整个定位过程不足5分钟。
|
||||
|
||||
从这个案例我们看到,使用正确的工具、正确的方法来分析问题,几乎可以在几分钟内定位到问题根因。今天,我和你介绍的MAT正是分析Java堆内存问题的利器,而Arthas是快速定位分析Java程序生产Bug的利器。利用好这两个工具,就可以帮助我们在分钟级定位生产故障。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. 在介绍[线程池](https://time.geekbang.org/column/article/210337)的时候,我们模拟了两种可能的OOM情况,一种是使用Executors.newFixedThreadPool,一种是使用Executors.newCachedThreadPool,你能回忆起OOM的原因吗?假设并不知道OOM的原因,拿到了这两种OOM后的堆转储,你能否尝试使用MAT分析堆转储来定位问题呢?
|
||||
1. Arthas还有一个强大的热修复功能。比如,遇到高CPU问题时,我们定位出是管理员用户会执行很多次MD5,消耗大量CPU资源。这时,我们可以直接在服务器上进行热修复,步骤是:jad命令反编译代码->使用文本编辑器(比如Vim)直接修改代码->使用sc命令查找代码所在类的ClassLoader->使用redefine命令热更新代码。你可以尝试使用这个流程,直接修复程序(注释doTask方法中的相关代码)吗?
|
||||
|
||||
在平时工作中,你还会使用什么工具来分析排查Java应用程序的问题呢?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。
|
||||
146
极客时间专栏/Java业务开发常见错误100例/加餐/36 | 加餐6:这15年来,我是如何在工作中学习技术和英语的?.md
Normal file
146
极客时间专栏/Java业务开发常见错误100例/加餐/36 | 加餐6:这15年来,我是如何在工作中学习技术和英语的?.md
Normal file
@@ -0,0 +1,146 @@
|
||||
<audio id="audio" title="36 | 加餐6:这15年来,我是如何在工作中学习技术和英语的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/70/0c/7045230e84b0712f43b28fbbe2c3b30c.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。今天,我来和你聊聊如何在工作中,让自己成长得更快。
|
||||
|
||||
工作这些年来,经常会有同学来找我沟通学习和成长,他们的问题可以归结为两个。
|
||||
|
||||
一是,长期参与CRUD业务开发项目,技术提升出现瓶颈,学不到新知识,完全没有办法实践各种新技术,以后会不会被淘汰、找不到工作?
|
||||
|
||||
二是,英语学得比较晚,大学的时候也只是为了应试,英语水平很低,看不了英文的技术资料,更别说去外企找工作了。
|
||||
|
||||
不知道你是不是也面临这两个问题呢?今天,我就通过自己的经历和你分享一下,如何利用有限的环境、有限的时间来学习技术和英语?
|
||||
|
||||
## 学好技术
|
||||
|
||||
在我看来,知识网络的搭建就是在造楼房:基础也就是地基的承载力,决定了你能把楼造多高;广度就像是把房子造大、造宽;深度就是楼房的高度。因此,如果你想要提升自己的水平,那这三个方面的发展缺一不可。
|
||||
|
||||
### 第一,学习必须靠自觉。
|
||||
|
||||
虽说工作经历和项目经验是实践技术、提升技术的一个重要手段,但不可能所有的工作经历和项目都能持续地提升我们的技术。所以,我们要想提升自己的技术水平,就必须打消仅仅通过工作经历来提升的念头,要靠业余时间主动地持续学习和积累来提升。
|
||||
|
||||
比如,你可以针对项目中用到的技术,全面阅读官方文档,做各种Demo来论证其技术特性。在这个过程中,你一定还会产生许多技术疑问,那就继续展开学习。
|
||||
|
||||
### 第二,不要吝啬分享。
|
||||
|
||||
刚毕业那会,我花了很多时间和精力在CSDN回答问题,积极写博客、写书和翻译书。这些经历对我的技术成长,帮助非常大。
|
||||
|
||||
很多知识点我们自认为完全掌握了,但其实并不是那么回事儿。当我们要说出来教别人的时候,就必须100%了解每一个细节。因此,分享不仅是帮助自己进一步理清每一个知识点、锻炼自己的表达能力,更是一种强迫自己学习的手段,因为你要保证按时交付。
|
||||
|
||||
当然了,分享的过程也需要些正向激励,让自己保持分享的激情。就比如说,我获得的几次微软MVP、CSDN TOP3专家等荣誉,就对我激励很大,可以让我保持热情去不断地学习并帮助别人。
|
||||
|
||||
### 第三,不要停留在舒适区。
|
||||
|
||||
分享一段我的真实经历吧。我加入一家公司组建新团队后,在做技术选型的时候,考虑到成本等因素,放弃了从事了七年的.NET技术,转型Java。有了.NET的积累,我自己转型Java只用了两周。其实,一开始做这个决定非常痛苦,但是突破自己的舒适区并没有想象得那么困难。随后,我又自学了iOS、深度学习、Python等技术或语言。
|
||||
|
||||
随着掌握的技术越来越多,这些技术不但让我触类旁通,更让我理解了技术只是工具,解决问题需要使用合适的技术。因此,我也建议你,利用业余时间多学习几门不同类型的编程语言,比如Java、Python和Go。
|
||||
|
||||
有些时候,我们因为恐惧跳出舒适区而不愿意学习和引入合适的新技术来解决问题,虽然省去了前期的学习转型成本,但是后期却会投入更多的时间来弥补技术上的短板。
|
||||
|
||||
### 第四,打好基础很重要。
|
||||
|
||||
这里的“基础”是指,和编程语言无关的那部分知识,包括硬件基础、操作系统原理、TCP/IP、HTTP、数据结构和算法、安全基础、设计模式、数据库原理等。学习基础知识是比较枯燥的过程,需要大块的时间来系统阅读相关书籍,并要尝试把学到的知识付诸实践。只有实践过的技术才能映入脑子里,否则只是书本上的知识。
|
||||
|
||||
比如,学习TCP/IP的时候,我们可以使用Wireshark来观察网络数据。又比如,学习设计模式的时候,我们可以结合身边的业务案例来思考下,是否有对应的适用场景,如果没有能否模拟一个场景,然后使用所有设计模式和自己熟悉的语言开发一个实际的Demo。
|
||||
|
||||
这些看似和我们日常业务开发关系不大的基础知识,是我们是否能够深入理解技术的最重要的基石。
|
||||
|
||||
### 第五,想办法积累技术深度。
|
||||
|
||||
对开发者而言,技术深度体现在从一个框架、组件或SDK的使用者转变为开发者。
|
||||
|
||||
虽然不建议大家重复去造轮子、造框架,但我们完全可以阅读各种框架的源码去了解其实现,并亲手实现一些框架的原型。比如,你可以尝试把MVC、RPC、ORM、IoC、AOP等框架,都实现一个最基本功能点原型。在实现的过程中,你一定会遇到很多问题和难点,然后再继续研究一下Spring、Hibernate、Dubbo等框架是如何实现的。
|
||||
|
||||
当把自己的小框架实现出来的那一刻,你收获的不仅是满满的成就感,更是在技术深度积累上的更进一步。在这个过程中,你肯定会遇到不少问题、解决不少问题。有了这些积累,之后再去学习甚至二次开发那些流行的开源框架,就会更容易了。
|
||||
|
||||
除了实现一些框架外,我还建议你选择一个中间件(比如Redis、RocketMQ)来练手学习网络知识。
|
||||
|
||||
我们可以先实现它的客户端,用Netty实现TCP通信层的功能,之后参照官方文档实现协议封装、客户端连接池等功能。在实现的过程中,你可以对自己实现的客户端进行压测,分析和官方实现的性能差距。这样一来,你不仅可以对TCP/IP网络有更深入的了解,还可以获得很多网络方面的优化经验。
|
||||
|
||||
然后,再尝试实现服务端,进一步加深对网络的认识。最后,尝试把服务端扩展为支持高可用的集群,来加深对分布式通信技术的理解。
|
||||
|
||||
在实现这样一个分布式C/S中间件的过程中,你对技术的理解肯定会深入了许多。在这个过程中,你会发现,技术深度的“下探”和基础知识的积累息息相关。基础知识不扎实,往深了走往往会步履维艰。这时,你可以再回过头来,重新系统学习一些基础理论。
|
||||
|
||||
### 第六,扩大技术广度也重要。
|
||||
|
||||
除了之前提到的多学几门编程语言之外,在技术广度的拓展上,我们还可以在两个方面下功夫。
|
||||
|
||||
第一,阅读大量的技术书籍。新出来的各种技术图书(不只是编程相关的),一般我都会买。十几年来,我买了500多本技术图书,大概有三分之一是完整看过的,还有三分之一只翻了一个大概,还有三分之一只看了目录。
|
||||
|
||||
广泛的阅读,让我能够了解目前各种技术的主流框架和平台。这样的好处是,在整体看技术方案的时候,我可以知道大家都在做什么,不至于只能理解方案中的一部分。对于看不完的、又比较有价值的书,我会做好标签,等空闲的时候再看。
|
||||
|
||||
第二,在开发程序的时候,我们会直接使用运维搭建的数据库(比如Elasticsearch、MySQL)、中间件(比如RabbitMQ、ZooKeeper)、容器云(比如Kubernetes)。但,如果我们只会使用这些组件而不会搭建的话,对它们的理解很可能只是停留在API或客户端层面。
|
||||
|
||||
因此,我建议你去尝试下从头搭建和配置这些组件,在遇到性能问题的时候自己着手分析一下。把实现技术的前后打通,遇到问题时我们就不至于手足无措了。我通常会购买公有云按小时收费的服务器,来构建一些服务器集群,尝试搭建和测试这些系统,加深对运维的理解。
|
||||
|
||||
## 学好英语
|
||||
|
||||
为啥要单独说英语的学习方法呢,这是因为学好英语对做技术的同学非常重要:
|
||||
|
||||
- 国外的社区环境比较好,许多技术问题只有通过英文关键字才能在Google或Stackoverflow上搜到答案;
|
||||
- 可以第一时间学习各种新技术、阅读第一手资料,中文翻译资料往往至少有半年左右的延迟;
|
||||
- 参与或研究各种开源项目,和老外沟通需要使用英语来提问,以及阅读别人的答复。
|
||||
|
||||
所以说,学好英语可以整体拓宽个人视野。不过,对于上班族来说,我们可能没有太多的大块时间投入英语学习,那如何利用碎片时间、相对休闲地学习英语呢?还有一个问题是,学好英语需要大量的练习和训练,但不在外企工作就连个英语环境都没有,那如何解决这样的矛盾呢?
|
||||
|
||||
接下来,我将从读、听、写和说四个方面,和你分享一下我学习英语的方法。
|
||||
|
||||
### 读方面
|
||||
|
||||
读对于我们这些搞技术的人来说是最重要的,并且也是最容易掌握的。我建议你这么学:
|
||||
|
||||
- 先从阅读身边的技术文档开始,有英语文档的一定要选择阅读英语文档。一来,贴近实际工作,是我们真正用得到的知识,比较容易有兴趣去读;二来,这些文档中大部分词汇,我们日常基本都接触过,难度不会太大。
|
||||
- 技术书籍的常用词汇量不大,有了一些基础后,你可以正式或非正式地参与翻译一些英语书籍或文档。从我的经验来看,翻译过一本书之后,你在日常阅读任何技术资料时基本都不需要查字典了。
|
||||
- 订阅一些英语报纸,比如ChinaDaily。第一,贴近日常生活,都是我们身边发生的事儿,不会很枯燥;第二,可以进一步积累词汇量。在这个过程中,你肯定需要大量查字典打断阅读,让你感觉很痛苦。但一般来说,一个单词最多查三次也就记住了,所以随着时间推移,你慢慢可以摆脱字典,词汇量也可以上一个台阶了。
|
||||
|
||||
技术方面阅读能力的培养,通常只需要三个月左右的时间,但生活方面资料的阅读可能需要一年甚至更长的时间。
|
||||
|
||||
### 听方面
|
||||
|
||||
读需要积累词汇量,听力的训练需要通过时间来磨耳朵。每个人都可以选择适合自己的材料来磨耳朵,比如我是通过看美剧来训练听力的。
|
||||
|
||||
我就以看美剧为例,说说练听力的几个关键点。
|
||||
|
||||
- 量变到质变的过程,需要1000小时的量。如果一部美剧是100小时,那么看前9部的时候可能都很痛苦,直到某一天你突然觉得一下子都可以听懂了。
|
||||
- 需要确保看美剧时没有中文字幕,否则很难忍住不看,看了字幕就无法起到训练听力的效果。
|
||||
- 在美剧的选择上,可以先选择对话比较少,也可以选择自己感兴趣的题材,这样不容易放弃。如果第一次听下来,听懂率低于30%,连理解剧情都困难,那么可以先带着中文字幕看一遍,然后再脱离字幕看。
|
||||
- 看美剧不在乎看的多少,而是要找适合的素材反复训练。有人说,反复看100遍《老友记》,英语的听说能力可以接近母语是英语的人的水平。
|
||||
|
||||
如果看美剧不适合你的话,你可以选择其他方式,比如开车或坐地铁的时候听一些感兴趣的PodCast等。
|
||||
|
||||
总而言之,选择自己喜欢的材料和内容,从简单开始,不断听。如果你有一定词汇量的话,查字典其实不是必须的,很多时候不借助字典,同一个单词出现10遍后我们也就知道它的意思了。
|
||||
|
||||
一定要记住,在积累1000小时之前,别轻易放弃。
|
||||
|
||||
### 写方面
|
||||
|
||||
如果有外企经历,那么平时写英语邮件和文档基本就可以让你的工作英语过关;如果没有外企经历也没关系,你可以尝试通过下面的方式锻炼写作:
|
||||
|
||||
- 每天写英语日记。日记是自己看的,没人会嘲笑你,可以从简单的开始。
|
||||
- 在保持写作的同时,需要确保自己能有持续的一定量的阅读。因为,写作要实现从正确到准确到优雅,离不开阅读的积累。
|
||||
- 写程序的时候使用英语注释,或者尝试写英语博客,总之利用好一切写的机会,来提升自己的英语表达。
|
||||
|
||||
再和你分享一个小技巧。当你要通过查词典知道中文的英语翻译时,尽量不要直接用找到的英文单词,最好先在英语例句中确认这个翻译的准确性再使用,以免闹笑话。
|
||||
|
||||
### 说方面
|
||||
|
||||
训练说英语的机会是最少的,毕竟身边说英语的人少,很难自己主动练习。
|
||||
|
||||
这里我和你分享两个方法吧。
|
||||
|
||||
第一是,买外教的1-1对话课程来训练。这些课程一般按小时计费,由母语是英语的人在线和你聊一些话题,帮助你训练对话能力。
|
||||
|
||||
买不买课程不重要,只要能有母语是英语的人来帮你提升就可以。同时,大量的听力训练也可以帮助你提升说的能力,很多英语短句经过反复强化会成为脱口而出的下意识反应。所以,你会发现在听力达到质变的时候,说的能力也会上一个台阶。
|
||||
|
||||
第二,大胆说,不要担心有语法错误、单词发音问题、表达不流畅问题而被嘲笑。其实,你可以反过来想想,老外说中文时出现这些问题,你会嘲笑他吗。
|
||||
|
||||
这里有一个技巧是,尽量选用简单的表达和词汇去说,先尝试把内容说出来,甚至是只说几个关键字,而不是憋着在脑子里尝试整理一个完整的句子。灵活运用有限的单词,尽可能地流畅、准确表达,才是聪明的做法。
|
||||
|
||||
## 总结
|
||||
|
||||
最后我想说,如果你感觉学得很累、进步很慢,也不要放弃,坚持下来就会越来越好。我刚毕业那会儿,有一阵子也对OOP很迷茫,感觉根本无法理解OOP的理念,写出的代码完全是过程化的代码。但我没有放弃,参与写了几年的复杂业务程序,再加上系统自学设计模式,到某一个时刻我突然就能写出OOP的业务代码了。
|
||||
|
||||
学习一定是一个日积月累、量变到质变的过程,希望我分享的学习方法能对你有启发。不过,每个人的情况都不同,一定要找到适合自己的学习方式,才更容易坚持下去。
|
||||
|
||||
持续学习很重要,不一定要短时间突击学习,而最好是慢慢学、持续积累,积累越多学习就会越轻松。如果学习遇到瓶颈感觉怎么都学不会,也不要沮丧,这其实还是因为积累不够。你一定也有过这样的经验:一本去年觉得很难啃的书,到今年再看会觉得恰到好处,明年就会觉得比较简单,就是这个道理。
|
||||
|
||||
我是朱晔,欢迎在评论区与我留言分享你学习技术和英语的心得,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。
|
||||
236
极客时间专栏/Java业务开发常见错误100例/加餐/37 | 加餐7:程序员成长28计.md
Normal file
236
极客时间专栏/Java业务开发常见错误100例/加餐/37 | 加餐7:程序员成长28计.md
Normal file
@@ -0,0 +1,236 @@
|
||||
|
||||
你好,我是朱晔。
|
||||
|
||||
今天直播,我准备和你聊聊程序员成长的话题。我从毕业后入行到现在作为一个高级管理者,已经在互联网领域拼搏了15年了。我把这些年自己的成长历程、心得体会整理成了“程序员成长28计”。
|
||||
|
||||
今天,我就和你分别聊聊这28计。
|
||||
|
||||
## 入门 0.5年
|
||||
|
||||
**第1计**:不要过于纠结方向选择问题。
|
||||
|
||||
开始入门的时候,我们可能都会纠结于选择前端还是后端,选择了后端还犹豫到底选Java、Go还是Python。
|
||||
|
||||
其实,我觉得不用过于纠结。如果说你对偏前端的内容感兴趣,那就从前端入手;对数据库方面的内容感兴趣,那就从后端入手。等你真正入门以后,你再去转方向、转技术栈都会非常容易,因为技术都是相通的。
|
||||
|
||||
**第2计:**学习一定要敢于踏出真正的第一步。
|
||||
|
||||
这里我说的第一步,不是说开始看某个领域的书了,而是真正把IDE下载好、把编程环境搭建好,并实现一个最简单的程序。我一直觉得,把编程环境搭建好,就已经算是入门一半了。
|
||||
|
||||
如果你只是停留在看书这个层次上的话,是永远入不了门的。因为这些知识只是停留在书上,还没有真正变成你自己的。只有自己写过、实践过,才能真正掌握。
|
||||
|
||||
**第3计:**找人给你指一下方向。
|
||||
|
||||
刚入门的时候,面对各种各样的语言、技术你很可能会迷茫。就比如说,刚入门后端的时候,Spring全家桶有十几样,还有各种数据库方面的,Java程序本身的语法和框架,那到底先学什么呢?这个时候,只要找人给你指点一下学习的顺序,以及按照怎样的主线来学习,就会事半功倍。否则,你会在大量的资料里花费大量的时间、消耗大量的精力。
|
||||
|
||||
**第4计**:找准合适的入门资料。
|
||||
|
||||
在我看来,选择入门资料,需要注意两点:
|
||||
|
||||
1. 一定要选择手把手的资料,也就是从搭环境开始怎么一步步地去操作,并带一些实战项目。这样看,视频课程可能更合适。
|
||||
1. 要难度合适。
|
||||
|
||||
那怎么理解“难度合适”呢?举个例子,你看的这本书的知识深度在70分,而你自己的知识深度在60分,那这本书非常合适。因为从60到65的感觉是非常爽的。在60分的时候,你有能力去汲取70分深度的书里面的知识点,然后你会变成65分。而如果你现在的知识深度在20分去看70分的书,或者你的知识深度在75分却去看70分的书,就不会有任何感觉、任何收益。所以,很多同学看我的专栏课程会有共鸣,也是这个原因。
|
||||
|
||||
## 程序员2年
|
||||
|
||||
**第5计**:想办法系统性地学习。
|
||||
|
||||
步入两年这个阶段后,我们要开始想办法系统性地学习了,比如系统性地学习设计模式、算法、数据库。只有系统性地学习,才能给我们建立起完整的知识框架。因为一定是先有知识网络,才能在网络上继续铺更多的东西。
|
||||
|
||||
那怎么才能有系统性学习的动力呢?
|
||||
|
||||
第一,分享可以让自己有动力。比如,你说要写一个什么系列的文章,那话说出去了,就会逼着自己去实现。
|
||||
|
||||
第二,花钱买课程,做系统性的学习。当你花了几百甚至几千块钱去买课程的时候,就会逼着自己的学习,不然钱就浪费掉了。
|
||||
|
||||
**第6计:**选择一份好工作。
|
||||
|
||||
选择一份好工作,也就是选择一个好的项目,从而积累一些人脉资源,是非常重要的,可能要比技术成长更重要些。
|
||||
|
||||
比如说,你能够进入到一个相对较大的公司,它能带给你的最最主要的就是人脉资源,也就是你能够认识更多、更优秀的人。认识这些人,就是你日后的机会。
|
||||
|
||||
**第7计:**学习必须靠自觉。
|
||||
|
||||
我们不能期望项目经验一定或者说一直会给自己带来技术提升。即使是你能接触一些高并发的、比较复杂的项目,它们带来的提升也是有限的,或者说持续的时间通常会比较短。
|
||||
|
||||
因为大多数公司在乎的都是你的输出,输出你的能力和经验。所以说,学习和成长这件事儿,必须靠自觉,包括自觉地去想如何系统性地学习、如何有计划地学习,以及平时要多问为什么。
|
||||
|
||||
**第8计:**看适合自己的书。
|
||||
|
||||
这里也是说,我们在看书的过程中,要注意去鉴别书的层次,选择难度合适的书。
|
||||
|
||||
其实,在做程序员前两年的时间里,我不太建议去广泛地看书,要先想办法能够专注些,打好自己主要的编程语言的基础;然后,围绕着自己主要的编程语言或者主要使用的技术去看书。
|
||||
|
||||
**第9计:**想办法积累技术广度。
|
||||
|
||||
将来踏上技术管理路线之后,你有可能管的团队不是你这个领域,比如你是后端出身可能要带领移动团队。如果你不知道移动端最基本的东西的话,是没有办法跟团队成员沟通的。所以说,你可以有自己的一个专长,但是你要知道其他领域最基本的东西。
|
||||
|
||||
积累技术广度的方式,主要有下面三种。
|
||||
|
||||
第一,体验全栈。如果你是做后端的,就应该去大概了解下客户端、移动端,或者说大前端;可以了解下测试和运维怎么做,了解运维的话帮助可能会更大。你还可以动手做一个自己的项目,就从云服务器的采购开始。在搭建项目部署的过程中,你可以自己去搭建运维相关的部分,甚至是自己搭建一些中间件。
|
||||
|
||||
因为在大厂,一般都有自动化发布系统、有工程化平台、有自己的运维体系、有自己的监控系统等等。但是,如果只是使用这些工具的话,我们是没法建立一个全局观的,因为我们不知道它们是怎么运作的。
|
||||
|
||||
第二,多学一些编程语言。但是学了几门编程语言后,你会发现每门语言都有自己的特色和软肋。这就会引发你很多的思考,比如为什么这个语言没有这个特性,又怎么样去解决。另外,每门语言他都有自己的技术栈,你会来回地比较。这些思考和比较,对自己的成长都很有用。
|
||||
|
||||
如果你对一个语言的掌握比较透彻的话,再去学其他语言不会花很久。我刚毕业是做.net,后来转了Java,再后来又去学Python。因为高级语言的特性基本上都差不多,你只要学一些语法,用到的时候再去查更多的内容,然后做个项目,所以学一门语言可能也就需要一个月甚至会更快一些。
|
||||
|
||||
第三,广泛看书。
|
||||
|
||||
**第10计:**想办法积累技术深度。
|
||||
|
||||
主要的方式是造轮子、看源码和学底层。
|
||||
|
||||
第一,造轮子。所谓的造轮子,不一定是要造完要用,你可以拿造轮子来练手,比如徒手写一个框架。在这个过程中,你会遇到很多困难,然后可能会想办法去学习一些现有技术的源码,这对技术深度的理解是非常有帮助的。
|
||||
|
||||
第二,看一些源码。如果你能够理清楚一些源码的主线,然后你能积累很多设计模式的知识。
|
||||
|
||||
第三,学一些偏向于底层的东西,可以帮助你理解技术的本质。上层的技术都依赖于底层的技术,所以你学完了底层的技术后,就会发现上层的技术再变也没有什么本质上的区别,然后学起来就会非常快。
|
||||
|
||||
**第11计:**学会使用搜索引擎。
|
||||
|
||||
对于程序员来说,最好可以使用Google来搜索,也就是说要使用英文的关键字来搜索。一方面,通过Google你可以搜到更多的内容,另一方面国外的技术圈或者网站关于纯技术的讨论会多一些。
|
||||
|
||||
**第12计:**学会和适应画图、写文档。
|
||||
|
||||
我觉得,写文档是在锻炼自己的总结能力和表达能力,画图更多的是在锻炼自己的抽象能力。写文档、画架构图,不仅仅是架构师需要具备的能力,还是你准确表达自己观点的必备方式。所以,我们不要觉得,宁肯写100行代码,也不愿意写一句话。
|
||||
|
||||
## 架构师3年
|
||||
|
||||
**第13计:**注意软素质的提升。
|
||||
|
||||
这时候你已经有了好几年的经验了,那除了技术方面,还要注意软素质,比如沟通、自我驱动、总结等能力的提升。比如说沟通能力,就是你能不能很流畅地表达自己的观点,能不能比较主动地去沟通。
|
||||
|
||||
这些素质在日常工作中还是挺重要的,因为你做了架构师之后,免不了要去跟业务方和技术团队,甚至是其他的团队的架构师去沟通。 如果你的这些软素质不过硬,那可能你的方案就得不到认可,没办法达成自己的目标。
|
||||
|
||||
**第14计:**积累领域经验也很重要。
|
||||
|
||||
当你在一个领域工作几年之后,你就会对这个领域的产品非常熟悉,甚至比产品经理更懂产品。也就是说,即使这个产品没有别人的帮助,你也可以确保它朝着正确的方向发展。如果你想一直在这个领域工作的话,这种领域经验的积累就对自己的发展非常有帮助。
|
||||
|
||||
所以说,有些人做的是业务架构师,他可能在技术上并不是特别擅长,但对这个领域的系统设计或者说产品设计特别在行。如果说,你不想纯做技术的话,可以考虑积累更多的领域经验。
|
||||
|
||||
**第15计:**架构工作要接地气。
|
||||
|
||||
我以前做架构师的时候发现,有些架构师给出的方案非常漂亮,但就是不接地气、很难去落地。所以,在我看来,架构工作必须要接地气,包括三个方面:产出符合实际情况的方案、方案要落地实际项目、不要太技术化。
|
||||
|
||||
这里其实会有一个矛盾点:如果你想要提升自己的经验、技术,很多时候就需要去引入一些新技术,但是这些新技术的引入需要成本。而这里的成本不仅仅是你自己学习的成本,还需要整个团队有一定的经验。
|
||||
|
||||
比如Kubernetes,不是你引入了团队用就完事儿,整个团队的技术都需要得到提升,才能够驾驭这个系统。如果我们是为了自己的利益去引入一些不太符合公司实际情况的技术的话,其实对公司来说是不负责任的,而且这个方案很大程度上有可能会失败。
|
||||
|
||||
所以说,我觉得做架构工作是要产出一些更接地气的方案。比如同样是解决一个问题,有些架构方式或设计比较“老土”,但往往是很稳定的;而一些复杂的技术,虽然有先进的理念和设计,但你要驾驭它就需要很多成本,而且因为它的“新”往往还会存在各种各样的问题。
|
||||
|
||||
这也就是说,我们在设计架构的时候,必须要权衡方案是否接地气。
|
||||
|
||||
**第16计:**打造个人品牌。
|
||||
|
||||
我觉得,个人品牌包括口碑和影响力两个方面。
|
||||
|
||||
口碑就是你日常工作的态度,包括你的能力和沟通,会让人知道你靠不靠谱、能力是不是够强。好的口碑再加上宝贵的人脉,就是你非常重要的资源。口碑好的人基本上是不需要主动去找工作的,因为一直会有一些老领导或者朋友、同事会千方百计地想要给你机会。
|
||||
|
||||
很多人的技术非常不错,但就是没人知道他,问题就出在影响力上。而提升影响力的方法,无外乎就是参加技术大会、做分享、写博客、写书等等。
|
||||
|
||||
有了影响力和口碑,让更多的人能接触到你、认识你,你就会有更多的机会。
|
||||
|
||||
## 技术管理
|
||||
|
||||
**第17计:**掌握管事的方法。
|
||||
|
||||
“管事”就是你怎样去安排,这里包括了制定项目管理流程、制定技术标准、工具化和自动化三个方面。
|
||||
|
||||
刚转做技术管理时容易犯的一个错的是,把事情都抓在自己手里。这时,你一定要想通,不是你自己在干活,你的产出是靠团队的。与其说什么事情都自己干,还不如说你去制定规范、流程和方向,然后让团队去做,否则你很容易就成了整个团队的瓶颈。
|
||||
|
||||
**第18计:**掌握带团队的方法。
|
||||
|
||||
第一,招人&放权。带团队的话,最重要是招到优秀的人,然后就是放权。不要因为担心招到的人会比自己优秀,就想要找“弱”一些的。只有团队的事情做得更好了,你的整个团队的产出才是最高。
|
||||
|
||||
第二,工程师文化。通过建立工程师文化,让大家去互相交流、学习,从而建立一个良好的学习工作氛围。
|
||||
|
||||
第三,适当的沟通汇报制度。这也属于制定流程里面的,也是要建立一个沟通汇报的制度。
|
||||
|
||||
**第19计:**关注前沿技术,思考技术创新。
|
||||
|
||||
做了技术管理之后,你的视角要更高。你团队的成员,可能只是看到、接触到这一个部分、这一个模块,没有更多的信息,也没办法想得更远。这时,你就必须去创新、去关注更多的前沿技术,去思考自己的项目能不能用上这些技术。
|
||||
|
||||
**第20计:**关注产品。
|
||||
|
||||
在我看来,一个产品的形态很多时候决定了公司的命运,在产品上多想一些点子,往往要比技术上的重构带来的收益更大。这里不仅仅包括这个产品是怎么运作的,还包括产品中包含的创新、你能否挖掘一些衍生品。
|
||||
|
||||
## 高级技术管理
|
||||
|
||||
在这个层次上面,我们更高级的技术管理可能是总监级别甚至以上,我以前在两家百人以上的小公司做过CTO。我当时的感觉是,所做的事情不能仅限于产品技术本身了。
|
||||
|
||||
**第21计:**搭建团队最重要。
|
||||
|
||||
这和招人还不太一样,招人肯定招的是下属,而搭建团队是必须让团队有一个梯队。一旦你把一些核心的人固化下来以后,整个团队就发展起来了。所以,你要在招人方面花费更多的精力,当然不仅仅是指面试。
|
||||
|
||||
搭建团队最重要的是你自己要有一个想法,知道自己需要一个什么样的职位来填补空缺,这个岗位上又需要什么样的人。
|
||||
|
||||
**第22计:**打造技术文化。
|
||||
|
||||
虽然在做技术管理的时候,我强调说要建立制度,但文化会更高于制度,而且文化没有那么强势。因为制度其实是列出来,要求大家去遵守,有“强迫”的感觉;而文化更强调潜移默化,通过耳濡目染获得大同感。这样一来,大家慢慢地就不会觉得这是文化了,而是说我现在就是这么干事儿的。
|
||||
|
||||
**第23计:**提炼价值观。
|
||||
|
||||
价值观是说公司按照这个理念去运作,希望有一些志同道合的人在一起干活。所以价值观又会高于文化,是整个公司层面的,对大家的影响也会更多。
|
||||
|
||||
虽然说价值观不会那么显性,但可以长久地确保公司里面的整个团队的心都是齐的,大家都知道公司是怎么运作的,有相同的目标。
|
||||
|
||||
**第24计:**关注运营和财务。
|
||||
|
||||
到了高级技术管理的位置,你就不仅仅是一个打工的了,你的命运是和公司紧紧绑定在一起的。所以,你需要更多地关注公司的运营和财务。
|
||||
|
||||
当你觉得自己的团队很小却要做那么多项目的时候,可以站在更高的角度去换位思考下。这时你可能就发现,你的团队做的事情并没有那么重要,对整个公司的发展来说你的团队规模已经足够了。如果说我们再大量招人的话,那么财务上就会入不敷出,整个公司的情况肯定也不会好。
|
||||
|
||||
## 职场心得
|
||||
|
||||
**第25计:**掌握工作汇报的方式方法。
|
||||
|
||||
首先,我们不要把汇报当作负担、当作浪费时间。汇报其实是双向的,你跟上级多沟通的话,他可以反馈给你更多的信息,这个信息可能是你工作的方向,也可能是给你的一些资源,还可能是告诉你上级想要什么。因为你和你的上级其实在一个信息层面上是不对等的,他能收到更上级的信息,比如公司策略方面的信息。
|
||||
|
||||
**第26计:**坚持+信念。
|
||||
|
||||
第一,如果说你的目标就是成功的话,那没有什么可以阻挡你。职场上的扯皮和甩锅,都是避免不了的。举个例子吧。
|
||||
|
||||
我以前在一家公司工作的时候,别人不愿意配合我的工作。那怎么办呢,我知道自己的目标是把这件事儿做成。当时,这个项目的很多内容,比如说运维,都不在我这边,需要其他同事来负责。但人家就是不配合,群里艾特也不看,打电话也不接,那我怎么办呢?多打两次呗,实在不行我就发邮件抄送大家的上级。总之,就是想尽办法去沟通,因为你的目标就是成功。
|
||||
|
||||
第二,很多时候,创新就是相信一定可以实现才有的。
|
||||
|
||||
很多时候,你觉得这个事情是做不成的,然后直接拒绝掉了,创新就没有了。但如果相信这个事情一定是可以做成的,你就会想方设法去实现它,这个时候你想出来的东西就是有开创性的,就是创新。
|
||||
|
||||
**第27计:**持续的思考和总结。
|
||||
|
||||
在职场上提炼方法论是非常重要的。你要去思考自己在工作中对各种各样的事情的处理,是不是妥当,是不是能够总结出一些方法论。把这些方法论提炼保留下来,将来是能够帮到你的。很多东西,比如复盘自己的工作经历、复盘自己的选择,都要动脑子、都要去写,不能说过去了就过去了。这些经历提炼出的方法论,都是你的经验,是非常有价值的。
|
||||
|
||||
**第28计:**有关和平级同事的相处。
|
||||
|
||||
和平级同事之间,要以帮助别人的心态来合作。我们和上下级的同事来沟通,一般是不会有什么问题的,但跟平级的,尤其是跨部门的平级同事去沟通的时候,往往会因为利益问题,不会很愉快。
|
||||
|
||||
我觉得,这里最重要的就是以帮助别人的心态来合作。 比如这样说“你有什么困难的话,可以来问我”“你人手是不是不够,我可以帮你一起把这个项目做好”。这样大家的合作会比较顺畅,别人也不会有那么多戒心。
|
||||
|
||||
人和人的沟通,还在于有一层纱,突破了这层纱以后,大家就都会相信你,觉得你是一个靠谱的人。这样,平级同事也会愿意和你分享一些东西,因为他放心。
|
||||
|
||||
## 管理格言
|
||||
|
||||
接下来,我要推荐的8条管理格言,是曹操管理和用人的理念,不是我自己总结出来的。
|
||||
|
||||
第一,真心诚意,以情感人。人和人之间去沟通的时候,不管是和上级或者下级的沟通,都要以非常诚恳的态度去沟通。
|
||||
|
||||
第二,推心置腹,以诚待人。有事情不要藏在心里,做“城府很深”的管理者。我觉得更好的方式是,让大家尽可能地知道更多的事儿,统一战线,站在一个角度来考虑问题。
|
||||
|
||||
第三,开诚布公,以理服人。把管理策略公布出来,不管是奖励也好惩罚也罢,让团队成员感觉公平公正,
|
||||
|
||||
第四,言行一致,以信取人。说到做到,对于管理下属、和别人沟通都非常重要。
|
||||
|
||||
第五,令行禁止,依法治人。管理上,你要制定好相关的制度,而且要公开出来。如果触犯了制度就需要惩罚,做得好了就要有奖赏。
|
||||
|
||||
第六,设身处地,以宽容人。很多时候,我们和别人的矛盾是没有足够的换位思考,没有设身处地地去想。如果说你的下属犯了错,还是要想一想是不是多给些机会,是不是能宽容一些。
|
||||
|
||||
第七,扬人责己,以功归人。这是非常重要的一点。事情是团队一起做的话,那就是团队的功劳,甚至下属的功劳。如果别人做得好的话,就要多表扬一些。对自己要严格一些,很多时候团队的问题就是管理者的问题,跟下属没太多关系。
|
||||
|
||||
第八,论功行赏,以奖励人。做得好了,要多给别人一些奖励。这也是公平公正的,大家都能看得到。
|
||||
|
||||
最后,我将关于程序员成长的28计整理在了一张思维导图上,以方便你收藏、转发。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/b7/bf/b72104acfeeeecef49ab6c0a5908cebf.jpg" alt="">
|
||||
|
||||
我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。
|
||||
272
极客时间专栏/Java业务开发常见错误100例/加餐/答疑篇:加餐篇思考题答案合集.md
Normal file
272
极客时间专栏/Java业务开发常见错误100例/加餐/答疑篇:加餐篇思考题答案合集.md
Normal file
@@ -0,0 +1,272 @@
|
||||
<audio id="audio" title="答疑篇:加餐篇思考题答案合集" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8d/db/8d3847da3b36e5c590f376694eda7bdb.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。
|
||||
|
||||
今天,我们继续一起分析这门课的“不定期加餐”篇中5讲的课后思考题。这些题目涉及了Java 8基础知识、定位和分析应用问题相关的几大知识点。
|
||||
|
||||
接下来,我们就一一具体分析吧。
|
||||
|
||||
### [加餐1 | 带你吃透课程中Java 8的那些重要知识点(一)](https://time.geekbang.org/column/article/212374)
|
||||
|
||||
**问题:**对于并行流部分的并行消费处理1到100的例子,如果把forEach替换为forEachOrdered,你觉得会发生什么呢?
|
||||
|
||||
答:forEachOrdered 会让parallelStream丧失部分的并行能力,主要原因是forEach遍历的逻辑无法并行起来(需要按照循序遍历,无法并行)。
|
||||
|
||||
我们来比较下面的三种写法:
|
||||
|
||||
```
|
||||
//模拟消息数据需要1秒时间
|
||||
private static void consume(int i) {
|
||||
try {
|
||||
TimeUnit.SECONDS.sleep(1);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
System.out.print(i);
|
||||
}
|
||||
//模拟过滤数据需要1秒时间
|
||||
private static boolean filter(int i) {
|
||||
try {
|
||||
TimeUnit.SECONDS.sleep(1);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return i % 2 == 0;
|
||||
}
|
||||
@Test
|
||||
public void test() {
|
||||
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", String.valueOf(10));
|
||||
|
||||
StopWatch stopWatch = new StopWatch();
|
||||
stopWatch.start("stream");
|
||||
stream();
|
||||
stopWatch.stop();
|
||||
stopWatch.start("parallelStream");
|
||||
parallelStream();
|
||||
stopWatch.stop();
|
||||
stopWatch.start("parallelStreamForEachOrdered");
|
||||
parallelStreamForEachOrdered();
|
||||
stopWatch.stop();
|
||||
System.out.println(stopWatch.prettyPrint());
|
||||
}
|
||||
//filtre和forEach串行
|
||||
private void stream() {
|
||||
IntStream.rangeClosed(1, 10)
|
||||
.filter(ForEachOrderedTest::filter)
|
||||
.forEach(ForEachOrderedTest::consume);
|
||||
}
|
||||
//filter和forEach并行
|
||||
private void parallelStream() {
|
||||
IntStream.rangeClosed(1, 10).parallel()
|
||||
.filter(ForEachOrderedTest::filter)
|
||||
.forEach(ForEachOrderedTest::consume);
|
||||
}
|
||||
//filter并行而forEach串行
|
||||
private void parallelStreamForEachOrdered() {
|
||||
IntStream.rangeClosed(1, 10).parallel()
|
||||
.filter(ForEachOrderedTest::filter)
|
||||
.forEachOrdered(ForEachOrderedTest::consume);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
得到输出:
|
||||
|
||||
```
|
||||
---------------------------------------------
|
||||
ns % Task name
|
||||
---------------------------------------------
|
||||
15119607359 065% stream
|
||||
2011398298 009% parallelStream
|
||||
6033800802 026% parallelStreamForEachOrdered
|
||||
|
||||
```
|
||||
|
||||
从输出中,我们可以看到:
|
||||
|
||||
- stream方法的过滤和遍历全部串行执行,总时间是10秒+5秒=15秒;
|
||||
- parallelStream方法的过滤和遍历全部并行执行,总时间是1秒+1秒=2秒;
|
||||
- parallelStreamForEachOrdered方法的过滤并行执行,遍历串行执行,总时间是1秒+5秒=6秒。
|
||||
|
||||
### [加餐2 | 带你吃透课程中Java 8的那些重要知识点(二)](https://time.geekbang.org/column/article/212398)
|
||||
|
||||
**问题1:**使用Stream可以非常方便地对List做各种操作,那有没有什么办法可以实现在整个过程中观察数据变化呢?比如,我们进行filter+map操作,如何观察filter后map的原始数据呢?
|
||||
|
||||
答:要想观察使用Stream对List的各种操作的过程中的数据变化,主要有下面两个办法。
|
||||
|
||||
第一,**使用peek方法**。比如如下代码,我们对数字1~10进行了两次过滤,分别是找出大于5的数字和找出偶数,我们通过peek方法把两次过滤操作之前的原始数据保存了下来:
|
||||
|
||||
```
|
||||
List<Integer> firstPeek = new ArrayList<>();
|
||||
List<Integer> secondPeek = new ArrayList<>();
|
||||
List<Integer> result = IntStream.rangeClosed(1, 10)
|
||||
.boxed()
|
||||
.peek(i -> firstPeek.add(i))
|
||||
.filter(i -> i > 5)
|
||||
.peek(i -> secondPeek.add(i))
|
||||
.filter(i -> i % 2 == 0)
|
||||
.collect(Collectors.toList());
|
||||
System.out.println("firstPeek:" + firstPeek);
|
||||
System.out.println("secondPeek:" + secondPeek);
|
||||
System.out.println("result:" + result);
|
||||
|
||||
```
|
||||
|
||||
最后得到输出,可以看到第一次过滤之前是数字1~10,一次过滤后变为6~10,最终输出6、8、10三个数字:
|
||||
|
||||
```
|
||||
firstPeek:[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
secondPeek:[6, 7, 8, 9, 10]
|
||||
result:[6, 8, 10]
|
||||
|
||||
```
|
||||
|
||||
第二,**借助IDEA的Stream的调试功能**。详见[这里](https://www.jetbrains.com/help/idea/analyze-java-stream-operations.html),效果类似下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3e/5e/3ee49c0589286bba37dd66032530d65e.png" alt="">
|
||||
|
||||
**问题2:**Collectors类提供了很多现成的收集器,那我们有没有办法实现自定义的收集器呢?比如,实现一个MostPopularCollector,来得到List中出现次数最多的元素,满足下面两个测试用例:
|
||||
|
||||
```
|
||||
assertThat(Stream.of(1, 1, 2, 2, 2, 3, 4, 5, 5).collect(new MostPopularCollector<>()).get(), is(2));
|
||||
assertThat(Stream.of('a', 'b', 'c', 'c', 'c', 'd').collect(new MostPopularCollector<>()).get(), is('c'));
|
||||
|
||||
```
|
||||
|
||||
答:我来说下我的实现思路和方式:通过一个HashMap来保存元素的出现次数,最后在收集的时候找出Map中出现次数最多的元素:
|
||||
|
||||
```
|
||||
public class MostPopularCollector<T> implements Collector<T, Map<T, Integer>, Optional<T>> {
|
||||
//使用HashMap保存中间数据
|
||||
@Override
|
||||
public Supplier<Map<T, Integer>> supplier() {
|
||||
return HashMap::new;
|
||||
}
|
||||
//每次累积数据则累加Value
|
||||
@Override
|
||||
public BiConsumer<Map<T, Integer>, T> accumulator() {
|
||||
return (acc, elem) -> acc.merge(elem, 1, (old, value) -> old + value);
|
||||
}
|
||||
//合并多个Map就是合并其Value
|
||||
@Override
|
||||
public BinaryOperator<Map<T, Integer>> combiner() {
|
||||
return (a, b) -> Stream.concat(a.entrySet().stream(), b.entrySet().stream())
|
||||
.collect(Collectors.groupingBy(Map.Entry::getKey, summingInt(Map.Entry::getValue)));
|
||||
}
|
||||
//找出Map中Value最大的Key
|
||||
@Override
|
||||
public Function<Map<T, Integer>, Optional<T>> finisher() {
|
||||
return (acc) -> acc.entrySet().stream()
|
||||
.reduce(BinaryOperator.maxBy(Map.Entry.comparingByValue()))
|
||||
.map(Map.Entry::getKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<Characteristics> characteristics() {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### [加餐3 | 定位应用问题,排错套路很重要](https://time.geekbang.org/column/article/221982)
|
||||
|
||||
**问题:**如果你现在打开一个App后发现首页展示了一片空白,那这到底是客户端兼容性的问题,还是服务端的问题呢?如果是服务端的问题,又如何进一步细化定位呢?你有什么分析思路吗?
|
||||
|
||||
答:首先,我们需要区分客户端还是服务端错误。我们可以先从客户端下手,排查看看是否是服务端问题,也就是通过抓包来看服务端的返回(一般而言客户端发布之前会经过测试,而且无法随时变更,所以服务端出错的可能性会更大一点)。因为一个客户端程序可能对应几百个服务端接口,先从客户端(发出请求的根源)开始排查问题,更容易找到方向。
|
||||
|
||||
服务端没有返回正确的输出,那么就需要继续排查服务端接口或是上层的负载均衡了,排查方式为:
|
||||
|
||||
- 查看负载均衡(比如Nginx)的日志;
|
||||
- 查看服务端日志;
|
||||
- 查看服务端监控。
|
||||
|
||||
如果服务端返回了正确的输出,那么要么是由于客户端的Bug,要么就是外部配置等问题了,排查方式为:
|
||||
|
||||
- 查看客户端报错(一般而言,客户端都会对接SAAS的异常服务);
|
||||
- 直接本地启动客户端调试。
|
||||
|
||||
### [加餐4 | 分析定位Java问题,一定要用好这些工具(一)](https://time.geekbang.org/column/article/224816)
|
||||
|
||||
**问题1:**JDK中还有一个jmap工具,我们会使用jmap -dump命令来进行堆转储。那么,这条命令和jmap -dump:live有什么区别呢?你能否设计一个实验,来证明下它们的区别呢?
|
||||
|
||||
答:jmap -dump命令是转储堆中的所有对象,而jmap -dump:live是转储堆中所有活着的对象。因为,jmap -dump:live会触发一次FullGC。
|
||||
|
||||
写一个程序测试一下:
|
||||
|
||||
```
|
||||
@SpringBootApplication
|
||||
@Slf4j
|
||||
public class JMapApplication implements CommandLineRunner {
|
||||
|
||||
//-Xmx512m -Xms512m
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(JMapApplication.class, args);
|
||||
}
|
||||
@Override
|
||||
public void run(String... args) throws Exception {
|
||||
while (true) {
|
||||
//模拟产生字符串,每次循环后这个字符串就会失去引用可以GC
|
||||
String payload = IntStream.rangeClosed(1, 1000000)
|
||||
.mapToObj(__ -> "a")
|
||||
.collect(Collectors.joining("")) + UUID.randomUUID().toString();
|
||||
log.debug(payload);
|
||||
TimeUnit.MILLISECONDS.sleep(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,使用jmap不带和带live分别生成两个转储:
|
||||
|
||||
```
|
||||
jmap -dump:format=b,file=nolive.hprof 57323
|
||||
jmap -dump:live,format=b,file=live.hprof 5732
|
||||
|
||||
```
|
||||
|
||||
可以看到,nolive这个转储的不可到达对象包含了164MB char[](可以认为基本是字符串):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8e/9b/8e4f3eea80edfe6d867cab754967589b.png" alt="">
|
||||
|
||||
而live这个转储只有1.3MB的char[],说明程序循环中的这些字符串都被GC了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/18/87/18403a0b683c3b726700d5624f968287.png" alt="">
|
||||
|
||||
**问题2:**你有没有想过,客户端是如何和MySQL进行认证的呢?你能否对照[MySQL的文档](https://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::Handshake),使用Wireshark观察分析这一过程呢?
|
||||
|
||||
答:一般而言,认证(握手)过程分为三步。
|
||||
|
||||
首先,服务端给客户端主动发送握手消息:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/29/b2/29f5e4a9056b7b6aeb9d9ac2yy5e97b2.png" alt="">
|
||||
|
||||
Wireshark已经把消息的字段做了解析,你可以对比[官方文档](https://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::Handshake)的协议格式一起查看。HandshakeV10消息体的第一个字节是消息版本0a,见图中红色框标注的部分。前面四个字节是MySQL的消息头,其中前三个字节是消息体长度(16进制4a=74字节),最后一个字节是消息序列号。
|
||||
|
||||
然后,客户端给服务端回复的HandshakeResponse41消息体,包含了登录的用户名和密码:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a0/96/a0b37df4f3e92f7e8602409a7ca0f696.png" alt="">
|
||||
|
||||
可以看到,用户名是string[NUL]类型的,说明字符串以00结尾代表字符串结束。关于MySQL协议中的字段类型,你可以参考[这里](https://dev.mysql.com/doc/internals/en/string.html)。
|
||||
|
||||
最后,服务端回复的OK消息,代表握手成功:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d7/e7/d746f34df74cfedb4d294db1e2b771e7.png" alt="">
|
||||
|
||||
这样分析下来,我们可以发现使用Wireshark观察客户端和MySQL的认证过程,非常方便。而如果不借助Wireshark工具,我们只能一个字节一个字节地对照协议文档分析内容。
|
||||
|
||||
其实,各种CS系统定义的通讯协议本身并不深奥,甚至可以说对着协议文档写通讯客户端是体力活。你可以继续按照这里我说的方式,结合抓包和文档,分析一下MySQL的查询协议。
|
||||
|
||||
### [加餐5 | 分析定位Java问题,一定要用好这些工具(二)](https://time.geekbang.org/column/article/230534)
|
||||
|
||||
**问题:**Arthas还有一个强大的热修复功能。比如,遇到高CPU问题时,我们定位出是管理员用户会执行很多次MD5,消耗大量CPU资源。这时,我们可以直接在服务器上进行热修复,步骤是:jad命令反编译代码->使用文本编辑器(比如Vim)直接修改代码->使用sc命令查找代码所在类的ClassLoader->使用redefine命令热更新代码。你可以尝试使用这个流程,直接修复程序(注释doTask方法中的相关代码)吗?
|
||||
|
||||
答:Arthas的官方文档有[详细的操作步骤](https://alibaba.github.io/arthas/redefine.html),实现jad->sc->redefine的整个流程,需要注意的是:
|
||||
|
||||
- redefine命令和jad/watch/trace/monitor/tt等命令会冲突。执行完redefine之后,如果再执行上面提到的命令,则会把redefine的字节码重置。 原因是,JDK本身redefine和Retransform是不同的机制,同时使用两种机制来更新字节码,只有最后的修改会生效。
|
||||
- 使用redefine不允许新增或者删除field/method,并且运行中的方法不会立即生效,需要等下次运行才能生效。
|
||||
|
||||
以上,就是咱们这门课里面5篇加餐文章的思考题答案了。至此,咱们这个课程的“答疑篇”模块也就结束了。
|
||||
|
||||
关于这些题目,以及背后涉及的知识点,如果你还有哪里感觉不清楚的,欢迎在评论区与我留言,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。
|
||||
Reference in New Issue
Block a user