分类 Java 相关 下的文章

Java 性能优化视频笔记

https://thestrangeloop.com/2017/the-performance-engineers-guide-to-java-hotspot-virtual-machines-execution-engine.html
这个视频其实和她的那本书基本是一致的, 加了些那年比较新的东西.

  1. JDK 是我们开发用的开发工具集, 里面包含 JRE 和一些工具, 比如 jps, cmd, JVisualVM 等;
  2. JRE 是 Java 运行时引擎, 是 Java 代码跑起来的必要设施. 它处于用户代码和 OS 之间;
  3. API 是我们的代码和 JRE 之间的接口;
  4. JRE 里面主要的功能是 编译 和 内存管理, 所以 JRE 层面的优化主要是编译和内存管理(heap)方面;
  5. Java 的内存管理主要是管理 内存的分配和内存的回收 -> 内存管理算法 -> CMS, G1, etc;
  6. 编译分为 静态编译 和 动态运行时编译;
  7. JIT 优化: 提前编译, 分层编译, 基于已搜集信息的编译(profile-based), 启发式编译;
  8. Java 代码是先翻译(javac 成 class 文件字节码)然后再部分编译(JIT);
  9. JRE(运行时)的主要目的就是把字节码转换成操作系统 native 代码;
  10. 内存分配-> 快速分配 -> TLAB & PLAB;
  11. 锁: 偏向锁, 轻量锁, 锁膨胀, 锁消除;
  12. 代码: inline, 逃逸分析, 代码消除, 栈上分配, 寄存器分配;
  13. String 实现的优化 itern();

Java 语言中的 assert 关键字

我们读有些 Java 代码的时候, 通常会遇到使用 assert 关键字的代码, 比如:
assert Thread.holdsLock(this);
那么这个 assert 在这里有什么用呢? 我们要不要使用它呢?

assert 是 JDK 1.4 引入的, 它通常有下面 2 种使用方式:
assert 以 boolen 值为结果的表达式;

assert 以 boolen 值为结果的表达式 : "某个字符串, 告诉为什么出错了";

默认情况下, 即便你们代码里写了 assert 关键字的代码, 它们也不会被执行. 通过下面 2 个启动参数, 我们可以控制是不是需要执行 assert 的代码:
-enableassertions-ea
-disableassertions-da

语法:

java [ -enableassertions | -ea  ] [:<package name>"..." | :<class name> ]

官方文档: https://docs.oracle.com/javase/7/docs/technotes/guides/language/assert.html

Java socket timeout 在 Linux 的实现

Block IO (SocketInputStream)

我们从 java.net.SocketInputStream 看起. 它的 read 方法传入 timeout 的值. 如果不传入 timeout 值, 则这个 timeout 的值是通过 setOption 设置的, 若没有设置过, 那么它的值就是 0.

也就是说每次 read 方法被调用, 都针对这次 read 操作有一个重新计时的 timeout时钟. 如果一个网络数据要读好几次, 每次 read 都会重新计时这个 timeout.

那么这个 timeout 最终在哪里使用的呢? 下面是每一步调用的栈:

  1. java.net.SocketInputStream.read(byte b[], int off, int length, int timeout)
  2. java.net.SocketInputStream.socketRead(FileDescriptor fd, byte b[], int off, int length, int timeout)
  3. native java.net.SocketInputStream.socketRead0(FileDescriptor fd, byte b[], int off, int length, int timeout)
  4. SocketInputStream.c
    Java_java_net_SocketInputStream_socketRead0(JNIEnv *env, jobject this, jobject fdObj, jbyteArray data, jint off, jint len, jint timeout) 点过上面链接, 仔细查看路径会发现, 这个SocketInputStream.c既不是 shared 代码, 也不是 Linux 的代码, 而是 Solaris, 而我们讨论的是 Linux 的代码, 为什么给出的是 Solaris 代码呢? 这个问答给出了明确的答案: SocketInputStream.c 包含了 net_util.h, 而 net_util.h 又包含了 net_util_md.h. 而 net_util_md.h 里面使用 #if defined(__linux__) || defined(MACOSX) 来区分不同的 OS.
  5. 上面的 SocketInputStream.c 又把 timeout 参数传给了 NET_Timeout 函数. NET_Timeoutlinux_close.c (注意还是 Solaris 目录)里面定义:

    /*
    * Wrapper for poll(s, timeout).
    * Auto restarts with adjusted timeout if interrupted by
    * signal other than our wakeup signal.
    */
    int NET_Timeout(int s, long timeout) {
  6. 上面的注释中很好的说明了 它只是用来封装 poll 系统调用, 具体的调用就是:
    在一个无限循环中, 每次用剩余的 timeout 时间去看看有没有可读数据( poll()), 直到读到, 若没读到, 则计算剩余时间, 继续用剩余时间去读, 直到读到, 或者 timeout 没有剩余.

    if (timeout > 0) {
       gettimeofday(&t, NULL);
       prevtime = t.tv_sec * 1000  +  t.tv_usec / 1000;
     }
    
     for(;;) {
       struct pollfd pfd;
       int rv;
       threadEntry_t self;
    
       /*
        * Poll the fd. If interrupted by our wakeup signal
        * errno will be set to EBADF.
        */
       pfd.fd = s;
       pfd.events = POLLIN | POLLERR;
    
       startOp(fdEntry, &self);
       rv = poll(&pfd, 1, timeout);
       endOp(fdEntry, &self);
    
       /*
        * If interrupted then adjust timeout. If timeout
        * has expired return 0 (indicating timeout expired).
        */
       if (rv < 0 && errno == EINTR) {
           if (timeout > 0) {
               gettimeofday(&t, NULL);
               newtime = t.tv_sec * 1000  +  t.tv_usec / 1000;
               timeout -= newtime - prevtime;
               if (timeout <= 0) {
                   return 0;
               }
               prevtime = newtime;
           }
       } else {
           return rv;
       }
    
     }

    NIO (SocketChannel)

    Java New IO 可以选择是否是 block 模式.
    socketChannel.configureBlocking(false);
    这主要影响到这 3 个 API 的操作

  7. connect();
  8. read();
  9. write();

    注意到, 他们和之前 Blocking IO 的区别: 他们都没有地方设置 timeout 的值. 
    connect(): 若同步, 则等到建立连接或者 IO error 产生; 若异步, 则立马返回, 后续通过一直调用 finishConnect() 来测试是不是已经建立连接;
    read(): 若同步, 直到读到数据或 IO 出错; 若异步, 读一次, 立马返回, 不管是不是读到数据, 通过返回值判断读多少;
    write(): 若同步, 直到写完或者 IO 出错; 若异步, 写一次, 立马返回, 不管是不是有数据写出, 通过返回值判断写多少;

sun.nio.ch.SocketChannelImpl 里面的read()实现(写类似):

if (blocking) {
     do {
          n = IOUtil.read(fd, buf, -1, nd);
     } while (n == IOStatus.INTERRUPTED && isOpen());
} else {
     n = IOUtil.read(fd, buf, -1, nd);
}

即便使用 non-blocking 的方式, 为了保证连接建立好, 读完整, 写完数据, 就要业务线程使用 loop 的方式一直检查这些连接操作,读操作, 写操作是不是好了. 这样并不能带来更好的性能, 相反, 需要更多的 CPU 时间.

所以, 为了更好的性能, 就单独找个线程, 来负责所有这些 IO 操作的连接, 读, 写操作. 其它线程就去做其它事情去了, 这个负责 IO 操作的线程为了更好的同时处理多个 IO, 就使用 Selector 的方式, 相当于一个人看守多个流水线.

到此, 我们还没有涉及 timeout, 并且上面 API 中也根本没有提到 timeout, 那么 non-blocking 的方式中, timeout 是怎么实现的呢? 通常都是负责 IO 的线程来实现的, 它每次批量 select() 之后, 就会查看一遍是不是已经有超时的 IO. 比如 JDK 自带的 jdk.internal.net.http.HttpClientImpl 中的 purgeTimeoutsAndReturnNextDeadline 方法, 就负责每次 select() 完查看是否有 timeout 的 IO.
https://github.com/AdoptOpenJDK/openjdk-jdk11/blob/master/src/java.net.http/share/classes/jdk/internal/net/http/HttpClientImpl.java#L1210

Blocking IO 里面的 timeout 每次调用 read/write API 的时候, 直接传到 epoll() 方法去的, 也就是说如果一个 response 调用了 read() 多次, 那么每次 read() 都是重新计时 timeout 的值. 但是在 NIO 里面, 我们看到 JDK 提供的这个HttpClientImpl 实现里面, 使用 NIO 的方式, 它每次调用 read() 方法是不会 reset timeout 值的, 这个 timeout 值的使用方式是: 从一开始便扣减里面剩余的时间. 这 2 种方式对于读一个很大数据流是有影响的.

另外这篇文章详细介绍了各种 http client 是否支持 sync, async, 是使用 future 的方式, 还是使用 callback 的方式:
https://www.mocklab.io/blog/which-java-http-client-should-i-use-in-2020/

可以调节下面代码的 connect timeout 和 read timeout 值, 来观察或者 debug 是如何实现的 timeout:

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpClient.Version;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.time.Duration;

public class NioTest {

    public static void main(String[] args) throws IOException, InterruptedException {
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create("https://www.tianxiaohui.com/"))
                .timeout(Duration.ofMillis(1000))
                .header("Content-Type", "application/json")
                 .GET()
                .build();
        HttpClient client = HttpClient.newBuilder()
                  .version(Version.HTTP_1_1)
                  .connectTimeout(Duration.ofMillis(1000))
                  .build();
        
        client.sendAsync(request, BodyHandlers.ofString())
                .thenApply(HttpResponse::body)
                .thenAccept(System.out::println)
                .exceptionally(t -> {System.out.println(t.getMessage());t.printStackTrace(); return null;}); 
           
           try {
            Thread.sleep(600000);
            System.out.println();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

查看 DNS 查询记录

要使用外部服务, 就要连接外部 endpoint, 通常我们都是用 FQDN 去连接外部, 偶尔直接使用 IP 地址. 如果使用 FQDN, 那么就涉及 DNS 查询. 通常 DNS 记录都有 TTL (time to live)时限, 时限一过, 就要重新 DNS 查询.
使用 dig 命令可以查看一条 DNS 记录的 ttl:

$ dig +nocmd +noall +answer +ttlid a www.tianxiaohui.com
www.tianxiaohui.com.    389    IN    A    103.144.218.
$ dig +nocmd +noall +answer +ttlid a www.baidu.com
www.baidu.com.        1200    IN    CNAME    www.a.shifen.com.
www.a.shifen.com.    239    IN    CNAME    www.wshifen.com.
www.wshifen.com.    239    IN    A    45.113.192.102
www.wshifen.com.    239    IN    A    45.113.192.101

上面结果第二列就是 ttl, 单位是秒.

如何查看 Linux 上的 DNS 查询

使用 tcpdump 查看 53 端口的流量, 就能查看 DNS 查询记录 (

$ sudo tcpdump -l port 53  # -l 表示使用 buffer
06:29:08.312928 IP 10.226.197.72.40898 > sin-dns.tianxiaohui.com.domain: 42885+ A? firestore.googleapis.com. (42)
06:29:08.422806 IP sin-dns.tianxiaohui.com.domain > 10.226.197.72.40898: 42885 1/0/0 A 142.250.4.95 (58)
06:29:45.507982 IP 10.226.197.72.5013 > sin-dns.tianxiaohui.com.domain: 2509+ A? github.com. (28)
06:29:45.575657 IP sin-dns.tianxiaohui.com.domain > 10.226.197.72.5013: 2509 1/0/0 A 20.205.243.166 (44)
06:29:45.576415 IP 10.226.197.72.42090 > sin-dns.tianxiaohui.com.domain: 64400+ A? github.com. (28)
06:29:45.640234 IP sin-dns.tianxiaohui.com.domain > 10.226.197.72.42090: 64400 1/0/0 A 20.205.243.166 (44)
06:30:05.738311 IP 10.226.197.72.54902 > sin-dns.tianxiaohui.com.domain: 51140+ A? translate.google.com. (38)

如何查看一个 Java 应用程序的 DNS 查询

Java 应用程序的 DNS 最近的查询记录都记录在 InetAddress 类的某个字段中, 所以对这个字段读, 或者你有一个 heap dump, 查看这个字段, 就能查看到最近的 DNS 记录. 详细的方法, 可以查看这段代码:
https://github.com/manecocomph/myJavaAgent/blob/master/src/com/tianxiaohui/java/agent/SampleAgent.java#L191

使用 Java 访问 https 的站点 TLS 协商中的 SNI 问题

上周有同事说他们的 Java 应用连一个新的服务, 总是有连接问题, 但是切换到老的服务一起都正常. 经过查看日志, tcp 抓包, Loader Balancer 验证, 我们发现问题的根源是 TLS 协商中 client_hello 中涉及的 SNI 扩展问题.


问题再现

这个 Java 应用连接一个 Elastic Search 服务, 这个 ES (Elastic Search) 的 endpoint 只支持 https 协议, 平时一切运行正常. 最近这个 ES 服务重新在 K8S 集群上面重新做了一个, 同样只支持 https 协议. 但是在 Java 应用这边, 把 endpoint 改成新的 URL, 可是总是报错. 一开始开发人员以为是新的 ES 可能慢, 或者网络问题, 于是加大了 client 端的 connect timeout & read timeout 时间. 即便增加了很多, 问题还是没有改善.

原因分析

首先, 我们看了出错日志, 发现是连接超时, 也就是连接本身就有问题. 然后我们在客户端抓包, 发现以下情形: 首先 TCP 的三次握手是没有任何问题的. 然后开始协商 TLS 协议, 客户端发送 client hello 包, 服务端 ack client hello, 然后就直接 reset 了这个连接. 然后一切就结束了. 所以, 可以看到是 TLS 协商的问题.
image.png

从这里推断, 我们猜测是客户端发送的 client_hello 里面的一些信息, 在服务端认为不可接受, 所以直接就 reset 了. 首先我们猜测客户端发送的这些加密方法(cipher suites) 在服务端没有一个被接受的. 客户端说我使用 TLSv1.2 协议, 发送了如下的加密方法:
image (1).png
我们查看了这些加密方法, 这些方法正是: io.netty.handler.ssl.SslUtils.java 里面默认的加密方法:
defaultcipher.png
这些加密方法是默认所有加密端都必须接受的, 所以这个不是问题.

又咨询了做加密证书的同事, 他们说这些加密方法服务端肯定是接受的, 都是默认的. 他们猜测这些连接被 reset 的原因很有可能是 SNI 导致的, 对于在K8S新加的 endpoint, 这些都要求客户端在发送 client_hello 的时候, 必须发送 SNI 扩展, 否则直接 reset 连接.

于是重新查看了 TCP dump, 发现确实没有发送 SNI 扩展项. 这就是导致问题的根源.

HTTP 协议中的 host header

在讲 TLS 协议的 SNI 之前, 我们先说一下 HTTP 协议中的 host header. 在早期的 HTTP 协议中, 是没有 host header 的. 一个 HTTP 请求大概是这样的:

GET /index.html HTTP/1.0

在那个时候, 网站还没有那么多, 一般每个网站都有一个独立的 IP. 后来网站越来越多, IPv4逐渐被用光, IPv6 还没被大规模使用, 于是兴起了虚拟主机(Virtual Host), 每个 IP 后边可能有多个网站. 为了区分不同的网站, 于是 HTTP 协议就加了一个 host header 用来区分不同的网站. 于是一个 HTTP 请求大概是这样的:

GET /index.html HTTP/1.0
Host www.tianxiaohui.com

所以, 概括起来就是: Host header 解决了一个 IP 多个网站的问题.

TLS 协议 client_hello 中的 SNI 扩展

如果使用 https 协议, 那么在 TCP 3次握手之后, 就是 TLS 协商, 如果一个 IP 上面有多个网站, 都有不同的证书, 那么如何在 TLS 协商阶段就能区分不同的网站呢? 于是 TLS 协议添加了 SNI (Server Name Indication) 扩展, 在 SNI 扩展里面设置 server_name 的字段值, 通过这个字段值, 服务端在开始握手的时候, 就能知道它要访问具体那个网站.
sni.png

Java 支持 SNI

Java 从 JDK 7 开始支持 SNI. https://docs.oracle.com/javase/7/docs/technotes/guides/security/enhancements-7.html

Server Name Indication (SNI) for JSSE client: The Java SE 7 release supports the Server Name Indication (SNI) extension in the JSSE client. SNI is described in RFC 4366. This enables TLS clients to connect to virtual servers.

Java 中常见的2种连接关于 SNI 的实验

我们的应用使用的是 JDK 8, 那么为什么还缺少 SNI 呢? 这要归因于那段代码使用的是 Netty 库, Netty 对于 TLS 的处理是使用的 JDK 底层 API, 需要自己处理 SNI 这个参数.

通常在 Java 中我们会遇到如下两种 https连接方式: 1) 使用 Java 的 URL连接; 2) 使用 Netty 的连接. 于是我们实验了这2种方式, 在使用Java 的 URL 连接的时候, 它默认是带 SNI 的. 如果使用 Netty 的方式, 就需要手工设置 SslParameter 里面设置主机名. 否则 Netty 不会发送 SNI. 通过 tcp 抓包的方式, 我们可以验证这些信息.
实验代码:

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.InetSocketAddress;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Collections;

import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SNIHostName;
import javax.net.ssl.SSLEngine;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLParameters;

import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.Unpooled;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.http.DefaultFullHttpRequest;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.HttpMethod;
import io.netty.handler.codec.http.HttpVersion;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;
import io.netty.handler.ssl.SslHandler;
import io.netty.handler.ssl.SslProvider;

public class Test {
    public static void main(String[] args) throws IOException, InterruptedException {
        //Test.testHttpsUrlSni();
        Test.testNettyHttpsSni();
    }

    public static void testHttpsUrlSni() throws IOException {
        URL u = new URL("https://www.tianxiaohui.com/");
        HttpsURLConnection http = (HttpsURLConnection) u.openConnection();
        http.setAllowUserInteraction(true);
        http.setRequestMethod("GET");
        http.connect();

        try (InputStream is = http.getInputStream();
                BufferedReader reader = new BufferedReader(new InputStreamReader(is))) {

            StringBuilder stringBuilder = new StringBuilder();
            String line = null;
            while (null != (line = reader.readLine())) {
                stringBuilder.append(line + "\n");
            }
            System.out.println(stringBuilder.toString());
        }

        System.out.println("***************Https testing completed **************");
    }

    public static void testNettyHttpsSni() throws SSLException, InterruptedException {
        EventLoopGroup childGroup = new NioEventLoopGroup();

        // SSL Parameters to set SNI TLS Extension

        SSLParameters sslParameters = new SSLParameters();
        sslParameters.setServerNames(Collections.singletonList(new SNIHostName("facebook.com")));

        // Build SSLContext for Client
        SslContext sslContext = SslContextBuilder.forClient().sslProvider(SslProvider.JDK).build();

        // SSLEngine with SSL Parameters for SNI
        SSLEngine sslEngine = sslContext.newEngine(ByteBufAllocator.DEFAULT);
        // sslEngine.setSSLParameters(sslParameters);//是否设置在这里

        // SSL Handler
        SslHandler sslHandler = new SslHandler(sslEngine);

        Bootstrap bootstrap = new Bootstrap();
        bootstrap.group(childGroup).channel(NioSocketChannel.class).handler(new SimpleChannelInboundHandler<>() {
            @Override
            protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
                // JUST RANDOM CODE TO MAKE TLS REQUEST
                FullHttpRequest fullHttpRequest = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/",
                        Unpooled.EMPTY_BUFFER);
                ctx.writeAndFlush(fullHttpRequest);
            }
        });

        Channel ch = bootstrap.connect(new InetSocketAddress("tianxiaohui.com", 443)).sync().channel();
        ch.pipeline().addFirst(sslHandler);
    }