Dunwu Blog

大道至简,知易行难

Flume

Flume 简介

Apache Flume 是一个分布式,高可用的数据收集系统。它可以从不同的数据源收集数据,经过聚合后发送到存储系统中,通常用于日志数据的收集。Flume 分为 NG 和 OG (1.0 之前) 两个版本,NG 在 OG 的基础上进行了完全的重构,是目前使用最为广泛的版本。下面的介绍均以 NG 为基础。

Flume 架构

Agent component diagram

外部数据源以特定格式向 Flume 发送 events (事件),当 source 接收到 events 时,它将其存储到一个或多个 channelchannel 会一直保存 events 直到它被 sink 所消费。sink 的主要功能从 channel 中读取 events,并将其存入外部存储系统或转发到下一个 source,成功后再从 channel 中移除 events

Flume 基本概念

  • Event - Event 是 Flume NG 数据传输的基本单元。类似于 JMS 和消息系统中的消息。一个 Event 由标题和正文组成:前者是键/值映射,后者是任意字节数组。
  • Agent - 是一个独立的 (JVM) 进程,包含 SourceChannelSink 等组件。
    • Source - 数据收集组件,从外部数据源收集数据,并存储到 Channel 中。
    • Channel - Channel 是源和接收器之间的管道,用于临时存储数据。可以是内存或持久化的文件系统:
      • Memory Channel : 使用内存,优点是速度快,但数据可能会丢失 (如突然宕机);
      • File Channel : 使用持久化的文件系统,优点是能保证数据不丢失,但是速度慢。
    • Sink - Sink 的主要功能从 Channel 中读取 Event,并将其存入外部存储系统或将其转发到下一个 Source,成功后再从 Channel 中移除 Event

Flume 组件种类

Flume 中的每一个组件都提供了丰富的类型,适用于不同场景:

  • Source 类型 :内置了几十种类型,如 Avro SourceThrift SourceKafka SourceJMS Source
  • Sink 类型 :HDFS SinkHive SinkHBaseSinksAvro Sink 等;
  • Channel 类型 :Memory ChannelJDBC ChannelKafka ChannelFile Channel 等。

对于 Flume 的使用,除非有特别的需求,否则通过组合内置的各种类型的 Source,Sink 和 Channel 就能满足大多数的需求。在 Flume 官网 上对所有类型组件的配置参数均以表格的方式做了详尽的介绍,并附有配置样例;同时不同版本的参数可能略有所不同,所以使用时建议选取官网对应版本的 User Guide 作为主要参考资料。

Flume 架构模式

Flume 支持多种架构模式,分别介绍如下

multi-agent flow

Two agents communicating over Avro RPC

Flume 支持跨越多个 Agent 的数据传递,这要求前一个 Agent 的 Sink 和下一个 Agent 的 Source 都必须是 Avro 类型,Sink 指向 Source 所在主机名 (或 IP 地址) 和端口(详细配置见下文案例三)。

Consolidation

A fan-in flow using Avro RPC to consolidate events in one place

日志收集中常常存在大量的客户端(比如分布式 web 服务),Flume 支持使用多个 Agent 分别收集日志,然后通过一个或者多个 Agent 聚合后再存储到文件系统中。

Multiplexing the flow

A fan-out flow using a (multiplexing) channel selector

Flume 支持从一个 Source 向多个 Channel,也就是向多个 Sink 传递事件,这个操作称之为 Fan Out(扇出)。默认情况下 Fan Out 是向所有的 Channel 复制 Event,即所有 Channel 收到的数据都是相同的。同时 Flume 也支持在 Source 上自定义一个复用选择器 (multiplexing selector) 来实现自定义的路由规则。

Flume 配置格式

Flume 配置通常需要以下两个步骤:

(1)分别定义好 Agent 的 Sources,Sinks,Channels,然后将 Sources 和 Sinks 与通道进行绑定。需要注意的是一个 Source 可以配置多个 Channel,但一个 Sink 只能配置一个 Channel。基本格式如下:

1
2
3
4
5
6
7
8
9
<Agent>.sources = <Source>
<Agent>.sinks = <Sink>
<Agent>.channels = <Channel1> <Channel2>

# set channel for source
<Agent>.sources.<Source>.channels = <Channel1> <Channel2> ...

# set channel for sink
<Agent>.sinks.<Sink>.channel = <Channel1>

(2)分别定义 Source,Sink,Channel 的具体属性。基本格式如下:

1
2
3
4
5
6
7
<Agent>.sources.<Source>.<someProperty> = <someValue>

# properties for channels
<Agent>.channel.<Channel>.<someProperty> = <someValue>

# properties for sinks
<Agent>.sources.<Sink>.<someProperty> = <someValue>

Flume 使用案例

介绍几个 Flume 的使用案例:

  • 案例一:使用 Flume 监听文件内容变动,将新增加的内容输出到控制台。
  • 案例二:使用 Flume 监听指定目录,将目录下新增加的文件存储到 HDFS。
  • 案例三:使用 Avro 将本服务器收集到的日志数据发送到另外一台服务器。

案例一

需求: 监听文件内容变动,将新增加的内容输出到控制台。

实现: 主要使用 Exec Source 配合 tail 命令实现。

配置

新建配置文件 exec-memory-logger.properties, 其内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#指定 agent 的 sources,sinks,channels
a1.sources = s1
a1.sinks = k1
a1.channels = c1

#配置 sources 属性
a1.sources.s1.type = exec
a1.sources.s1.command = tail -F /tmp/log.txt
a1.sources.s1.shell = /bin/bash -c

#将 sources 与 channels 进行绑定
a1.sources.s1.channels = c1

#配置 sink
a1.sinks.k1.type = logger

#将 sinks 与 channels 进行绑定
a1.sinks.k1.channel = c1

#配置 channel 类型
a1.channels.c1.type = memory

启动

1
2
3
4
5
flume-ng agent \
--conf conf \
--conf-file /usr/app/apache-flume-1.6.0-cdh5.15.2-bin/examples/exec-memory-logger.properties \
--name a1 \
-Dflume.root.logger=INFO,console

测试

向文件中追加数据:

img

控制台的显示:

img

案例二

需求: 监听指定目录,将目录下新增加的文件存储到 HDFS。

实现:使用 Spooling Directory SourceHDFS Sink

配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#指定 agent 的 sources,sinks,channels
a1.sources = s1
a1.sinks = k1
a1.channels = c1

#配置 sources 属性
a1.sources.s1.type =spooldir
a1.sources.s1.spoolDir =/tmp/logs
a1.sources.s1.basenameHeader = true
a1.sources.s1.basenameHeaderKey = fileName
#将 sources 与 channels 进行绑定
a1.sources.s1.channels =c1


#配置 sink
a1.sinks.k1.type = hdfs
a1.sinks.k1.hdfs.path = /flume/events/%y-%m-%d/%H/
a1.sinks.k1.hdfs.filePrefix = %{fileName}
#生成的文件类型,默认是 Sequencefile,可用 DataStream,则为普通文本
a1.sinks.k1.hdfs.fileType = DataStream
a1.sinks.k1.hdfs.useLocalTimeStamp = true
#将 sinks 与 channels 进行绑定
a1.sinks.k1.channel = c1

#配置 channel 类型
a1.channels.c1.type = memory

启动

1
2
3
4
flume-ng agent \
--conf conf \
--conf-file /usr/app/apache-flume-1.6.0-cdh5.15.2-bin/examples/spooling-memory-hdfs.properties \
--name a1 -Dflume.root.logger=INFO,console

测试

拷贝任意文件到监听目录下,可以从日志看到文件上传到 HDFS 的路径:

1
# cp log.txt logs/

img

查看上传到 HDFS 上的文件内容与本地是否一致:

1
# hdfs dfs -cat /flume/events/19-04-09/13/log.txt.1554788567801

img

案例三

需求: 将本服务器收集到的数据发送到另外一台服务器。

实现:使用 avro sourcesavro Sink 实现。

配置日志收集 Flume

新建配置 netcat-memory-avro.properties,监听文件内容变化,然后将新的文件内容通过 avro sink 发送到 hadoop001 这台服务器的 8888 端口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#指定 agent 的 sources,sinks,channels
a1.sources = s1
a1.sinks = k1
a1.channels = c1

#配置 sources 属性
a1.sources.s1.type = exec
a1.sources.s1.command = tail -F /tmp/log.txt
a1.sources.s1.shell = /bin/bash -c
a1.sources.s1.channels = c1

#配置 sink
a1.sinks.k1.type = avro
a1.sinks.k1.hostname = hadoop001
a1.sinks.k1.port = 8888
a1.sinks.k1.batch-size = 1
a1.sinks.k1.channel = c1

#配置 channel 类型
a1.channels.c1.type = memory
a1.channels.c1.capacity = 1000
a1.channels.c1.transactionCapacity = 100

配置日志聚合 Flume

使用 avro source 监听 hadoop001 服务器的 8888 端口,将获取到内容输出到控制台:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#指定 agent 的 sources,sinks,channels
a2.sources = s2
a2.sinks = k2
a2.channels = c2

#配置 sources 属性
a2.sources.s2.type = avro
a2.sources.s2.bind = hadoop001
a2.sources.s2.port = 8888

#将 sources 与 channels 进行绑定
a2.sources.s2.channels = c2

#配置 sink
a2.sinks.k2.type = logger

#将 sinks 与 channels 进行绑定
a2.sinks.k2.channel = c2

#配置 channel 类型
a2.channels.c2.type = memory
a2.channels.c2.capacity = 1000
a2.channels.c2.transactionCapacity = 100

启动

启动日志聚集 Flume:

1
2
3
4
flume-ng agent \
--conf conf \
--conf-file /usr/app/apache-flume-1.6.0-cdh5.15.2-bin/examples/avro-memory-logger.properties \
--name a2 -Dflume.root.logger=INFO,console

在启动日志收集 Flume:

1
2
3
4
flume-ng agent \
--conf conf \
--conf-file /usr/app/apache-flume-1.6.0-cdh5.15.2-bin/examples/netcat-memory-avro.properties \
--name a1 -Dflume.root.logger=INFO,console

这里建议按以上顺序启动,原因是 avro.source 会先与端口进行绑定,这样 avro sink 连接时才不会报无法连接的异常。但是即使不按顺序启动也是没关系的,sink 会一直重试,直至建立好连接。

img

测试

向文件 tmp/log.txt 中追加内容:

img

可以看到已经从 8888 端口监听到内容,并成功输出到控制台:

img

参考资料

Spark

Spark 简介

Spark 概念

  • 大规模分布式通用计算引擎
    • Spark Core:核心计算框架
    • Spark SQL:结构化数据查询
    • Spark Streaming:实时流处理
    • Spark MLib:机器学习
    • Spark GraphX:图计算
  • 具有高吞吐、低延时、通用易扩展、高容错等特点
  • 采用 Scala 语言开发
  • 提供多种运行模式

Spark 特点

  • 计算高效
    • 利用内存计算、Cache 缓存机制,支持迭代计算和数据共享,减少数据读取的 IO 开销
    • 利用 DAG 引擎,减少中间计算结果写入 HDFS 的开销
    • 利用多线程池模型,减少任务启动开销,避免 Shuffle 中不必要的排序和磁盘 IO 操作
  • 通用易用
    • 适用于批处理、流处理、交互式计算、机器学习算法等场景
    • 提供了丰富的开发 API,支持 Scala、Java、Python、R 等
  • 运行模式多样
    • Local 模式
    • Standalone 模式
    • YARN/Mesos 模式
  • 计算高效
    • 利用内存计算、Cache 缓存机制,支持迭代计算和数据共享,减少数据读取的 IO 开销
    • 利用 DAG 引擎,减少中间计算结果写入 HDFS 的开销
    • 利用多线程池模型,减少任务启动开销,避免 Shuffle 中不必要的排序和磁盘 IO 操作
  • 通用易用
    • 适用于批处理、流处理、交互式计算、机器学习等场景
    • 提供了丰富的开发 API,支持 Scala、Java、Python、R 等

Spark 原理

编程模型

RDD

  • 弹性分布式数据集(Resilient Distributed Datesets)
    • 分布在集群中的只读对象集合
    • 由多个 Partition 组成
    • 通过转换操作构造
    • 失效后自动重构(弹性)
    • 存储在内存或磁盘中
  • Spark 基于 RDD 进行计算

RDD 操作(Operator)

  • Transformation(转换)
    • 将 Scala 集合或 Hadoop 输入数据构造成一个新 RDD
    • 通过已有的 RDD 产生新 RDD
    • 惰性执行:只记录转换关系,不触发计算
    • 例如:map、filter、flatmap、union、distinct、sortbykey
  • Action(动作)
    • 通过 RDD 计算得到一个值或一组值
    • 真正触发计算
    • 例如:first、count、collect、foreach、saveAsTextFile

RDD 依赖(Dependency)

  • 窄依赖(Narrow Dependency)
    • 父 RDD 中的分区最多只能被一个子 RDD 的一个分区使用
    • 子 RDD 如果有部分分区数据丢失或损坏,只需从对应的父 RDD 重新计算恢复
    • 例如:map、filter、union
  • 宽依赖(Shuffle/Wide Dependency )
    • 子 RDD 分区依赖父 RDD 的所有分区
    • 子 RDD 如果部分或全部分区数据丢失或损坏,必须从所有父 RDD 分区重新计算
    • 相对于窄依赖,宽依赖付出的代价要高很多,尽量避免使用
    • 例如:groupByKey、reduceByKey、sortByKey

YARN

YARN 简介

Apache YARN (Yet Another Resource Negotiator) 是 hadoop 2.0 引入的集群资源管理系统。用户可以将各种服务框架部署在 YARN 上,由 YARN 进行统一地管理和资源分配。

YARN 架构

ResourceManager

ResourceManager 通常在独立的机器上以后台进程的形式运行,它是整个集群资源的主要协调者和管理者。ResourceManager 负责给用户提交的所有应用程序分配资源,它根据应用程序优先级、队列容量、ACLs、数据位置等信息,做出决策,然后以共享的、安全的、多租户的方式制定分配策略,调度集群资源。

NodeManager

NodeManager 是 YARN 集群中的每个具体节点的管理者。主要负责该节点内所有容器的生命周期的管理,监视资源和跟踪节点健康。具体如下:

  • 启动时向 ResourceManager 注册并定时发送心跳消息,等待 ResourceManager 的指令;
  • 维护 Container 的生命周期,监控 Container 的资源使用情况;
  • 管理任务运行时的相关依赖,根据 ApplicationMaster 的需要,在启动 Container 之前将需要的程序及其依赖拷贝到本地。

ApplicationMaster

在用户提交一个应用程序时,YARN 会启动一个轻量级的进程 ApplicationMasterApplicationMaster 负责协调来自 ResourceManager 的资源,并通过 NodeManager 监视容器内资源的使用情况,同时还负责任务的监控与容错。具体如下:

  • 根据应用的运行状态来决定动态计算资源需求;
  • ResourceManager 申请资源,监控申请的资源的使用情况;
  • 跟踪任务状态和进度,报告资源的使用情况和应用的进度信息;
  • 负责任务的容错。

Container

Container 是 YARN 中的资源抽象,它封装了某个节点上的多维度资源,如内存、CPU、磁盘、网络等。当 AM 向 RM 申请资源时,RM 为 AM 返回的资源是用 Container 表示的。YARN 会为每个任务分配一个 Container,该任务只能使用该 Container 中描述的资源。ApplicationMaster 可在 Container 内运行任何类型的任务。例如,MapReduce ApplicationMaster 请求一个容器来启动 map 或 reduce 任务,而 Giraph ApplicationMaster 请求一个容器来运行 Giraph 任务。

YARN 工作原理

  1. Client 提交作业到 YARN 上;

  2. Resource Manager 选择一个 Node Manager,启动一个 Container 并运行 Application Master 实例;

  3. Application Master 根据实际需要向 Resource Manager 请求更多的 Container 资源(如果作业很小,应用管理器会选择在其自己的 JVM 中运行任务);

  4. Application Master 通过获取到的 Container 资源执行分布式计算。

作业提交

client 调用 job.waitForCompletion 方法,向整个集群提交 MapReduce 作业 (第 1 步) 。新的作业 ID(应用 ID) 由资源管理器分配 (第 2 步)。作业的 client 核实作业的输出,计算输入的 split, 将作业的资源 (包括 Jar 包,配置文件,split 信息) 拷贝给 HDFS(第 3 步)。 最后,通过调用资源管理器的 submitApplication() 来提交作业 (第 4 步)。

作业初始化

当资源管理器收到 submitApplciation() 的请求时,就将该请求发给调度器 (scheduler), 调度器分配 container, 然后资源管理器在该 container 内启动应用管理器进程,由节点管理器监控 (第 5 步)。

MapReduce 作业的应用管理器是一个主类为 MRAppMaster 的 Java 应用,其通过创造一些 bookkeeping 对象来监控作业的进度,得到任务的进度和完成报告 (第 6 步)。然后其通过分布式文件系统得到由客户端计算好的输入 split(第 7 步),然后为每个输入 split 创建一个 map 任务,根据 mapreduce.job.reduces 创建 reduce 任务对象。

任务分配

如果作业很小,应用管理器会选择在其自己的 JVM 中运行任务。

如果不是小作业,那么应用管理器向资源管理器请求 container 来运行所有的 map 和 reduce 任务 (第 8 步)。这些请求是通过心跳来传输的,包括每个 map 任务的数据位置,比如存放输入 split 的主机名和机架 (rack),调度器利用这些信息来调度任务,尽量将任务分配给存储数据的节点,或者分配给和存放输入 split 的节点相同机架的节点。

任务运行

当一个任务由资源管理器的调度器分配给一个 container 后,应用管理器通过联系节点管理器来启动 container(第 9 步)。任务由一个主类为 YarnChild 的 Java 应用执行, 在运行任务之前首先本地化任务需要的资源,比如作业配置,JAR 文件,以及分布式缓存的所有文件 (第 10 步。 最后,运行 map 或 reduce 任务 (第 11 步)。

YarnChild 运行在一个专用的 JVM 中,但是 YARN 不支持 JVM 重用。

进度和状态更新

YARN 中的任务将其进度和状态 (包括 counter) 返回给应用管理器,客户端每秒 (通 mapreduce.client.progressmonitor.pollinterval 设置) 向应用管理器请求进度更新,展示给用户。

作业完成

除了向应用管理器请求作业进度外,客户端每 5 分钟都会通过调用 waitForCompletion() 来检查作业是否完成,时间间隔可以通过 mapreduce.client.completion.pollinterval 来设置。作业完成之后,应用管理器和 container 会清理工作状态, OutputCommiter 的作业清理方法也会被调用。作业的信息会被作业历史服务器存储以备之后用户核查。

提交作业到 YARN 上运行

这里以提交 Hadoop Examples 中计算 Pi 的 MApReduce 程序为例,相关 Jar 包在 Hadoop 安装目录的 share/hadoop/mapreduce 目录下:

1
2
# 提交格式:hadoop jar jar 包路径 主类名称 主类参数
# hadoop jar hadoop-mapreduce-examples-2.6.0-cdh5.15.2.jar pi 3 3

参考资料

大数据简介

简介

什么是大数据

大数据是指超出传统数据库工具收集、存储、管理和分析能力的数据集。与此同时,及时采集、存储、聚合、管理数据,以及对数据深度分析的新技术和新能力,正在快速增长,就像预测计算芯片增长速度的摩尔定律一样。

  • Volume - 数据规模巨大
  • Velocity - 生成和处理速度极快
  • Variety - 数据规模巨大
  • Value - 生成和处理速度极快

应用场景

基于大数据的数据仓库

基于大数据的实时流处理

Hadoop 编年史

时间 事件
2003.01 Google 发表了 Google File System 论文
2004.01 Google 发表了 MapReduce 论文
2006.02 Apache Hadoop 项目正式启动,并支持 MapReduce 和 HDFS 独立发展
2006.11 Google 发表了 Bigtable 论文
2008.01 Hadoop 成为 Apache 顶级项目
2009.03 Cloudera 推出世界上首个 Hadoop 发行版——CDH,并完全开放源码
2012.03 HDFS NameNode HA 加入 Hadoop 主版本
2014.02 Spark 代替 MapReduce 成为 Hadoop 的缺省计算引擎,并成为 Apache 顶级项目

技术体系

HDFS

概念

  • Hadoop 分布式文件系统(Hadoop Distributed File System)
  • 在开源大数据技术体系中,地位无可替代

特点

  • 高容错:数据多副本,副本丢失后自动恢复
  • 高可用:NameNode HA,安全模式
  • 高扩展:10K 节点规模
  • 简单一致性模型:一次写入多次读取,支持追加,不允许修改
  • 流式数据访问:批量读而非随机读,关注吞吐量而非时间
  • 大规模数据集:典型文件大小 GB~TB 级,百万以上文件数量, PB 以上数据规模
  • 构建成本低且安全可靠:运行在大量的廉价商用机器上,硬件错误是常态,提供容错机制

MapReduce

概念

  • 面向批处理的分布式计算框架
  • 编程模型:将 MapReduce 程序分为 Map、Reduce 两个阶段

核心思想

  • 分而治之,分布式计算
  • 移动计算,而非移动数据

特点

  • 高容错:任务失败,自动调度到其他节点重新执行
  • 高扩展:计算能力随着节点数增加,近似线性递增
  • 适用于海量数据的离线批处理
  • 降低了分布式编程的门槛

Spark

高性能分布式通用计算引擎

  • Spark Core - 基础计算框架(批处理、交互式分析)
  • Spark SQL - SQL 引擎(海量结构化数据的高性能查询)
  • Spark Streaming - 实时流处理(微批)
  • Spark MLlib - 机器学习
  • Spark GraphX - 图计算

采用 Scala 语言开发

特点

  • 计算高效 - 内存计算、Cache 缓存机制、DAG 引擎、多线程池模型
  • 通用易用 - 适用于批处理、交互式计算、流处理、机器学习、图计算等多种场景
  • 运行模式多样 - Local、Standalone、YARN/Mesos

YARN

概念

  • Yet Another Resource Negotiator,另一种资源管理器
  • 为了解决 Hadoop 1.x 中 MapReduce 的先天缺陷
  • 分布式通用资源管理系统
  • 负责集群资源的统一管理
  • 从 Hadoop 2.x 开始,YARN 成为 Hadoop 的核心组件

特点

  • 专注于资源管理和作业调度
  • 通用 - 适用各种计算框架,如 - MapReduce、Spark
  • 高可用 - ResourceManager 高可用、HDFS 高可用
  • 高扩展

Hive

概念

  • Hadoop 数据仓库 - 企业决策支持
  • SQL 引擎 - 对海量结构化数据进行高性能的 SQL 查询
  • 采用 HDFS 或 HBase 为数据存储
  • 采用 MapReduce 或 Spark 为计算框架

特点

  • 提供类 SQL 查询语言
  • 支持命令行或 JDBC/ODBC
  • 提供灵活的扩展性
  • 提供复杂数据类型、扩展函数、脚本等

HBase

概念

  • Hadoop Database
  • Google BigTable 的开源实现
  • 分布式 NoSQL 数据库
  • 列式存储 - 主要用于半结构化、非结构化数据
  • 采用 HDFS 为文件存储系统

特点

  • 高性能 - 支持高并发写入和查询
  • 高可用 - HDFS 高可用、Region 高可用
  • 高扩展 - 数据自动切分和分布,可动态扩容,无需停机
  • 海量存储 - 单表可容纳数十亿行,上百万列

ElasticSearch

  • 开源的分布式全文检索引擎
  • 基于 Lucene 实现全文数据的快速存储、搜索和分析
  • 处理大规模数据 - PB 级以上
  • 具有较强的扩展性,集群规模可达上百台
  • 首选的分布式搜索引擎

术语

数据仓库(Data Warehouse) - 数据仓库,是为企业所有级别的决策制定过程,提供所有类型数据支持的战略集合。它是单个数据存储,出于分析性报告和决策支持目的而创建。 为需要业务智能的企业,提供指导业务流程改进、监视时间、成本、质量以及控制。

资源

HBase 运维

配置文件

  • backup-masters - 默认情况下不存在。列出主服务器应在其上启动备份主进程的主机,每行一个主机。
  • hadoop-metrics2-hbase.properties - 用于连接 HBase Hadoop 的 Metrics2 框架。
  • hbase-env.cmd and hbase-env.sh - 用于 Windows 和 Linux / Unix 环境的脚本,用于设置 HBase 的工作环境,包括 Java,Java 选项和其他环境变量的位置。
  • hbase-policy.xml - RPC 服务器用于对客户端请求进行授权决策的默认策略配置文件。仅在启用 HBase 安全性时使用。
  • hbase-site.xml - 主要的 HBase 配置文件。此文件指定覆盖 HBase 默认配置的配置选项。您可以在 docs / hbase-default.xml 中查看(但不要编辑)默认配置文件。您还可以在 HBase Web UI 的 HBase 配置选项卡中查看群集的整个有效配置(默认值和覆盖)。
  • log4j.properties - log4j 日志配置。
  • regionservers - 包含应在 HBase 集群中运行 RegionServer 的主机列表。默认情况下,此文件包含单个条目 localhost。它应包含主机名或 IP 地址列表,每行一个,并且如果群集中的每个节点将在其 localhost 接口上运行 RegionServer,则应仅包含 localhost。

环境要求

  • Java
    • HBase 2.0+ 要求 JDK8+
    • HBase 1.2+ 要求 JDK7+
  • SSH - 环境要支持 SSH
  • DNS - 环境中要在 hosts 配置本机 hostname 和本机 IP
  • NTP - HBase 集群的时间要同步,可以配置统一的 NTP
  • 平台 - 生产环境不推荐部署在 Windows 系统中
  • Hadoop - 依赖 Hadoop 配套版本
  • Zookeeper - 依赖 Zookeeper 配套版本

运行模式

单点

hbase-site.xml 配置如下:

1
2
3
4
5
6
7
8
9
10
<configuration>
<property>
<name>hbase.rootdir</name>
<value>hdfs://namenode.example.org:8020/hbase</value>
</property>
<property>
<name>hbase.cluster.distributed</name>
<value>false</value>
</property>
</configuration>

分布式

hbase-site.xm 配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<configuration>
<property>
<name>hbase.rootdir</name>
<value>hdfs://namenode.example.org:8020/hbase</value>
</property>
<property>
<name>hbase.cluster.distributed</name>
<value>true</value>
</property>
<property>
<name>hbase.zookeeper.quorum</name>
<value>node-a.example.com,node-b.example.com,node-c.example.com</value>
</property>
</configuration>

引用和引申

扩展阅读

Java 并发简介

摘要 - 并发编程并非 Java 语言所独有,而是一种成熟的编程范式,Java 只是用自己的方式实现了并发工作模型。学习 Java 并发编程,应该先熟悉并发的基本概念,然后进一步了解并发的特性以及其特性所面临的问题。掌握了这些,当学习 Java 并发工具时,才会明白它们各自是为了解决什么问题,为什么要这样设计。通过这样由点到面的学习方式,更容易融会贯通,将并发知识形成体系化。

什么是并发

技术在进步,CPU、内存、I/O 设备的性能也在不断提高。但是,始终存在一个核心矛盾:CPU、内存、I/O 设备存在很大的速度差异 - CPU 远快于内存,内存远快于 I/O 设备。木桶短板理论告诉我们:一只木桶能装多少水,取决于最短的那块木板。同理,程序整体性能取决于最慢的操作(即 I/O 操作),所以单方面提高 CPU、内存的性能是无效的。

为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:

  • CPU 增加了缓存,以均衡与 CPU 内存的速度差异;
  • 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 的速度差异;
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

其中,进程、线程使得计算机、程序有了并发处理任务的能力。并发是指具备处理多个任务的能力,但不一定要同时。

并发的优点

并发所带来的好处有:

  • 提升资源利用率
  • 程序响应更快

提升资源利用率

想象一下,一个应用程序需要从本地文件系统中读取和处理文件的情景。比方说,从磁盘读取一个文件需要 5 秒,处理一个文件需要 2 秒。处理两个文件则需要:

1
2
3
4
5
6
5 秒读取文件 A
2 秒处理文件 A
5 秒读取文件 B
2 秒处理文件 B
---------------------
总共需要 14 秒

从磁盘中读取文件的时候,大部分的 CPU 时间用于等待磁盘去读取数据。在这段时间里,CPU 非常的空闲。它可以做一些别的事情。通过改变操作的顺序,就能够更好的使用 CPU 资源。看下面的顺序:

1
2
3
4
5
5 秒读取文件 A
5 秒读取文件 B + 2 秒处理文件 A
2 秒处理文件 B
---------------------
总共需要 12 秒

CPU 等待第一个文件被读取完。然后开始读取第二个文件。当第二文件在被读取的时候,CPU 会去处理第一个文件。记住,在等待磁盘读取文件的时候,CPU 大 部分时间是空闲的。

总的说来,CPU 能够在等待 IO 的时候做一些其他的事情。这个不一定就是磁盘 IO。它也可以是网络的 IO,或者用户输入。通常情况下,网络和磁盘的 IO 比 CPU 和内存的 IO 慢的多。

程序响应更快

将一个单线程应用程序变成多线程应用程序的另一个常见的目的是实现一个响应更快的应用程序。设想一个服务器应用,它在某一个端口监听进来的请求。当一个请求到来时,它去处理这个请求,然后再返回去监听。

服务器的流程如下所述:

1
2
3
4
while(server is active) {
listen for request
process request
}

如果一个请求需要占用大量的时间来处理,在这段时间内新的客户端就无法发送请求给服务端。只有服务器在监听的时候,请求才能被接收。另一种设计是,监听线程把请求传递给工作者线程 (worker thread),然后立刻返回去监听。而工作者线程则能够处理这个请求并发送一个回复给客户端。这种设计如下所述:

1
2
3
4
while(server is active) {
listen for request
hand request to worker thread
}

这种方式,服务端线程迅速地返回去监听。因此,更多的客户端能够发送请求给服务端。这个服务也变得响应更快。

桌面应用也是同样如此。如果你点击一个按钮开始运行一个耗时的任务,这个线程既要执行任务又要更新窗口和按钮,那么在任务执行的过程中,这个应用程序看起来好像没有反应一样。相反,任务可以传递给工作者线程(worker thread)。当工作者线程在繁忙地处理任务的时候,窗口线程可以自由地响应其他用户的请求。当工作者线程完成任务的时候,它发送信号给窗口线程。窗口线程便可以更新应用程序窗口,并显示任务的结果。对用户而言,这种具有工作者线程设计的程序显得响应速度更快。

任何事物都有利弊,并发也不例外。我们知道了并发带来的好处:提升资源利用率、程序响应更快,同时也要认识到并发带来的问题,主要有:

  • 安全性问题
  • 活跃性问题
  • 性能问题

下面会一一讲解。

安全性问题

并发最重要的问题是并发安全问题。所谓并发安全,是指保证程序的正确性,使得并发处理结果符合预期。

并发安全需要保证几个基本特性:

  • 可见性 - 是一个线程修改了某个共享变量,其状态能够立即被其他线程知晓,通常被解释为将线程本地状态反映到主内存上,volatile 就是负责保证可见性的。
  • 原子性 - 简单说就是相关操作不会中途被其他线程干扰,一般通过同步机制(加锁:sychronizedLock)实现。
  • 有序性 - 是保证线程内串行语义,避免指令重排等。

缓存导致的可见性问题

一个线程对共享变量的修改,另外一个线程能够立刻看到,称为 可见性

在单核时代,所有的线程都是在一颗 CPU 上执行,CPU 缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个 CPU 的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。例如在下面的图中,线程 A 和线程 B 都是操作同一个 CPU 里面的缓存,所以线程 A 更新了变量 V 的值,那么线程 B 之后再访问变量 V,得到的一定是 V 的最新值(线程 A 写过的值)。

多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。比如下图中,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存,很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。

::: tabs#计数器示例

@tab 线程不安全的计数器

【示例】线程不安全的计数器示例 ❌

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@NotThreadSafe
public class NotThreadSafeCounter {

private static long count = 0;

private void add() {
int cnt = 0;
while (cnt++ < 100000) {
count += 1;
}
}

public static void main(String[] args) throws InterruptedException {
final NotThreadSafeCounter demo = new NotThreadSafeCounter();
// 创建两个线程,执行 add() 操作
Thread t1 = new Thread(() -> {
demo.add();
});
Thread t2 = new Thread(() -> {
demo.add();
});
// 启动两个线程
t1.start();
t2.start();
// 等待两个线程执行结束
t1.join();
t2.join();
System.out.println("count = " + count);
}

}
// 输出:
// count = 156602
// 实际结果总是会小于预期值 200000

这段程序的目的是将 count 变量累加到 100000,两个线程执行,则应该累加到 200000,但实际结果总是会小于预期值 200000。

假设线程 A 和线程 B 同时开始执行,那么第一次都会将 count=0 读到各自的 CPU 缓存里,执行完 count+=1 之后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2。之后由于各自的 CPU 缓存里都有了 count 的值,两个线程都是基于 CPU 缓存里的 count 值来计算,所以导致最终 count 的值都是小于 20000 的。这就是缓存的可见性问题。

@tab 线程安全的计数器

【示例】线程安全的计数器示例 ✔

针对上面线程不安全的计数器最简单的改造方法就是在 add() 方法上增加 synchronized 锁,如下所示:

1
2
3
4
5
6
7
8
9
10
@ThreadSafe
public class ThreadSafeCounter {
private synchronized void add() {
int cnt = 0;
while (cnt++ < 100000) {
count += 1;
}
}
// 省略
}

:::

线程切换带来的原子性问题

由于 IO 太慢,早期的操作系统就发明了多进程。CPU 会给各个程序分配一个允许执行时间段,即时间片。从表面上看,各程序是同时运行的;实际上, 如果在时间片结束时进程还在运行,则 CPU 将被剥夺并分配给另一个进程。 如果进程在时间片结束前阻塞或结束,则 CPU 当即进行切换(称为“任务切换”)。

Java 的并发也是基于任务切换。Java 中,即使是一条语句,也可能需要执行多条 CPU 指令。一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性

CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符。违背直觉的是,高级语言里一条语句往往需要多条 CPU 指令完成,例如上面代码中的count += 1,至少需要三条 CPU 指令。

  • 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
  • 指令 2:之后,在寄存器中执行+1 操作;
  • 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

因此,执行 count += 1 不是原子操作。

编译优化带来的有序性问题

有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:a=6; b=7; 编译器优化后可能变成 b=7; a=6;,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的 Bug。

在 Java 领域一个经典的案例就是利用双重检查创建单例对象。

【示例】双重检查创建单例对象

1
2
3
4
5
6
7
8
9
10
11
12
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}

假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现 instance == null ,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。

这看上去一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢?出在 new 操作上,我们以为的 new 操作应该是:

  1. 分配一块内存 M;
  2. 在内存 M 上初始化 Singleton 对象;
  3. 然后 M 的地址赋值给 instance 变量。

但是实际上优化后的执行路径却是这样的:

  1. 分配一块内存 M;
  2. 将 M 的地址赋值给 instance 变量;
  3. 最后在内存 M 上初始化 Singleton 对象。

优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。

保证并发安全的思路

互斥同步(阻塞同步)

互斥同步是最常见的并发正确性保障手段。

同步是指在多线程并发访问共享数据时,保证共享数据在同一时刻只能被一个线程访问

互斥是实现同步的一种手段。临界区(Critical Sections)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式。

最典型的案例是使用 synchronizedLock

互斥同步最主要的问题是线程阻塞和唤醒所带来的性能问题,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。

非阻塞同步

随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略:先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。

为什么说乐观锁需要 硬件指令集的发展 才能进行?因为需要操作和冲突检测这两个步骤具备原子性。而这点是由硬件来完成,如果再使用互斥同步来保证就失去意义了。

这类乐观锁指令常见的有:

  • 测试并设置(Test-and-Set)
  • 获取并增加(Fetch-and-Increment)
  • 交换(Swap)
  • 比较并交换(CAS)
  • 加载链接、条件存储(Load-linked / Store-Conditional)

Java 典型应用场景:J.U.C 包中的原子类(基于 Unsafe 类的 CAS 操作)

无同步

要保证线程安全,不一定非要进行同步。同步只是保证共享数据争用时的正确性,如果一个方法本来就不涉及共享数据,那么自然无须同步。

Java 中的 无同步方案 有:

  • 可重入代码 - 也叫纯代码。如果一个方法,它的 返回结果是可以预测的,即只要输入了相同的数据,就能返回相同的结果,那它就满足可重入性,当然也是线程安全的。
  • 线程本地存储 - 使用 ThreadLocal 为共享变量在每个线程中都创建了一个本地副本,这个副本只能被当前线程访问,其他线程无法访问,那么自然是线程安全的。

活跃性问题

程序运行时,当某个操作无法继续执行下去时,就会产生活跃性问题。

对于串行程序,活跃性问题的常见形式是无意中造成的死循环,使得循环之后的代码无法执行。

对于并发程序,会有一些其他的活跃性问题,常见形式有:

  • 死锁
  • 活锁
  • 饥饿

死锁(Deadlock)

什么是死锁

死锁一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象

死锁是一种特定的程序状态,在实体之间,由于循环依赖导致彼此一直处于等待之中,没有任何个体可以继续前进。死锁不仅仅是在线程之间会发生,存在资源独占的进程之间同样也可能出现死锁。通常来说,我们大多是聚焦在多线程场景中的死锁,指两个或多个线程之间,由于互相持有对方需要的锁,而永久处于阻塞的状态。

【示例】存在死锁的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
// 锁定转出账户
synchronized(this) {
// 锁定转入账户
synchronized(target) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
}

如何定位死锁

定位死锁最常见的方式就是利用 jstack 等工具获取线程栈,然后定位互相之间的依赖关系,进而找到死锁。如果是比较明显的死锁,往往 jstack 等就能直接定位,类似 JConsole 甚至可以在图形界面进行有限的死锁检测。

如果我们是开发自己的管理工具,需要用更加程序化的方式扫描服务进程、定位死锁,可以考虑使用 Java 提供的标准管理 API,ThreadMXBean,其直接就提供了 findDeadlockedThreads() 方法用于定位。

如何避免死锁

只有以下这四个条件都发生时才会出现死锁:

  • 互斥,共享资源 X 和 Y 只能被一个线程占用;
  • 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
  • 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
  • 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。

也就是说只要破坏任意一个,就可以避免死锁的发生

其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?

  1. 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
  2. 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。超时释放锁
  3. 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。

活锁(Livelock)

什么是活锁

活锁是一个递归的情况,两个或更多的线程会不断重复一个特定的代码逻辑。预期的逻辑通常为其他线程提供机会继续支持’this’线程。

想象这样一个例子:两个人在狭窄的走廊里相遇,二者都很礼貌,试图移到旁边让对方先通过。但是他们最终在没有取得任何进展的情况下左右摇摆,因为他们都在同一时间向相同的方向移动。

如图所示:两个线程想要通过一个 Worker 对象访问共享公共资源的情况,但是当他们看到另一个 Worker(在另一个线程上调用)也是“活动的”时,它们会尝试将该资源交给其他工作者并等待为它完成。如果最初我们让两名工作人员都活跃起来,他们将会面临活锁问题。

避免活锁

解决“活锁”的方案很简单,谦让时,尝试等待一个随机的时间就可以了。由于等待的时间是随机的,所以同时相撞后再次相撞的概率就很低了。“等待一个随机时间”的方案虽然很简单,却非常有效,Raft 这样知名的分布式一致性算法中也用到了它。

饥饿(Starvation)

什么是饥饿

  • 高优先级线程吞噬所有的低优先级线程的 CPU 时间。
  • 线程被永久堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前持续地对该同步块进行访问。
  • 线程在等待一个本身(在其上调用 wait()) 也处于永久等待完成的对象,因为其他线程总是被持续地获得唤醒。

饥饿问题最经典的例子就是哲学家问题。如图所示:有五个哲学家用餐,每个人要获得两支筷子才可以就餐。当 2、4 就餐时,1、3、5 永远无法就餐,只能看着盘中的美食饥饿的等待着。

解决饥饿

Java 不可能实现 100% 的公平性,我们依然可以通过同步结构在线程间实现公平性的提高。

有三种方案:

  • 保证资源充足
  • 公平地分配资源
  • 避免持有锁的线程长时间执行

这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。

那如何公平地分配资源呢?在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。

性能问题

并发执行一定比串行执行快吗?线程越多执行越快吗?

答案是:并发不一定比串行快。因为并发过程中,有创建线程和线程上下文切换的开销。

上下文切换

当 CPU 从执行一个线程切换到执行另一个线程时,CPU 需要保存当前线程的本地数据,程序指针等状态,并加载下一个要执行的线程的本地数据,程序指针等。这个开关被称为上下文切换(Context Switch)

如果频繁地出现上下文切换,将带来极大的开销:恢复执行上下文,丢失局部性,并且 CPU 时间将更多地花在线程调度而不是线程运行上。当线程共享数据时,必须使用同步机制,而这些机制往往会抑制某些编译器优化,使内存缓存区中的数据无效,以及增加共享内存总线的同步流量。所有这些因素都会产生额外的性能开销。

减少上下文切换的方法:

  • 无锁并发编程 - 多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的 ID 按照 Hash 算法取模分段,不同的线程处理不同段的数据。
  • CAS 算法 - Java 的 Atomic 包使用 CAS 算法来更新数据,而不需要加锁。
  • 使用最少线程 - 避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
  • 使用协程 - 在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

资源限制

程序的执行速度受限于计算机硬件资源或软件资源。在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行。但是,如果将某段串行的代码并发执行,因为受限于资源仍然在串行执行,这时候程序不仅不会加快执行,反而会更慢,因为增加了上下文切换和资源调度的时间。

如何解决资源限制的问题呢?在资源受限的情况下,可以根据不同的资源限制调整程序的并发度:

  • 对于硬件资源限制,可以考虑使用集群并行执行程序。
  • 对于软件资源限制,可以考虑使用资源池将资源复用。

并发编程

并发编程可以抽象成三个核心问题:分工、同步、互斥。

  • 分工 - 是指如何高效地拆解任务并分配给线程。
  • 同步 - 是指线程之间如何协作。
  • 互斥 - 是指保证同一时刻只允许一个线程访问共享资源。

J.U.C 简介

Java 的 java.util.concurrent 包(简称 J.U.C)中提供了大量并发工具类,是 Java 并发能力的主要体现(注意,不是全部,有部分并发能力的支持在其他包中)。从功能上,大致可以分为:

  • 原子类 - 如:AtomicIntegerAtomicIntegerArrayAtomicReferenceAtomicStampedReference 等。
  • - 如:ReentrantLockReentrantReadWriteLock 等。
  • 并发容器 - 如:ConcurrentHashMapCopyOnWriteArrayListCopyOnWriteArraySet 等。
  • 阻塞队列 - 如:ArrayBlockingQueueLinkedBlockingQueue 等。
  • 非阻塞队列 - 如: ConcurrentLinkedQueueLinkedTransferQueue 等。
  • 线程池 - 如:ThreadPoolExecutorExecutors 等。

J.U.C 包中的工具类是基于 synchronizedvolatileCASThreadLocal 这样的并发核心机制打造的。所以,要想深入理解 J.U.C 工具类的特性、为什么具有这样那样的特性,就必须先理解这些核心机制。

并发术语

并发编程中有很多术语概念相近,容易让人混淆。本节内容通过对比分析,力求让读者清晰理解其概念以及差异。

串行、并行、并发

并发和并行是最容易让新手费解的概念,那么如何理解二者呢?其最关键的差异在于:是否是同时发生:

  • 串行 - 是指任务按照顺序依次执行,每个任务在前一个任务完成后才能开始执行。
  • 并行 - 是指具备同时处理多个任务的能力
  • 并发 - 是指具备处理多个任务的能力,但不一定要同时

下面是我见过最生动的说明,摘自 并发与并行的区别是什么?——知乎的高票答案

  • 你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。
  • 你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。
  • 你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。

同步和异步

  • 同步 - 是指在发出一个调用时,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了。
  • 异步 - 则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。

举例来说明:

  • 同步就像是打电话:不挂电话,通话不会结束。
  • 异步就像是发短信:发完短信后,就可以做其他事;当收到回复短信时,手机会通过铃声或振动来提醒。

阻塞和非阻塞

阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态:

  • 阻塞 - 是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
  • 非阻塞 - 是指在不能立刻得到结果之前,该调用不会阻塞当前线程

举例来说明:

  • 阻塞调用就像是打电话,通话不结束,不能放下。
  • 非阻塞调用就像是发短信,发完短信后,就可以做其他事,短信来了,手机会提醒。

进程、线程、管程、协程

  • 进程(Process) - 进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动。进程是操作系统进行资源分配的基本单位。进程可视为一个正在运行的程序
  • 线程(Thread) - 线程是操作系统进行调度的基本单位
  • 管程(Monitor) - 管程是指管理共享变量以及对共享变量的操作过程,让他们支持并发
    • Java 通过 synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法来实现管程技术。
    • 管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程
  • 协程(Coroutine) - 协程可以理解为一种轻量级的线程
    • 从操作系统的角度来看,线程是在内核态中调度的,而协程是在用户态调度的,所以相对于线程来说,协程切换的成本更低。
    • 协程虽然也有自己的栈,但是相比线程栈要小得多,典型的线程栈大小差不多有 1M,而协程栈的大小往往只有几 K 或者几十 K。所以,无论是从时间维度还是空间维度来看,协程都比线程轻量得多。
    • Go、Python、Lua、Kotlin 等语言都支持协程;Java OpenSDK 中的 Loom 项目目标就是支持协程。

进程和线程的差异:

  • 一个程序至少有一个进程,一个进程至少有一个线程。
  • 线程比进程划分更细,所以执行开销更小,并发性更高
  • 进程是一个实体,拥有独立的资源;而同一个进程中的多个线程共享进程的资源。

img

JVM 在单个进程中运行,JVM 中的线程共享属于该进程的堆。这就是为什么几个线程可以访问同一个对象。线程共享堆并拥有自己的堆栈空间。这是一个线程如何调用一个方法以及它的局部变量是如何保持线程安全的。但是堆不是线程安全的并且为了线程安全必须进行同步。

竞态条件和临界区

  • 竞态条件(Race Condition) - 程序的执行结果依赖多线程执行的顺序。通俗的说,即多个线程竞争访问同一个资源
  • 临界区(Critical Sections) - 指的是访问共享资源的程序片段

参考资料

深入理解 Java 异常

img

异常框架

Throwable

Throwable 是 Java 语言中所有错误(Error)和异常(Exception)的超类。在 Java 中只有 Throwable 类型的实例才可以被抛出(throw)或者捕获(catch),它是异常处理机制的基本组成类型。

Throwable 包含了其线程创建时线程执行堆栈的快照,它提供了 printStackTrace() 等接口用于获取堆栈跟踪数据等信息。

主要方法:

  • fillInStackTrace - 用当前的调用栈层次填充 Throwable 对象栈层次,添加到栈层次任何先前信息中。
  • getMessage - 返回关于发生的异常的详细信息。这个消息在 Throwable 类的构造函数中初始化了。
  • getCause - 返回一个 Throwable 对象代表异常原因。
  • getStackTrace - 返回一个包含堆栈层次的数组。下标为 0 的元素代表栈顶,最后一个元素代表方法调用堆栈的栈底。
  • printStackTrace - 打印 toString() 结果和栈层次到 System.err,即错误输出流。
  • toString - 使用 getMessage 的结果返回代表 Throwable 对象的字符串。

Error

ErrorThrowable 的一个子类。**Error 表示正常情况下,不大可能出现的严重问题编译器不会检查 Error**。绝大部分的 Error 都会导致程序(比如 JVM 自身)处于非正常的、不可恢复状态。既然是非正常情况,所以不便于也不需要捕获,常见的比如 OutOfMemoryError 之类,都是 Error 的子类。

常见 Error

  • AssertionError - 断言错误。
  • VirtualMachineError - 虚拟机错误。
  • UnsupportedClassVersionError - Java 类版本错误。
  • StackOverflowError - 栈溢出错误。
  • OutOfMemoryError - 内存溢出错误。

Exception

ExceptionThrowable 的一个子类。**Exception 表示合理的应用程序可能想要捕获的条件。**Exception 是程序正常运行中,可以预料的意外情况,可能并且应该被捕获,进行相应处理。

Exception 又分为可检查(checked)异常和不检查(unchecked)异常,可检查异常在源代码里必须显式地进行捕获处理,这是编译期检查的一部分。

编译器会检查 Exception 异常。此类异常,要么通过 throws 进行声明抛出,要么通过 try catch 进行捕获处理,否则不能通过编译。

常见 Exception

  • ClassNotFoundException - 应用程序试图加载类时,找不到相应的类,抛出该异常。
  • CloneNotSupportedException - 当调用 Object 类中的 clone 方法克隆对象,但该对象的类无法实现 Cloneable 接口时,抛出该异常。
  • IllegalAccessException - 拒绝访问一个类的时候,抛出该异常。
  • InstantiationException - 当试图使用 Class 类中的 newInstance 方法创建一个类的实例,而指定的类对象因为是一个接口或是一个抽象类而无法实例化时,抛出该异常。
  • InterruptedException - 一个线程被另一个线程中断,抛出该异常。
  • NoSuchFieldException - 请求的变量不存在。
  • NoSuchMethodException - 请求的方法不存在。

【示例】Exception 示例

1
2
3
4
5
public class ExceptionDemo {
public static void main(String[] args) {
Method method = String.class.getMethod("toString", int.class);
}
};

试图编译运行时会报错:

1
Error:(7, 47) java: 未报告的异常错误java.lang.NoSuchMethodException; 必须对其进行捕获或声明以便抛出

RuntimeException

RuntimeExceptionException 的一个子类。RuntimeException 是那些可能在 Java 虚拟机正常运行期间抛出的异常的超类。

编译器不会检查 RuntimeException 异常。当程序中可能出现这类异常时,倘若既没有通过 throws 声明抛出它,也没有用 try catch 语句捕获它,程序还是会编译通过。

【示例】RuntimeException 示例

1
2
3
4
5
6
7
8
public class RuntimeExceptionDemo {
public static void main(String[] args) {
// 此处产生了异常
int result = 10 / 0;
System.out.println("两个数字相除的结果:" + result);
System.out.println("----------------------------");
}
};

运行时输出:

1
2
Exception in thread "main" java.lang.ArithmeticException: / by zero
at io.github.dunwu.javacore.exception.RumtimeExceptionDemo01.main(RumtimeExceptionDemo01.java:6)

常见 RuntimeException

  • ArrayIndexOutOfBoundsException - 用非法索引访问数组时抛出的异常。如果索引为负或大于等于数组大小,则该索引为非法索引。
  • ArrayStoreException - 试图将错误类型的对象存储到一个对象数组时抛出的异常。
  • ClassCastException - 当试图将对象强制转换为不是实例的子类时,抛出该异常。
  • IllegalArgumentException - 抛出的异常表明向方法传递了一个不合法或不正确的参数。
  • IllegalMonitorStateException - 抛出的异常表明某一线程已经试图等待对象的监视器,或者试图通知其他正在等待对象的监视器而本身没有指定监视器的线程。
  • IllegalStateException - 在非法或不适当的时间调用方法时产生的信号。换句话说,即 Java 环境或 Java 应用程序没有处于请求操作所要求的适当状态下。
  • IllegalThreadStateException - 线程没有处于请求操作所要求的适当状态时抛出的异常。
  • IndexOutOfBoundsException - 指示某排序索引(例如对数组、字符串或向量的排序)超出范围时抛出。
  • NegativeArraySizeException - 如果应用程序试图创建大小为负的数组,则抛出该异常。
  • NullPointerException - 当应用程序试图在需要对象的地方使用 null 时,抛出该异常
  • NumberFormatException - 当应用程序试图将字符串转换成一种数值类型,但该字符串不能转换为适当格式时,抛出该异常。
  • SecurityException - 由安全管理器抛出的异常,指示存在安全侵犯。
  • StringIndexOutOfBoundsException - 此异常由 String 方法抛出,指示索引或者为负,或者超出字符串的大小。
  • UnsupportedOperationException - 当不支持请求的操作时,抛出该异常。

自定义异常

img

自定义一个异常类,只需要继承 ExceptionRuntimeException 即可。

【示例】自定义异常示例

1
2
3
4
5
6
7
8
9
10
11
public class MyExceptionDemo {
public static void main(String[] args) {
throw new MyException("自定义异常");
}

static class MyException extends RuntimeException {
public MyException(String message) {
super(message);
}
}
}

输出:

1
2
Exception in thread "main" io.github.dunwu.javacore.exception.MyExceptionDemo$MyException: 自定义异常
at io.github.dunwu.javacore.exception.MyExceptionDemo.main(MyExceptionDemo.java:9)

抛出异常

如果想在程序中明确地抛出异常,需要用到 throwthrows

如果一个方法没有捕获一个检查性异常,那么该方法必须使用 throws 关键字来声明。throws 关键字放在方法签名的尾部。

【示例】throw 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ThrowDemo {
public static void f() {
try {
throw new RuntimeException("抛出一个异常");
} catch (Exception e) {
System.out.println(e);
}
}

public static void main(String[] args) {
f();
}
};

输出:

1
java.lang.RuntimeException: 抛出一个异常

也可以使用 throw 关键字抛出一个异常,无论它是新实例化的还是刚捕获到的。

【示例】throws 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class ThrowsDemo {
public static void f1() throws NoSuchMethodException, NoSuchFieldException {
Field field = Integer.class.getDeclaredField("digits");
if (field != null) {
System.out.println("反射获取 digits 方法成功");
}
Method method = String.class.getMethod("toString", int.class);
if (method != null) {
System.out.println("反射获取 toString 方法成功");
}
}

public static void f2() {
try {
// 调用 f1 处,如果不用 try catch ,编译时会报错
f1();
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (NoSuchFieldException e) {
e.printStackTrace();
}
}

public static void main(String[] args) {
f2();
}
};

输出:

1
2
3
4
5
6
// 反射获取 digits 方法成功
java.lang.NoSuchMethodException: java.lang.String.toString(int)
at java.lang.Class.getMethod(Class.java:1786)
at io.github.dunwu.javacore.exception.ThrowsDemo.f1(ThrowsDemo.java:12)
at io.github.dunwu.javacore.exception.ThrowsDemo.f2(ThrowsDemo.java:21)
at io.github.dunwu.javacore.exception.ThrowsDemo.main(ThrowsDemo.java:30)

throwthrows 的区别:

  • throws 使用在函数上,throw 使用在函数内。
  • throws 后面跟异常类,可以跟多个,用逗号区别;throw 后面跟的是异常对象。

捕获异常

使用 try 和 catch 关键字可以捕获异常try catch 代码块放在异常可能发生的地方。

它的语法形式如下:

1
2
3
4
5
6
7
8
9
try {
// 可能会发生异常的代码块
} catch (Exception e1) {
// 捕获并处理try抛出的异常类型Exception
} catch (Exception2 e2) {
// 捕获并处理try抛出的异常类型Exception2
} finally {
// 无论是否发生异常,都将执行的代码块
}

此外,JDK7 以后,catch 多种异常时,也可以像下面这样简化代码:

1
2
3
4
5
6
7
try {
// 可能会发生异常的代码块
} catch (Exception | Exception2 e) {
// 捕获并处理try抛出的异常类型
} finally {
// 无论是否发生异常,都将执行的代码块
}

trycatchfinally 使用要点如下:

  • try - try 语句用于监听。将要被监听的代码(可能抛出异常的代码)放在 try 语句块之内,当 try 语句块内发生异常时,异常就被抛出。

  • catch - catch 语句包含要捕获异常类型的声明。当保护代码块中发生一个异常时,try 后面的 catch 块就会被检查。

  • finally - finally 语句块总是会被执行,无论是否出现异常。try catch 语句后不一定非要 finally 语句。finally 常用于这样的场景:由于 finally 语句块总是会被执行,所以那些在 try 代码块中打开的,并且必须回收的物理资源(如数据库连接、网络连接和文件),一般会放在 finally 语句块中释放资源。

  • trycatchfinally 三个代码块中的局部变量不可共享使用

  • catch 块尝试捕获异常时,是按照 catch 块的声明顺序依次寻找的,一旦匹配,就不会再向下执行。因此,如果同一个 try 块下的多个 catch 异常类型有父子关系,应该将子类异常放在前面,父类异常放在后面。

【示例】trycatchfinally 使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class TryCatchFinallyDemo {
public static void main(String[] args) {
try {
// 此处产生了异常
int temp = 10 / 0;
System.out.println("两个数字相除的结果:" + temp);
System.out.println("----------------------------");
} catch (ArithmeticException e) {
System.out.println("出现异常了:" + e);
} finally {
System.out.println("不管是否出现异常,都执行此代码");
}
}
};

运行时输出:

1
2
// 出现异常了:java.lang.ArithmeticException: / by zero
// 不管是否出现异常,都执行此代码

异常链

异常链是以一个异常对象为参数构造新的异常对象,新的异常对象将包含先前异常的信息。

通过使用异常链,我们可以提高代码的可理解性、系统的可维护性和友好性。

我们有两种方式处理异常,一是 throws 抛出交给上级处理,二是 try…catch 做具体处理。try…catchcatch 块我们可以不需要做任何处理,仅仅只用 throw 这个关键字将我们封装异常信息主动抛出来。然后在通过关键字 throws 继续抛出该方法异常。它的上层也可以做这样的处理,以此类推就会产生一条由异常构成的异常链。

【示例】异常链示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class ExceptionChainDemo {
static class MyException1 extends Exception {
public MyException1(String message) {
super(message);
}
}

static class MyException2 extends Exception {
public MyException2(String message, Throwable cause) {
super(message, cause);
}
}

public static void f1() throws MyException1 {
throw new MyException1("出现 MyException1");
}

public static void f2() throws MyException2 {
try {
f1();
} catch (MyException1 e) {
throw new MyException2("出现 MyException2", e);
}
}

public static void main(String[] args) throws MyException2 {
f2();
}
}

输出:

1
2
3
4
5
6
7
Exception in thread "main" io.github.dunwu.javacore.exception.ExceptionChainDemo$MyException2: 出现 MyException2
at io.github.dunwu.javacore.exception.ExceptionChainDemo.f2(ExceptionChainDemo.java:29)
at io.github.dunwu.javacore.exception.ExceptionChainDemo.main(ExceptionChainDemo.java:34)
Caused by: io.github.dunwu.javacore.exception.ExceptionChainDemo$MyException1: 出现 MyException1
at io.github.dunwu.javacore.exception.ExceptionChainDemo.f1(ExceptionChainDemo.java:22)
at io.github.dunwu.javacore.exception.ExceptionChainDemo.f2(ExceptionChainDemo.java:27)
... 1 more

扩展阅读:https://juejin.im/post/5b6d61e55188251b38129f9a#heading-10

这篇文章中对于异常链讲解比较详细。

异常注意事项

finally 覆盖异常

Java 异常处理中 finally 中的 return 会覆盖 catch 代码块中的 return 语句和 throw 语句,所以不建议在 finally 中使用 return 语句

此外 finally 中的 throw 语句也会覆盖 catch 代码块中的 return 语句和 throw 语句。

【示例】finally 覆盖示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class FinallyOverrideExceptionDemo {
static void f() throws Exception {
try {
throw new Exception("A");
} catch (Exception e) {
throw new Exception("B");
} finally {
throw new Exception("C");
}
}

public static void main(String[] args) {
try {
f();
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
}
// 输出:C

覆盖抛出异常的方法

当子类重写父类带有 throws 声明的函数时,其 throws 声明的异常必须在父类异常的可控范围内;用于处理父类的 throws 方法的异常处理器,必须也适用于子类的这个带 throws 方法——这是为了支持多态。

【示例】覆盖抛出异常示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class ExceptionOverrideDemo {
static class Father {
public void start() throws IOException {
throw new IOException();
}
}

static class Son extends Father {
@Override
public void start() throws SQLException {
throw new SQLException();
}
}

public static void main(String[] args) {
Father obj1 = new Father();
Father obj2 = new Son();
try {
obj1.start();
obj2.start();
} catch (IOException e) {
e.printStackTrace();
}
}
}

上面的示例编译时会报错,原因在于:

因为 Son 类抛出异常的实质是 SQLException,而 IOException 无法处理它。那么这里的 try catch 就不能处理 Son 中的异常了。多态就不能实现了。

异常和线程

如果 Java 程序只有一个线程,那么没有被任何代码处理的异常会导致程序终止。如果 Java 程序是多线程的,那么没有被任何代码处理的异常仅仅会导致异常所在的线程结束。

最佳实践

  • 对可恢复的情况使用检查性异常 Exception;对编程错误使用运行时异常RuntimeException
  • 优先使用 Java 标准的异常。
  • 抛出与抽象相对应的异常。
  • 在细节消息中包含能捕获失败的信息。
  • 尽可能减少 try 代码块的大小。
  • 尽量缩小异常范围。例如,如果明知尝试捕获的是一个 ArithmeticException,就应该 catch ArithmeticException,而不是 catch 范围较大的 RuntimeException,甚至是 Exception
  • 尽量不要在 finally 块抛出异常或者返回值。
  • 不要忽略异常,一旦捕获异常,就应该处理,而非丢弃。
  • 异常处理效率很低,所以不要用异常进行业务逻辑处理。
  • 各类异常必须要有单独的日志记录,将异常分级,分类管理,因为有的时候仅仅想给第三方运维看到逻辑异常,而不是更细节的信息。如何对异常进行分类:
    • 逻辑异常 - 这类异常用于描述业务无法按照预期的情况处理下去,属于用户制造的意外。
    • 代码错误 - 这类异常用于描述开发的代码错误,例如 NPE,ILLARG,都属于程序员制造的 BUG。
    • 专有异常 - 多用于特定业务场景,用于描述指定作业出现意外情况无法预先处理。

扩展阅读:

参考资料

深入理解 Java 基本数据类型

img

数据类型分类

Java 中的数据类型有两类:

  • 值类型(又叫内置数据类型,基本数据类型)
  • 引用类型(除值类型以外,都是引用类型,包括 String、数组等)

值类型

Java 语言提供了 8 种基本类型,大致分为 4 类:布尔型、字符型、整数型、浮点型。

基本数据类型 分类 大小 默认值 取值范围 包装类 说明
boolean 布尔型 - false false、true Boolean boolean 的大小,是由具体的JVM实现来决定的
char 字符型 16 bit 'u0000' 0 ~ 65535($2^{16} - 1$) Character 存储 Unicode 码,用单引号赋值
byte 整数型 8 bit 0 -128(-$2^7$) ~ 127($2^7 - 1$) Byte
short 整数型 16 bit 0 -32768(-$2^{15}$) ~ 32767($2^{15} - 1$) Short
int 整数型 32 bit 0 -$2^{31}$ ~ $2^{31} - 1$ Integer
long 整数型 64 bit 0L -$2^{63}$ ~ $2^{63} - 1$ Long 赋值时一般在数字后加上 lL
float 浮点型 32 bit 0.0f 1.4e-45f ~ 3.4028235e+38f Float 赋值时必须在数字后加上 fF
double 浮点型 64 bit 0.0d 4.9e-324 ~ 1.7976931348623157e+308 Double 赋值时一般在数字后加 dD

byteshortintlong 的最高比特位都用于表示正负(0 为正,-1 为负)。

值类型和引用类型的区别

值类型 引用类型
用途 一般用于常量和局部变量;不可用于泛型 可用于泛型
存储方式 值类型的局部变量存放在 JVM 中的局部变量表中;值类型的成员变量(未被 static 修饰 )存放在 JVM 中堆中 几乎所有引用类型的对象实例都存在于堆中
默认值 有默认值且不为 null 默认值是 null
比较方式 == 比较的是值 == 比较的是对象的内存地址;使用 equals() 才是比较值

为什么说几乎所有引用类型的对象实例都存在于堆中?

因为 HotSpot 虚拟机引入了 JIT 优化之后,会对对象进行逃逸分析:如果发现某一个对象并没有逃逸到方法外部,那么就可能通过标量替换来实现栈上分配,而避免堆上分配内存。

👉 扩展阅读:Java 基本数据类型和引用类型

装箱和拆箱

包装类、装箱、拆箱

Java 中为每一种基本数据类型提供了相应的包装类,如下:

1
2
3
4
5
6
7
8
Byte <-> byte
Short <-> short
Integer <-> int
Long <-> long
Float <-> float
Double <-> double
Character <-> char
Boolean <-> boolean

引入包装类的目的就是:提供一种机制,使得基本数据类型可以与引用类型互相转换

基本数据类型与包装类的转换被称为装箱和拆箱。

  • 装箱(boxing)是将值类型转换为引用类型。例如:intInteger
    • 装箱过程是通过调用包装类的 valueOf 方法实现的。
  • 拆箱(unboxing)是将引用类型转换为值类型。例如:Integerint
    • 拆箱过程是通过调用包装类的 xxxValue 方法实现的。(xxx 代表对应的基本数据类型)。

【示例】装箱、拆箱示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Integer i1 = 10; // 自动装箱
Integer i2 = new Integer(10); // 非自动装箱
Integer i3 = Integer.valueOf(10); // 非自动装箱
int i4 = new Integer(10); // 自动拆箱
int i5 = i2.intValue(); // 非自动拆箱
System.out.println("i1 = [" + i1 + "]");
System.out.println("i2 = [" + i2 + "]");
System.out.println("i3 = [" + i3 + "]");
System.out.println("i4 = [" + i4 + "]");
System.out.println("i5 = [" + i5 + "]");
System.out.println("i1 == i2 is [" + (i1 == i2) + "]");
System.out.println("i1 == i4 is [" + (i1 == i4) + "]"); // 自动拆箱
// Output:
// i1 = [10]
// i2 = [10]
// i3 = [10]
// i4 = [10]
// i5 = [10]
// i1 == i2 is [false]
// i1 == i4 is [true]

【说明】

上面的例子,虽然简单,但却隐藏了自动装箱、拆箱和非自动装箱、拆箱的应用。从例子中可以看到,明明所有变量都初始化为数值 10 了,但为何会出现 i1 == i2 is [false]i1 == i4 is [true]

原因在于:

  • i1、i2 都是包装类,使用 == 时,Java 将它们当做两个对象,而非两个 int 值来比较,所以两个对象自然是不相等的。正确的比较操作应该使用 equals 方法。
  • i1 是包装类,i4 是基础数据类型,使用 == 时,Java 会将两个 i1 这个包装类对象自动拆箱为一个 int 值,再代入到 == 运算表达式中计算;最终,相当于两个 int 进行比较,由于值相同,所以结果相等。

包装类的缓存机制

Java 基本数据类型的包装类型的大部分都用到了缓存机制来提升性能。

Byte,Short,Integer,Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 True or False

Long 缓存源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static Long valueOf(long l) {
final int offset = 128;
if (l >= -128 && l <= 127) { // will cache
return LongCache.cache[(int)l + offset];
}
return new Long(l);
}

private static class LongCache {
private LongCache(){}

static final Long cache[] = new Long[-(-128) + 127 + 1];

static {
for(int i = 0; i < cache.length; i++)
cache[i] = new Long(i - 128);
}
}

从以上代码可知:装箱时,若数值不在包装类缓存范围内,就会创建一个新的包装类实例。由此,我们不难进一步得出以下结论:

  1. 装箱操作可能会创建新对象,频繁的装箱操作会造成不必要的内存消耗,影响性能
  2. 基础数据类型的比较操作使用 ==,包装类的比较操作使用 equals 方法

自动装箱/拆箱

JDK5 开始,支持自动装箱/拆箱功能机制。

自动装箱/拆箱是一种简化程序代码的语法糖,使得值类型和包装类之间的转换更加直接。

JDK 5 之前的形式:

1
Integer i1 = new Integer(10); // 非自动装箱

JDK 5 之后:

1
2
Integer i = 10;  // 自动装箱
int n = i; // 自动拆箱

上面这两行代码对应的字节码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
L1

LINENUMBER 8 L1

ALOAD 0

BIPUSH 10

INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;

PUTFIELD AutoBoxTest.i : Ljava/lang/Integer;

L2

LINENUMBER 9 L2

ALOAD 0

ALOAD 0

GETFIELD AutoBoxTest.i : Ljava/lang/Integer;

INVOKEVIRTUAL java/lang/Integer.intValue ()I

PUTFIELD AutoBoxTest.n : I

RETURN

从字节码示例,可以发现:

  • 自动装箱过程是通过调用包装类的 valueOf 方法实现的。
  • 自动拆箱过程是通过调用包装类的 xxxValue 方法实现的。

因此,

  • Integer i = 10 等价于 Integer i = Integer.valueOf(10)
  • int n = i 等价于 int n = i.intValue();

Java 对于自动装箱和拆箱的设计,依赖于一种叫做享元模式的设计模式(有兴趣的朋友可以去了解一下源码,这里不对设计模式展开详述)。

👉 扩展阅读:深入剖析 Java 中的装箱和拆箱

结合示例,一步步阐述装箱和拆箱原理。

判等问题

Java 中,通常使用 equals== 进行判等操作。equals 是方法而 == 是操作符。此外,二者使用也是有区别的:

  • 值类型,比如 intlong,进行判等,只能使用 ==,比较的是字面值。因为基本类型的值就是其数值。
  • 引用类型,比如 IntegerLongString,进行判等,需要使用 equals 进行内容判等。因为引用类型的直接值是指针,使用 == 的话,比较的是指针,也就是两个对象在内存中的地址,即比较它们是不是同一个对象,而不是比较对象的内容。

包装类的判等

我们通过一个示例来深入研究一下判等问题。

【示例】包装类的判等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Integer a = 127; //Integer.valueOf(127)
Integer b = 127; //Integer.valueOf(127)
log.info("\nInteger a = 127;\nInteger b = 127;\na == b ? {}", a == b); // true

Integer c = 128; //Integer.valueOf(128)
Integer d = 128; //Integer.valueOf(128)
log.info("\nInteger c = 128;\nInteger d = 128;\nc == d ? {}", c == d); //false
//设置-XX:AutoBoxCacheMax=1000再试试

Integer e = 127; //Integer.valueOf(127)
Integer f = new Integer(127); //new instance
log.info("\nInteger e = 127;\nInteger f = new Integer(127);\ne == f ? {}", e == f); //false

Integer g = new Integer(127); //new instance
Integer h = new Integer(127); //new instance
log.info("\nInteger g = new Integer(127);\nInteger h = new Integer(127);\ng == h ? {}", g == h); //false

Integer i = 128; //unbox
int j = 128;
log.info("\nInteger i = 128;\nint j = 128;\ni == j ? {}", i == j); //true

第一个案例中,编译器会把 Integer a = 127 转换为 Integer.valueOf(127)。查看源码可以发现,这个转换在内部其实做了缓存,使得两个 Integer 指向同一个对象,所以 == 返回 true。

1
2
3
4
5
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}

第二个案例中,之所以同样的代码 128 就返回 false 的原因是,默认情况下会缓存[-128,127]的数值,而 128 处于这个区间之外。设置 JVM 参数加上 -XX:AutoBoxCacheMax=1000 再试试,是不是就返回 true 了呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];

static {
// high value may be configured by property
int h = 127;
String integerCacheHighPropValue =
sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
try {
int i = parseInt(integerCacheHighPropValue);
i = Math.max(i, 127);
// Maximum array size is Integer.MAX_VALUE
h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
} catch( NumberFormatException nfe) {
// If the property cannot be parsed into an int, ignore it.
}
}
high = h;

cache = new Integer[(high - low) + 1];
int j = low;
for(int k = 0; k < cache.length; k++)
cache[k] = new Integer(j++);

// range [-128, 127] must be interned (JLS7 5.1.7)
assert IntegerCache.high >= 127;
}

private IntegerCache() {}
}

第三和第四个案例中,New 出来的 Integer 始终是不走缓存的新对象。比较两个新对象,或者比较一个新对象和一个来自缓存的对象,结果肯定不是相同的对象,因此返回 false。

第五个案例中,我们把装箱的 Integer 和基本类型 int 比较,前者会先拆箱再比较,比较的肯定是数值而不是引用,因此返回 true。

【总结】综上,我们可以得出结论:**包装类需要使用 equals 进行内容判等,而不能使用 ==**。

String 的判等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
String a = "1";
String b = "1";
log.info("\nString a = \"1\";\nString b = \"1\";\na == b ? {}", a == b); //true

String c = new String("2");
String d = new String("2");
log.info("\nString c = new String(\"2\");\nString d = new String(\"2\");\nc == d ? {}", c == d); //false

String e = new String("3").intern();
String f = new String("3").intern();
log.info("\nString e = new String(\"3\").intern();\nString f = new String(\"3\").intern();\ne == f ? {}", e == f); //true

String g = new String("4");
String h = new String("4");
log.info("\nString g = new String(\"4\");\nString h = new String(\"4\");\ng == h ? {}", g.equals(h)); //true

在 JVM 中,当代码中出现双引号形式创建字符串对象时,JVM 会先对这个字符串进行检查,如果字符串常量池中存在相同内容的字符串对象的引用,则将这个引用返回;否则,创建新的字符串对象,然后将这个引用放入字符串常量池,并返回该引用。这种机制,就是字符串驻留或池化。

第一个案例返回 true,因为 Java 的字符串驻留机制,直接使用双引号声明出来的两个 String 对象指向常量池中的相同字符串。

第二个案例,new 出来的两个 String 是不同对象,引用当然不同,所以得到 false 的结果。

第三个案例,使用 String 提供的 intern 方法也会走常量池机制,所以同样能得到 true。

第四个案例,通过 equals 对值内容判等,是正确的处理方式,当然会得到 true。

虽然使用 new 声明的字符串调用 intern 方法,也可以让字符串进行驻留,但在业务代码中滥用 intern,可能会产生性能问题。

【示例】String#intern 性能测试

1
2
3
4
5
6
7
8
9
//-XX:+PrintStringTableStatistics
//-XX:StringTableSize=10000000
List<String> list = new ArrayList<>();
long begin = System.currentTimeMillis();
list = IntStream.rangeClosed(1, 10000000)
.mapToObj(i -> String.valueOf(i).intern())
.collect(Collectors.toList());
System.out.println("size:" + list.size());
System.out.println("time:" + (System.currentTimeMillis() - begin));

上面的示例执行时间会比较长。原因在于:字符串常量池是一个固定容量的 Map。如果容量太小(Number of
buckets=60013)、字符串太多(1000 万个字符串),那么每一个桶中的字符串数量会非常多,所以搜索起来就很慢。输出结果中的 Average bucket size=167,代表了 Map 中桶的平均长度是 167。

解决方法是:设置 JVM 参数 -XX:StringTableSize=10000000,指定更多的桶。

为了方便观察,可以在启动程序时设置 JVM 参数 -XX:+PrintStringTableStatistic,程序退出时可以打印出字符串常量表的统计信息。

执行结果比不设置 -XX:StringTableSize 要快很多。

【总结】没事别轻易用 intern,如果要用一定要注意控制驻留的字符串的数量,并留意常量表的各项指标

实现 equals

如果看过 Object 类源码,你可能就知道,equals 的实现其实是比较对象引用

1
2
3
public boolean equals(Object obj) {
return (this == obj);
}

之所以 Integer 或 String 能通过 equals 实现内容判等,是因为它们都覆写了这个方法。

对于自定义类型,如果不覆写 equals 的话,默认就是使用 Object 基类的按引用的比较方式。

实现一个更好的 equals 应该注意的点:

  • 考虑到性能,可以先进行指针判等,如果对象是同一个那么直接返回 true;
  • 需要对另一方进行判空,空对象和自身进行比较,结果一定是 fasle;
  • 需要判断两个对象的类型,如果类型都不同,那么直接返回 false;
  • 确保类型相同的情况下再进行类型强制转换,然后逐一判断所有字段。

【示例】自定义 equals 示例

自定义类:

1
2
3
4
5
class Point {
private final int x;
private final int y;
private final String desc;
}

自定义 equals:

1
2
3
4
5
6
7
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Point that = (Point) o;
return x == that.x && y == that.y;
}

hashCode 和 equals 要配对实现

1
2
3
4
5
6
Point p1 = new Point(1, 2, "a");
Point p2 = new Point(1, 2, "b");

HashSet<PointWrong> points = new HashSet<>();
points.add(p1);
log.info("points.contains(p2) ? {}", points.contains(p2));

按照改进后的 equals 方法,这 2 个对象可以认为是同一个,Set 中已经存在了 p1 就应该包含 p2,但结果却是 false。

出现这个 Bug 的原因是,散列表需要使用 hashCode 来定位元素放到哪个桶。如果自定义对象没有实现自定义的 hashCode 方法,就会使用 Object 超类的默认实现,得到的两个 hashCode 是不同的,导致无法满足需求。

要自定义 hashCode,我们可以直接使用 Objects.hash 方法来实现。

1
2
3
4
@Override
public int hashCode() {
return Objects.hash(x, y);
}

compareTo 和 equals 的逻辑一致性

【示例】自定义 compareTo 出错示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Data
@AllArgsConstructor
static class Student implements Comparable<Student> {

private int id;
private String name;

@Override
public int compareTo(Student other) {
int result = Integer.compare(other.id, id);
if (result == 0) { log.info("this {} == other {}", this, other); }
return result;
}

}

调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
List<Student> list = new ArrayList<>();
list.add(new Student(1, "zhang"));
list.add(new Student(2, "wang"));
Student student = new Student(2, "li");

log.info("ArrayList.indexOf");
int index1 = list.indexOf(student);
Collections.sort(list);
log.info("Collections.binarySearch");
int index2 = Collections.binarySearch(list, student);

log.info("index1 = " + index1);
log.info("index2 = " + index2);

binarySearch 方法内部调用了元素的 compareTo 方法进行比较;

  • indexOf 的结果没问题,列表中搜索不到 id 为 2、name 是 li 的学生;
  • binarySearch 返回了索引 1,代表搜索到的结果是 id 为 2,name 是 wang 的学生。

修复方式很简单,确保 compareTo 的比较逻辑和 equals 的实现一致即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Data
@AllArgsConstructor
static class StudentRight implements Comparable<StudentRight> {

private int id;
private String name;

@Override
public int compareTo(StudentRight other) {
return Comparator.comparing(StudentRight::getName)
.thenComparingInt(StudentRight::getId)
.compare(this, other);
}

}

小心 Lombok 生成代码的“坑”

Lombok 的 @Data 注解会帮我们实现 equals 和 hashcode 方法,但是有继承关系时,
Lombok 自动生成的方法可能就不是我们期望的了。

@EqualsAndHashCode 默认实现没有使用父类属性。为解决这个问题,我们可以手动设置 callSuper 开关为 true,来覆盖这种默认行为。

数据转换

Java 中,数据类型转换有两种方式:

  • 自动转换
  • 强制转换

自动转换

一般情况下,定义了某数据类型的变量,就不能再随意转换。但是 JAVA 允许用户对基本类型做有限度的类型转换。

如果符合以下条件,则 JAVA 将会自动做类型转换:

  • 由小数据转换为大数据

    显而易见的是,“小”数据类型的数值表示范围小于“大”数据类型的数值表示范围,即精度小于“大”数据类型。

    所以,如果“大”数据向“小”数据转换,会丢失数据精度。比如:long 转为 int,则超出 int 表示范围的数据将会丢失,导致结果的不确定性。

    反之,“小”数据向“大”数据转换,则不会存在数据丢失情况。由于这个原因,这种类型转换也称为扩大转换

    这些类型由“小”到“大”分别为:(byte,short,char) < int < long < float < double。

    这里我们所说的“大”与“小”,并不是指占用字节的多少,而是指表示值的范围的大小。

  • 转换前后的数据类型要兼容

    由于 boolean 类型只能存放 true 或 false,这与整数或字符是不兼容的,因此不可以做类型转换。

  • 整型类型和浮点型进行计算后,结果会转为浮点类型

示例:

1
2
3
long x = 30;
float y = 14.3f;
System.out.println("x/y = " + x/y);

输出:

1
x/y = 1.9607843

可见 long 虽然精度大于 float 类型,但是结果为浮点数类型。

强制转换

在不符合自动转换条件时或者根据用户的需要,可以对数据类型做强制的转换。

强制转换使用括号 ()

引用类型也可以使用强制转换。

示例:

1
2
3
float f = 25.5f;
int x = (int)f;
System.out.println("x = " + x);

丢失精度和数据溢出

为什么浮点数计算存在丢失精度的风险

计算机是把数值保存在了变量中,不同类型的数值变量能保存的数值范围不同,当数值超过类型能表达的数值上限则会发生溢出问题。

1
2
3
4
5
6
7
System.out.println(0.1 + 0.2); // 0.30000000000000004
System.out.println(1.0 - 0.8); // 0.19999999999999996
System.out.println(4.015 * 100); // 401.49999999999994
System.out.println(123.3 / 100); // 1.2329999999999999
double amount1 = 2.15;
double amount2 = 1.10;
System.out.println(amount1 - amount2); // 1.0499999999999998

上面的几个示例,输出结果和我们预期的很不一样。为什么会是这样呢?

出现这种问题的主要原因是,计算机是以二进制存储数值的,浮点数也不例外。Java 采用了 IEEE 754 标准实现浮点数的表达和运算,你可以通过这里查看数值转化为二进制的结果。

比如,0.1 的二进制表示为 0.0 0011 0011 0011… (0011 无限循环),再转换为十进制就是 0.1000000000000000055511151231257827021181583404541015625。对于计算机而言,0.1 无法精确表达,这是浮点数计算造成精度损失的根源。

如何解决浮点数丢失精度的问题

浮点数无法精确表达和运算的场景,一定要使用 BigDecimal 类型

使用 BigDecimal 时,有个细节要格外注意。让我们来看一段代码:

1
2
3
4
5
6
7
8
9
10
11
System.out.println(new BigDecimal(0.1).add(new BigDecimal(0.2)));
// Output: 0.3000000000000000166533453693773481063544750213623046875

System.out.println(new BigDecimal(1.0).subtract(new BigDecimal(0.8)));
// Output: 0.1999999999999999555910790149937383830547332763671875

System.out.println(new BigDecimal(4.015).multiply(new BigDecimal(100)));
// Output: 401.49999999999996802557689079549163579940795898437500

System.out.println(new BigDecimal(123.3).divide(new BigDecimal(100)));
// Output: 1.232999999999999971578290569595992565155029296875

为什么输出结果仍然不符合预期呢?

使用 BigDecimal 表示和计算浮点数,且务必使用字符串的构造方法来初始化 BigDecimal

【示例】浮点数的字符串格式化也要通过 BigDecimal 进行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private static void wrong1() {
double num1 = 3.35;
float num2 = 3.35f;
System.out.println(String.format("%.1f", num1)); // 3.4
System.out.println(String.format("%.1f", num2)); // 3.3
}

private static void wrong2() {
double num1 = 3.35;
float num2 = 3.35f;
DecimalFormat format = new DecimalFormat("#.##");
format.setRoundingMode(RoundingMode.DOWN);
System.out.println(format.format(num1)); // 3.35
format.setRoundingMode(RoundingMode.DOWN);
System.out.println(format.format(num2)); // 3.34
}

private static void right() {
BigDecimal num1 = new BigDecimal("3.35");
BigDecimal num2 = num1.setScale(1, BigDecimal.ROUND_DOWN);
System.out.println(num2); // 3.3
BigDecimal num3 = num1.setScale(1, BigDecimal.ROUND_HALF_UP);
System.out.println(num3); // 3.4
}

BigDecimal 判等问题

1
2
3
4
5
6
7
private static void wrong() {
System.out.println(new BigDecimal("1.0").equals(new BigDecimal("1")));
}

private static void right() {
System.out.println(new BigDecimal("1.0").compareTo(new BigDecimal("1")) == 0);
}

BigDecimal 的 equals 方法的注释中说明了原因,equals 比较的是 BigDecimal 的 value 和 scale,1.0 的 scale 是 1,1 的 scale 是 0,所以结果一定是 false。

如果我们希望只比较 BigDecimal 的 value,可以使用 compareTo 方法

BigDecimal 的 equals 和 hashCode 方法会同时考虑 value 和 scale,如果结合 HashSet 或 HashMap 使用的话就可能会出现麻烦。比如,我们把值为 1.0 的 BigDecimal 加入 HashSet,然后判断其是否存在值为 1 的 BigDecimal,得到的结果是 false。

1
2
3
Set<BigDecimal> hashSet1 = new HashSet<>();
hashSet1.add(new BigDecimal("1.0"));
System.out.println(hashSet1.contains(new BigDecimal("1")));//返回false

解决办法有两个:

第一个方法是,使用 TreeSet 替换 HashSet。TreeSet 不使用 hashCode 方法,也不使用 equals 比较元素,而是使用 compareTo 方法,所以不会有问题。

第二个方法是,把 BigDecimal 存入 HashSet 或 HashMap 前,先使用 stripTrailingZeros 方法去掉尾部的零,比较的时候也去掉尾部的 0,确保 value 相同的 BigDecimal,scale 也是一致的。

1
2
3
4
5
6
7
Set<BigDecimal> hashSet2 = new HashSet<>();
hashSet2.add(new BigDecimal("1.0").stripTrailingZeros());
System.out.println(hashSet2.contains(new BigDecimal("1.000").stripTrailingZeros()));//返回true

Set<BigDecimal> treeSet = new TreeSet<>();
treeSet.add(new BigDecimal("1.0"));
System.out.println(treeSet.contains(new BigDecimal("1")));//返回true

数值溢出

数值计算还有一个要小心的点是溢出,不管是 int 还是 long,所有的值类型都有超出表达范围的可能性。

1
2
3
long l = Long.MAX_VALUE;
System.out.println(l + 1); // -9223372036854775808
System.out.println(l + 1 == Long.MIN_VALUE); // true

显然这是发生了溢出,而且是默默的溢出,并没有任何异常。这类问题非常容易被忽略,改进方式有下面 2 种。

方法一是,考虑使用 Math 类的 addExact、subtractExact 等 xxExact 方法进行数值运算,这些方法可以在数值溢出时主动抛出异常。

1
2
3
4
5
6
try {
long l = Long.MAX_VALUE;
System.out.println(Math.addExact(l, 1));
} catch (Exception ex) {
ex.printStackTrace();
}

方法二是,使用 BigInteger类。

BigInteger 内部使用 int[] 数组来存储任意大小的整形数据。

BigDecimal 是处理浮点数的专家;而 BigInteger 则是对大数进行科学计算的专家。

1
2
3
4
5
6
7
8
BigInteger i = new BigInteger(String.valueOf(Long.MAX_VALUE));
System.out.println(i.add(BigInteger.ONE).toString());

try {
long l = i.add(BigInteger.ONE).longValueExact();
} catch (Exception ex) {
ex.printStackTrace();
}

参考资料

深入理解 Java 数组

简介

数组的特性

数组对于每一门编程语言来说都是重要的数据结构之一,当然不同语言对数组的实现及处理也不尽相同。几乎所有程序设计语言都支持数组。

数组代表一系列对象或者基本数据类型,所有相同的类型都封装到一起,采用一个统一的标识符名称。

数组的定义和使用需要通过方括号 []

Java 中,数组是一种引用类型。

Java 中,数组是用来存储固定大小的同类型元素。

数组和容器

Java 中,既然有了强大的容器,是不是就不需要数组了?

答案是不。

诚然,大多数情况下,应该选择容器存储数据。

但是,数组也不是毫无是处:

  • Java 中,数组是一种效率最高的存储和随机访问对象引用序列的方式。数组的效率要高于容器(如 ArrayList)。
  • 数组可以持有值类型,而容器则不能(这时,就必须用到包装类)。

Java 数组的本质是对象

Java 数组的本质是对象。它具有 Java 中其他对象的一些基本特点:封装了一些数据,可以访问属性,也可以调用方法。所以,数组是对象。

如果有两个类 A 和 B,如果 B 继承(extends)了 A,那么 A[] 类型的引用就可以指向 B[] 类型的对象。

扩展阅读:Java 中数组的特性

如果想要论证 Java 数组本质是对象,不妨一读这篇文章。

Java 数组和内存

Java 数组在内存中的存储是这样的:

数组对象(这里可以看成一个指针)存储在栈中。

数组元素存储在堆中。

如下图所示:只有当 JVM 执行 new String[] 时,才会在堆中开辟相应的内存区域。数组对象 array 可以视为一个指针,指向这块内存的存储地址。

img

声明数组

声明数组变量的语法如下:

1
2
int[] arr1; // 推荐风格
int arr2[]; // 效果相同

创建数组

Java 语言使用 new 操作符来创建数组。有两种创建数组方式:

  • 指定数组维度
    • 为数组开辟指定大小的数组维度。
    • 如果数组元素是基础数据类型,会将每个元素设为默认值;如果是引用类型,元素值为 null
  • 不指定数组维度
    • 用花括号中的实际元素初始化数组,数组大小与元素数相同。

示例 1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ArrayDemo {
public static void main(String[] args) {
int[] array1 = new int[2]; // 指定数组维度
int[] array2 = new int[] { 1, 2 }; // 不指定数组维度

System.out.println("array1 size is " + array1.length);
for (int item : array1) {
System.out.println(item);
}

System.out.println("array2 size is " + array1.length);
for (int item : array2) {
System.out.println(item);
}
}
}
// Output:
// array1 size is 2
// 0
// 0
// array2 size is 2
// 1
// 2

💡 说明
请注意数组 array1 中的元素虽然没有初始化,但是 length 和指定的数组维度是一样的。这表明指定数组维度后,无论后面是否初始化数组中的元素,数组都已经开辟了相应的内存

数组 array1 中的元素都被设为默认值。

示例 2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class ArrayDemo2 {
static class User {}

public static void main(String[] args) {
User[] array1 = new User[2]; // 指定数组维度
User[] array2 = new User[] {new User(), new User()}; // 不指定数组维度

System.out.println("array1: ");
for (User item : array1) {
System.out.println(item);
}

System.out.println("array2: ");
for (User item : array2) {
System.out.println(item);
}
}
}
// Output:
// array1:
// null
// null
// array2:
// io.github.dunwu.javacore.array.ArrayDemo2$User@4141d797
// io.github.dunwu.javacore.array.ArrayDemo2$User@68f7aae2

💡 说明

请将本例与示例 1 比较,可以发现:如果使用指定数组维度方式创建数组,且数组元素为引用类型,则数组中的元素元素值为 null

数组维度的形式

创建数组时,指定的数组维度可以有多种形式:

  • 数组维度可以是整数、字符。
  • 数组维度可以是整数型、字符型变量。
  • 数组维度可以是计算结果为整数或字符的表达式。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ArrayDemo3 {
public static void main(String[] args) {
int length = 3;
// 放开被注掉的代码,编译器会报错
// int[] array = new int[4.0];
// int[] array2 = new int["test"];
int[] array3 = new int['a'];
int[] array4 = new int[length];
int[] array5 = new int[length + 2];
int[] array6 = new int['a' + 2];
// int[] array7 = new int[length + 2.1];
System.out.println("array3.length = [" + array3.length + "]");
System.out.println("array4.length = [" + array4.length + "]");
System.out.println("array5.length = [" + array5.length + "]");
System.out.println("array6.length = [" + array6.length + "]");
}
}
// Output:
// array3.length = [97]
// array4.length = [3]
// array5.length = [5]
// array6.length = [99]

💡 说明

当指定的数组维度是字符时,Java 会将其转为整数。如字符 a 的 ASCII 码是 97。

综上,Java 数组的数组维度可以是常量、变量、表达式,只要转换为整数即可

请留意,有些编程语言则不支持这点,如 C/C++ 语言,只允许数组维度是常量。

数组维度的大小

数组维度并非没有上限的,如果数值过大,编译时会报错。

1
int[] array = new int[6553612431]; // 数组维度过大,编译报错

此外,数组过大,可能会导致栈溢出

访问数组

Java 中,可以通过在 [] 中指定下标,访问数组元素,下标位置从 0 开始。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ArrayDemo4 {
public static void main(String[] args) {
int[] array = {1, 2, 3};
for (int i = 0; i < array.length; i++) {
array[i]++;
System.out.println(String.format("array[%d] = %d", i, array[i]));
}
}
}
// Output:
// array[0] = 2
// array[1] = 3
// array[2] = 4

💡 说明

上面的示例中,从 0 开始,使用下标遍历数组 array 的所有元素,为每个元素值加 1 。

数组的引用

Java 中,数组类型是一种引用类型

因此,它可以作为引用,被 Java 函数作为函数入参或返回值

数组作为函数入参的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ArrayRefDemo {
private static void fun(int[] array) {
for (int i : array) {
System.out.print(i + "\t");
}
}

public static void main(String[] args) {
int[] array = new int[] {1, 3, 5};
fun(array);
}
}
// Output:
// 1 3 5

数组作为函数返回值的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ArrayRefDemo2 {
/**
* 返回一个数组
*/
private static int[] fun() {
return new int[] {1, 3, 5};
}

public static void main(String[] args) {
int[] array = fun();
System.out.println(Arrays.toString(array));
}
}
// Output:
// [1, 3, 5]

泛型和数组

通常,数组和泛型不能很好地结合。你不能实例化具有参数化类型的数组。

1
Peel<Banana>[] peels = new Pell<Banana>[10]; // 这行代码非法

Java 中不允许直接创建泛型数组。但是,可以通过创建一个类型擦除的数组,然后转型的方式来创建泛型数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class GenericArrayDemo<T> {

static class GenericArray<T> {
private T[] array;

public GenericArray(int num) {
array = (T[]) new Object[num];
}

public void put(int index, T item) {
array[index] = item;
}

public T get(int index) { return array[index]; }

public T[] array() { return array; }
}



public static void main(String[] args) {
GenericArray<Integer> genericArray = new GenericArray<Integer>(4);
genericArray.put(0, 0);
genericArray.put(1, 1);
Object[] array = genericArray.array();
System.out.println(Arrays.deepToString(array));
}
}
// Output:
// [0, 1, null, null]

扩展阅读:https://www.cnblogs.com/jiangzhaowei/p/7399522.html

我认为,对于泛型数组的理解,点到为止即可。实际上,真的需要存储泛型,还是使用容器更合适。

多维数组

多维数组可以看成是数组的数组,比如二维数组就是一个特殊的一维数组,其每一个元素都是一个一维数组。

Java 可以支持二维数组、三维数组、四维数组、五维数组。。。

但是,以正常人的理解能力,一般也就最多能理解三维数组。所以,请不要做反人类的事,去定义过多维度的数组。

多维数组使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class MultiArrayDemo {
public static void main(String[] args) {
Integer[][] a1 = { // 自动装箱
{1, 2, 3,},
{4, 5, 6,},
};
Double[][][] a2 = { // 自动装箱
{ {1.1, 2.2}, {3.3, 4.4} },
{ {5.5, 6.6}, {7.7, 8.8} },
{ {9.9, 1.2}, {2.3, 3.4} },
};
String[][] a3 = {
{"The", "Quick", "Sly", "Fox"},
{"Jumped", "Over"},
{"The", "Lazy", "Brown", "Dog", "and", "friend"},
};
System.out.println("a1: " + Arrays.deepToString(a1));
System.out.println("a2: " + Arrays.deepToString(a2));
System.out.println("a3: " + Arrays.deepToString(a3));
}
}
// Output:
// a1: [[1, 2, 3], [4, 5, 6]]
// a2: [[[1.1, 2.2], [3.3, 4.4]], [[5.5, 6.6], [7.7, 8.8]], [[9.9, 1.2], [2.3, 3.4]]]
// a3: [[The, Quick, Sly, Fox], [Jumped, Over], [The, Lazy, Brown, Dog, and, friend]]

Arrays 类

Java 中,提供了一个很有用的数组工具类:Arrays。

它提供的主要操作有:

  • sort - 排序
  • binarySearch - 查找
  • equals - 比较
  • fill - 填充
  • asList - 转列表
  • hash - 哈希
  • toString - 转字符串

扩展阅读:https://juejin.im/post/5a6ade5c518825733e60acb8

小结

img

参考资料

深入理解 Java 方法

方法(有的人喜欢叫函数)是一段可重用的代码段。

方法的使用

方法定义

方法定义语法格式:

1
2
3
4
5
6
[修饰符] 返回值类型 方法名([参数类型 参数名]){
...
方法体
...
return 返回值;
}

示例:

1
2
3
public static void main(String[] args) {
System.out.println("Hello World");
}

方法包含一个方法头和一个方法体。下面是一个方法的所有部分:

  • 修饰符 - 修饰符是可选的,它告诉编译器如何调用该方法。定义了该方法的访问类型。
  • 返回值类型 - 返回值类型表示方法执行结束后,返回结果的数据类型。如果没有返回值,应设为 void。
  • 方法名 - 是方法的实际名称。方法名和参数表共同构成方法签名。
  • 参数类型 - 参数像是一个占位符。当方法被调用时,传递值给参数。参数列表是指方法的参数类型、顺序和参数的个数。参数是可选的,方法可以不包含任何参数。
  • 方法体 - 方法体包含具体的语句,定义该方法的功能。
  • return - 必须返回声明方法时返回值类型相同的数据类型。在 void 方法中,return 语句可有可无,如果要写 return,则只能是 return; 这种形式。

方法的调用

当程序调用一个方法时,程序的控制权交给了被调用的方法。当被调用方法的返回语句执行或者到达方法体闭括号时候交还控制权给程序。

Java 支持两种调用方法的方式,根据方法是否有返回值来选择。

  • 有返回值方法 - 有返回值方法通常被用来给一个变量赋值或代入到运算表达式中进行计算。
1
int larger = max(30, 40);
  • 无返回值方法 - 无返回值方法只能是一条语句。
1
System.out.println("Hello World");

递归调用

Java 支持方法的递归调用(即方法调用自身)。

🔔 注意:

  • 递归方法必须有明确的结束条件。
  • 尽量避免使用递归调用。因为递归调用如果处理不当,可能导致栈溢出。

斐波那契数列(一个典型的递归算法)示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class RecursionMethodDemo {
public static int fib(int num) {
if (num == 1 || num == 2) {
return 1;
} else {
return fib(num - 2) + fib(num - 1);
}
}

public static void main(String[] args) {
for (int i = 1; i < 10; i++) {
System.out.print(fib(i) + "\t");
}
}
}

方法参数

在 C/C++ 等编程语言中,方法的参数传递一般有两种形式:

  • 值传递 - 值传递的参数被称为形参。值传递时,传入的参数,在方法中的修改,不会在方法外部生效。
  • 引用传递 - 引用传递的参数被称为实参。引用传递时,传入的参数,在方法中的修改,会在方法外部生效。

那么,Java 中是怎样的呢?

Java 中只有值传递。

示例一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class MethodParamDemo {
public static void method(int value) {
value = value + 1;
}
public static void main(String[] args) {
int num = 0;
method(num);
System.out.println("num = [" + num + "]");
method(num);
System.out.println("num = [" + num + "]");
}
}
// Output:
// num = [0]
// num = [0]

示例二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class MethodParamDemo2 {
public static void method(StringBuilder sb) {
sb = new StringBuilder("B");
}

public static void main(String[] args) {
StringBuilder sb = new StringBuilder("A");
System.out.println("sb = [" + sb.toString() + "]");
method(sb);
System.out.println("sb = [" + sb.toString() + "]");
sb = new StringBuilder("C");
System.out.println("sb = [" + sb.toString() + "]");
}
}
// Output:
// sb = [A]
// sb = [A]
// sb = [C]

说明:

以上两个示例,无论向方法中传入的是基础数据类型,还是引用类型,在方法中修改的值,在外部都未生效。

Java 对于基本数据类型,会直接拷贝值传递到方法中;对于引用数据类型,拷贝当前对象的引用地址,然后把该地址传递过去,所以也是值传递。

扩展阅读:

图解 Java 中的参数传递

方法修饰符

前面提到了,Java 方法的修饰符是可选的,它告诉编译器如何调用该方法。定义了该方法的访问类型。

Java 方法有好几个修饰符,让我们一一来认识一下:

访问控制修饰符

访问权限控制的等级,从最大权限到最小权限依次为:

1
public > protected > 包访问权限(没有任何关键字)> private
  • public - 表示任何类都可以访问;
  • 包访问权限 - 包访问权限,没有任何关键字。它表示当前包中的所有其他类都可以访问,但是其它包的类无法访问。
  • protected - 表示子类可以访问,此外,同一个包内的其他类也可以访问,即使这些类不是子类。
  • private - 表示其它任何类都无法访问。

static

static 修饰的方法被称为静态方法。

静态方法相比于普通的实例方法,主要有以下区别:

  • 在外部调用静态方法时,可以使用 类名.方法名 的方式,也可以使用 对象名.方法名 的方式。而实例方法只有后面这种方式。也就是说,调用静态方法可以无需创建对象

  • 静态方法在访问本类的成员时,只允许访问静态成员(即静态成员变量和静态方法),而不允许访问实例成员变量和实例方法;实例方法则无此限制。

静态方法常被用于各种工具类、工厂方法类。

final

final 修饰的方法不能被子类覆写(Override)。

final 方法示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class FinalMethodDemo {
static class Father {
protected final void print() {
System.out.println("call Father print()");
};
}

static class Son extends Father {
@Override
protected void print() {
System.out.println("call print()");
}
}

public static void main(String[] args) {
Father demo = new Son();
demo.print();
}
}
// 编译时会报错

说明:

上面示例中,父类 Father 中定义了一个 final 方法 print(),则其子类不能 Override 这个 final 方法,否则会编译报错。

default

JDK8 开始,支持在接口 Interface 中定义 default 方法。**default 方法只能出现在接口 Interface 中**。

接口中被 default 修饰的方法被称为默认方法,实现此接口的类如果没 Override 此方法,则直接继承这个方法,不再强制必须实现此方法。

default 方法语法的出现,是为了既有的成千上万的 Java 类库的类增加新的功能, 且不必对这些类重新进行设计。 举例来说,JDK8 中 Collection 类中有一个非常方便的 stream() 方法,就是被修饰为 default,Collection 的一大堆 List、Set 子类就直接继承了这个方法 I,不必再为每个子类都注意添加这个方法。

default 方法示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class DefaultMethodDemo {
interface MyInterface {
default void print() {
System.out.println("Hello World");
}
}


static class MyClass implements MyInterface {}

public static void main(String[] args) {
MyInterface obj = new MyClass();
obj.print();
}
}
// Output:
// Hello World

abstract

abstract 修饰的方法被称为抽象方法,方法不能有实体。抽象方法只能出现抽象类中。

抽象方法示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class AbstractMethodDemo {
static abstract class AbstractClass {
abstract void print();
}

static class ConcreteClass extends AbstractClass {
@Override
void print() {
System.out.println("call print()");
}
}

public static void main(String[] args) {
AbstractClass demo = new ConcreteClass();
demo.print();
}

}
// Outpu:
// call print()

synchronized

synchronized 用于并发编程。synchronized 修饰的方法在一个时刻,只允许一个线程执行。

在 Java 的同步容器(Vector、Stack、HashTable)中,你会见到大量的 synchronized 方法。不过,请记住:在 Java 并发编程中,synchronized 方法并不是一个好的选择,大多数情况下,我们会选择更加轻量级的锁 。

特殊方法

Java 中,有一些较为特殊的方法,分别使用于特殊的场景。

main 方法

Java 中的 main 方法是一种特殊的静态方法,因为所有的 Java 程序都是由 public static void main(String[] args) 方法开始执行。

有很多新手虽然一直用 main 方法,却不知道 main 方法中的 args 有什么用。实际上,这是用来接收接收命令行输入参数的。

示例:

1
2
3
4
5
6
7
public class MainMethodDemo {
public static void main(String[] args) {
for (String arg : args) {
System.out.println("arg = [" + arg + "]");
}
}
}

依次执行

1
2
javac MainMethodDemo.java
java MainMethodDemo A B C

控制台会打印输出参数:

1
2
3
arg = [A]
arg = [B]
arg = [C]

构造方法

任何类都有构造方法,构造方法的作用就是在初始化类实例时,设置实例的状态。

每个类都有构造方法。如果没有显式地为类定义任何构造方法,Java 编译器将会为该类提供一个默认构造方法。

在创建一个对象的时候,至少要调用一个构造方法。构造方法的名称必须与类同名,一个类可以有多个构造方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ConstructorMethodDemo {

static class Person {
private String name;

public Person(String name) {
this.name = name;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}
}

public static void main(String[] args) {
Person person = new Person("jack");
System.out.println("person name is " + person.getName());
}
}

注意,构造方法除了使用 public,也可以使用 private 修饰,这种情况下,类无法调用此构造方法去实例化对象,这常常用于设计模式中的单例模式。

变参方法

JDK5 开始,Java 支持传递同类型的可变参数给一个方法。在方法声明中,在指定参数类型后加一个省略号 ...。一个方法中只能指定一个可变参数,它必须是方法的最后一个参数。任何普通的参数必须在它之前声明。

变参方法示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class VarargsDemo {
public static void method(String... params) {
System.out.println("params.length = " + params.length);
for (String param : params) {
System.out.println("params = [" + param + "]");
}
}

public static void main(String[] args) {
method("red");
method("red", "yellow");
method("red", "yellow", "blue");
}
}
// Output:
// params.length = 1
// params = [red]
// params.length = 2
// params = [red]
// params = [yellow]
// params.length = 3
// params = [red]
// params = [yellow]
// params = [blue]

finalize() 方法

finalize 在对象被垃圾收集器析构(回收)之前调用,用来清除回收对象。

finalize 是在 java.lang.Object 里定义的,也就是说每一个对象都有这么个方法。这个方法在 GC 启动,该对象被回收的时候被调用。

finalizer() 通常是不可预测的,也是很危险的,一般情况下是不必要的。使用终结方法会导致行为不稳定、降低性能,以及可移植性问题。

请记住:应该尽量避免使用 finalizer()。千万不要把它当成是 C/C++ 中的析构函数来用。原因是:Finalizer 线程会和我们的主线程进行竞争,不过由于它的优先级较低,获取到的 CPU 时间较少,因此它永远也赶不上主线程的步伐。所以最后可能会发生 OutOfMemoryError 异常。

扩展阅读:

下面两篇文章比较详细的讲述了 finalizer() 可能会造成的问题及原因。

覆写和重载

覆写(Override)是指子类定义了与父类中同名的方法,但是在方法覆写时必须考虑到访问权限,子类覆写的方法不能拥有比父类更加严格的访问权限。

子类要覆写的方法如果要访问父类的方法,可以使用 super 关键字。

覆写示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class MethodOverrideDemo {
static class Animal {
public void move() {
System.out.println("会动");
}
}
static class Dog extends Animal {
@Override
public void move() {
super.move();
System.out.println("会跑");
}
}

public static void main(String[] args) {
Animal dog = new Dog();
dog.move();
}
}
// Output:
// 会动
// 会跑

方法的重载(Overload)是指方法名称相同,但参数的类型或参数的个数不同。通过传递参数的个数及类型的不同可以完成不同功能的方法调用。

🔔 注意:

重载一定是方法的参数不完全相同。如果方法的参数完全相同,仅仅是返回值不同,Java 是无法编译通过的。

重载示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class MethodOverloadDemo {
public static void add(int x, int y) {
System.out.println("x + y = " + (x + y));
}

public static void add(double x, double y) {
System.out.println("x + y = " + (x + y));
}

public static void main(String[] args) {
add(10, 20);
add(1.0, 2.0);
}
}
// Output:
// x + y = 30
// x + y = 3.0

小结

img

参考资料