JAVA快速入门教程

分類 编程语言, Java

Java最早是由SUN公司(已被Oracle收购)的詹姆斯·高斯林(高司令,人称Java之父)在上个世纪90年代初开发的一种编程语言,最初被命名为Oak,目标是针对小型家电设备的嵌入式应用,结果市场没啥反响。谁料到互联网的崛起,让Oak重新焕发了生机,于是SUN公司改造了Oak,在1995年以Java的名称正式发布,原因是Oak已经被人注册了,因此SUN注册了Java这个商标。随着互联网的高速发展,Java逐渐成为最重要的网络编程语言。

Java介于编译型语言和解释型语言之间。编译型语言如C、C++,代码是直接编译成机器码执行,但是不同的平台(x86、ARM等)CPU的指令集不同,因此,需要编译出每一种平台的对应机器码。解释型语言如Python、Ruby没有这个问题,可以由解释器直接加载源码然后运行,代价是运行效率太低。而Java是将代码编译成一种“字节码”,它类似于抽象的CPU指令,然后,针对不同平台编写虚拟机,不同平台的虚拟机负责加载字节码并执行,这样就实现了“一次编写,到处运行”的效果。当然,这是针对Java开发者而言。对于虚拟机,需要为每个平台分别开发。为了保证不同平台、不同公司开发的虚拟机都能正确执行Java字节码,SUN公司制定了一系列的Java虚拟机规范。从实践的角度看,JVM的兼容性做得非常好,低版本的Java字节码完全可以正常运行在高版本的JVM上。

随着Java的发展,SUN给Java又分出了三个不同版本:

  • Java SE:Standard Edition
  • Java EE:Enterprise Edition
  • Java ME:Micro Edition

这三者之间有啥关系呢?

1
2
3
4
5
6
7
8
9
┌───────────────────────────┐
│Java EE │
│ ┌────────────────────┐ │
│ │Java SE │ │
│ │ ┌─────────────┐ │ │
│ │ │ Java ME │ │ │
│ │ └─────────────┘ │ │
│ └────────────────────┘ │
└───────────────────────────┘

简单来说,Java SE就是标准版,包含标准的JVM和标准库,而Java EE是企业版,它只是在Java SE的基础上加上了大量的API和库,以便方便开发Web应用、数据库、消息服务等,Java EE的应用使用的虚拟机和Java SE完全相同。

Java ME就和Java SE不同,它是一个针对嵌入式设备的“瘦身版”,Java SE的标准库无法在Java ME上使用,Java ME的虚拟机也是“瘦身版”。

毫无疑问,Java SE是整个Java平台的核心,而Java EE是进一步学习Web应用所必须的。我们熟悉的Spring等框架都是Java EE开源生态系统的一部分。不幸的是,Java ME从来没有真正流行起来,反而是Android开发成为了移动平台的标准之一,因此,没有特殊需求,不建议学习Java ME。

因此我们推荐的Java学习路线图如下:

  1. 首先要学习Java SE,掌握Java语言本身、Java核心开发技术以及Java标准库的使用;
  2. 如果继续学习Java EE,那么Spring框架、数据库开发、分布式架构就是需要学习的;
  3. 如果要学习大数据开发,那么Hadoop、Spark、Flink这些大数据平台就是需要学习的,他们都基于Java或Scala开发;
  4. 如果想要学习移动开发,那么就深入Android平台,掌握Android App开发。

无论怎么选择,Java SE的核心技术是基础,这个教程的目的就是让你完全精通Java SE并掌握Java EE!

Java版本

从1996年发布1.0版本开始,到目前为止,最新的Java版本是Java 21:

时间 版本
1996 1.0
1997 1.1
1998 1.2
2000 1.3
2002 1.4
2004 1.5 / 5.0
2005 1.6 / 6.0
2011 1.7 / 7.0
2014 1.8 / 8.0
2017/9 1.9 / 9.0
2018/3 10
2018/9 11
2019/3 12
2019/9 13
2020/3 14
2020/9 15
2021/3 16
2021/9 17
2022/3 18
2022/9 19
2023/3 20
2023/9 21
2024/3 22
2024/9 23

本教程使用的Java版本是最新版的Java 23

名词解释

初学者学Java,经常听到JDK、JRE这些名词,它们到底是啥?

  • JDK:Java Development Kit
  • JRE:Java Runtime Environment

简单地说,JRE就是运行Java字节码的虚拟机。但是,如果只有Java源码,要编译成Java字节码,就需要JDK,因为JDK除了包含JRE,还提供了编译器、调试器等开发工具。

二者关系如下:

1
2
3
4
5
6
7
8
9
10
11
 ┌─    ┌──────────────────────────────────┐
│ │ Compiler, debugger, etc. │
│ └──────────────────────────────────┘
JDK ┌─ ┌──────────────────────────────────┐
│ │ │ │
│ JRE │ JVM + Runtime Library │
│ │ │ │
└─ └─ └──────────────────────────────────┘
┌───────┐┌───────┐┌───────┐┌───────┐
│Windows││ Linux ││ macOS ││others │
└───────┘└───────┘└───────┘└───────┘

要学习Java开发,当然需要安装JDK了。

那JSR、JCP……又是啥?

  • JSR规范:Java Specification Request
  • JCP组织:Java Community Process

为了保证Java语言的规范性,SUN公司搞了一个JSR规范,凡是想给Java平台加一个功能,比如说访问数据库的功能,大家要先创建一个JSR规范,定义好接口,这样,各个数据库厂商都按照规范写出Java驱动程序,开发者就不用担心自己写的数据库代码在MySQL上能跑,却不能跑在PostgreSQL上。

所以JSR是一系列的规范,从JVM的内存模型到Web程序接口,全部都标准化了。而负责审核JSR的组织就是JCP。

一个JSR规范发布时,为了让大家有个参考,还要同时发布一个“参考实现”,以及一个“兼容性测试套件”:

  • RI:Reference Implementation
  • TCK:Technology Compatibility Kit

比如有人提议要搞一个基于Java开发的消息服务器,这个提议很好啊,但是光有提议还不行,得贴出真正能跑的代码,这就是RI。如果有其他人也想开发这样一个消息服务器,如何保证这些消息服务器对开发者来说接口、功能都是相同的?所以还得提供TCK。

通常来说,RI只是一个“能跑”的正确的代码,它不追求速度,所以,如果真正要选择一个Java的消息服务器,一般是没人用RI的,大家都会选择一个有竞争力的商用或开源产品。

参考:Java消息服务JMS的JSR:https://jcp.org/en/jsr/detail?id=914

要开始学习Java编程,我们需要首先搭建开发环境。

本节我们介绍如何安装JDK,在命令行编译Java程序,以及如何使用IDE来开发Java。

安装JDK

因为Java程序必须运行在JVM之上,所以,我们第一件事情就是安装JDK。

搜索JDK 23,确保从Oracle的官网下载最新的稳定版JDK:

1
2
3
4
5
6
7
8
Java SE Development Kit 23 downloads

Linux macOS Windows
-------

x64 Compressed Archive Download
x64 Installer Download
x64 MSI Installer Download

选择合适的操作系统与安装包,找到Java SE 23的下载链接Download,下载安装即可。Windows优先选x64 MSI Installer,Linux和macOS要根据自己电脑的CPU是ARM还是x86来选择合适的安装包。

设置环境变量

安装完JDK后,需要设置一个JAVA_HOME的环境变量,它指向JDK的安装目录。在Windows下,它是安装目录,类似:

1
C:\Program Files\Java\jdk-23

在macOS下,它在~/.bash_profile~/.zprofile里,它是:

1
export JAVA_HOME=`/usr/libexec/java_home -v 23`

然后,把JAVA_HOMEbin目录附加到系统环境变量PATH上。在Windows下,它长这样:

1
Path=%JAVA_HOME%\bin;<现有的其他路径>

在macOS下,它在~/.bash_profile~/.zprofile里,长这样:

1
export PATH=$JAVA_HOME/bin:$PATH

JAVA_HOMEbin目录添加到PATH中是为了在任意文件夹下都可以运行java。打开PowerShell窗口,输入命令java -version,如果一切正常,你会看到如下输出:

1
2
3
4
5
6
7
8
9
10
11
12
┌─────────────────────────────────────────────────────────┐
│Windows PowerShell - □ x │
├─────────────────────────────────────────────────────────┤
│Windows PowerShell │
│Copyright (C) Microsoft Corporation. All rights reserved.│
│ │
│PS C:\Users\liaoxuefeng> java -version │
│java version "23" ... │
│Java(TM) SE Runtime Environment │
│Java HotSpot(TM) 64-Bit Server VM │
│ │
└─────────────────────────────────────────────────────────┘

如果你看到的版本号不是23,而是151.8之类,说明系统存在多个JDK,且默认JDK不是JDK 23,需要把JDK 23提到PATH前面。

如果你得到一个错误输出:“无法将“java”项识别为 cmdlet、函数、脚本文件或可运行程序的名称。”:

1
2
3
4
5
6
7
8
9
10
11
12
┌─────────────────────────────────────────────────────────┐
│Windows PowerShell - □ x │
├─────────────────────────────────────────────────────────┤
│Windows PowerShell │
│Copyright (C) Microsoft Corporation. All rights reserved.│
│ │
│PS C:\Users\liaoxuefeng> java -version │
│java : The term 'java' is not recognized as ... │
│... │
│ + FullyQualifiedErrorId : CommandNotFoundException │
│ │
└─────────────────────────────────────────────────────────┘

这是因为系统无法找到Java虚拟机的程序java.exe,需要检查JAVA_HOMEPATH的配置。

可以参考如何设置或更改PATH系统变量

JDK

细心的童鞋还可以在JAVA_HOMEbin目录下找到很多可执行文件:

  • java:这个可执行程序其实就是JVM,运行Java程序,就是启动JVM,然后让JVM执行指定的编译后的代码;
  • javac:这是Java的编译器,它用于把Java源码文件(以.java后缀结尾)编译为Java字节码文件(以.class后缀结尾);
  • jar:用于把一组.class文件打包成一个.jar文件,便于发布;
  • javadoc:用于从Java源码中自动提取注释并生成文档;
  • jdb:Java调试器,用于开发阶段的运行调试。


我们来编写第一个Java程序。

打开文本编辑器,输入以下代码:

1
2
3
4
5
public class Hello {
public static void main(String[] args) {
System.out.println("Hello, world!");
}
}

在一个Java程序中,你总能找到一个类似:

1
2
3
public class Hello {
...
}

的定义,这个定义被称为class(类),这里的类名是Hello,大小写敏感,class用来定义一个类,public表示这个类是公开的,publicclass都是Java的关键字,必须小写,Hello是类的名字,按照习惯,首字母H要大写。而花括号{}中间则是类的定义。

注意到类的定义中,我们定义了一个名为main的方法:

1
2
3
public static void main(String[] args) {
...
}

方法是可执行的代码块,一个方法除了方法名main,还有用()括起来的方法参数,这里的main方法有一个参数,参数类型是String[],参数名是argspublicstatic用来修饰方法,这里表示它是一个公开的静态方法,void是方法的返回类型,而花括号{}中间的就是方法的代码。

方法的代码每一行用;结束,这里只有一行代码,就是:

1
System.out.println("Hello, world!");

它用来打印一个字符串到屏幕上。

Java规定,某个类定义的public static void main(String[] args)是Java程序的固定入口方法,因此,Java程序总是从main方法开始执行。

注意到Java源码的缩进不是必须的,但是用缩进后,格式好看,很容易看出代码块的开始和结束,缩进一般是4个空格或者一个tab。

最后,当我们把代码保存为文件时,文件名必须是Hello.java,而且文件名也要注意大小写,因为要和我们定义的类名Hello完全保持一致。

如何运行Java程序

Java源码本质上是一个文本文件,我们需要先用javacHello.java编译成字节码文件Hello.class,然后,用java命令执行这个字节码文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
┌──────────────────┐
│ Hello.java │◀── source code
└──────────────────┘
│ compile

┌──────────────────┐
│ Hello.class │◀── byte code
└──────────────────┘
│ execute

┌──────────────────┐
│ Run on JVM │
└──────────────────┘

因此,可执行文件javac是编译器,而可执行文件java就是虚拟机。

第一步,在保存Hello.java的目录下执行命令javac Hello.java

1
$ javac Hello.java

如果源代码无误,上述命令不会有任何输出,而当前目录下会产生一个Hello.class文件:

1
2
$ ls
Hello.class Hello.java

第二步,执行Hello.class,使用命令java Hello(注意不是java Hello.class):

1
2
$ java Hello
Hello, world!

注意:给虚拟机传递的参数Hello是我们定义的类名,虚拟机自动查找对应的class文件并执行。

如果执行java Hello报错:

1
2
3
$ java Hello
Error: Could not find or load main class Hello
Caused by: java.lang.ClassNotFoundException: Hello

出现ClassNotFoundException信息,说明在当前目录下并没有Hello.class这个文件,请切换到Hello.class的目录,然后执行java Hello

有一些童鞋可能知道,直接运行java Hello.java也是可以的:

1
2
$ java Hello.java 
Hello, world!

这是从Java 11开始新增的一个功能,它可以直接运行一个单文件源码!

需要注意的是,在实际项目中,单个不依赖第三方库的Java源码是非常罕见的,所以,绝大多数情况下,我们无法直接运行一个Java源码文件,原因是它需要依赖其他的库。

小结

一个Java源码只能定义一个public类型的class,并且class名称和文件名要完全一致;

使用javac可以将.java源码编译成.class字节码;

使用java可以运行一个已编译的Java程序,参数是类名。

IDE是集成开发环境:Integrated Development Environment的缩写。

使用IDE的好处在于,可以把编写代码、组织项目、编译、运行、调试等放到一个环境中运行,能极大地提高开发效率。

IDE提升开发效率主要靠以下几点:

  • 编辑器的自动提示,可以大大提高敲代码的速度;
  • 代码修改后可以自动重新编译,并直接运行;
  • 可以方便地进行断点调试。

目前,流行的用于Java开发的IDE有:

Eclipse

Eclipse是由IBM开发并捐赠给开源社区的一个IDE,也是目前应用最广泛的IDE。Eclipse的特点是它本身是Java开发的,并且基于插件结构,即使是对Java开发的支持也是通过插件JDT实现的。

除了用于Java开发,Eclipse配合插件也可以作为C/C++开发环境、PHP开发环境、Rust开发环境等。

IntelliJ Idea

IntelliJ Idea是由JetBrains公司开发的一个功能强大的IDE,分为免费版和商用付费版。JetBrains公司的IDE平台也是基于IDE平台+语言插件的模式,支持Python开发环境、Ruby开发环境、PHP开发环境等,这些开发环境也分为免费版和付费版。

NetBeans

NetBeans是最早由SUN开发的开源IDE,由于使用人数较少,目前已不再流行。

使用Eclipse

你可以使用任何IDE进行Java学习和开发。我们不讨论任何关于IDE的优劣,本教程使用Eclipse作为开发演示环境,原因在于:

  • 完全免费使用;
  • 所有功能完全满足Java开发需求。

安装Eclipse

Eclipse的发行版提供了预打包的开发环境,包括Java、JavaEE、C++、PHP、Rust等。从这里下载:

我们需要下载的版本是Eclipse IDE for Java Developers:

eclipse-jdt

根据操作系统是Windows、Mac还是Linux,从右边选择对应的下载链接。

注意

教程从头到尾并不需要用到Enterprise Java的功能,所以不需要下载Eclipse IDE for Enterprise Java Developers

设置Eclipse

下载并安装完成后,我们启动Eclipse,对IDE环境做一个基本设置:

选择菜单“Eclipse/Window”-“Preferences”,打开配置对话框:

eclipse-pref

我们需要调整以下设置项:

General > Editors > Text Editors

钩上“Show line numbers”,这样编辑器会显示行号;

General > Workspace

钩上“Refresh using native hooks or polling”,这样Eclipse会自动刷新文件夹的改动;

对于“Text file encoding”,如果Default不是UTF-8,一定要改为“Other:UTF-8”,所有文本文件均使用UTF-8编码;

对于“New text file line delimiter”,建议使用Unix,即换行符使用\n而不是Windows的\r\n

workspace-utf-8

Java > Compiler

将“Compiler compliance level”设置为20,本教程的所有代码均使用Java 20的语法,并且编译到Java 20的版本。

去掉“Use default compliance settings”并钩上“Enable preview features for Java 20”,这样我们就可以使用Java 20的预览功能。

注意

如果Compiler compliance level没有22这个选项,请更新到最新版Eclipse。如果更新后还是没有22,打开Help - Eclipse Marketplace,搜索Java 22 Support安装后重启即可。

Java > Installed JREs

在Installed JREs中应该看到Java SE 20,如果还有其他的JRE,可以删除,以确保Java SE 20是默认的JRE。

Eclipse IDE结构

打开Eclipse后,整个IDE由若干个区域组成:

eclipse-workspace

  • 中间可编辑的文本区(见1)是编辑器,用于编辑源码;
  • 分布在左右和下方的是视图:
    • Package Exploroer(见2)是Java项目的视图
    • Console(见3)是命令行输出视图
    • Outline(见4)是当前正在编辑的Java源码的结构视图
  • 视图可以任意组合,然后把一组视图定义成一个Perspective(见5),Eclipse预定义了Java、Debug等几个Perspective,用于快速切换。

新建Java项目

在Eclipse菜单选择“File”-“New”-“Java Project”,填入HelloWorld,JRE选择Java SE 22

new-java-project

暂时不要勾选“Create module-info.java file”,因为模块化机制我们后面才会讲到:

new-java-project-2

点击“Finish”就成功创建了一个名为HelloWorld的Java工程。

新建Java文件并运行

展开HelloWorld工程,选中源码目录src,点击右键,在弹出菜单中选择“New”-“Class”:

new-java-class

在弹出的对话框中,Name一栏填入Hello

new-hello-class

点击”Finish“,就自动在src目录下创建了一个名为Hello.java的源文件。我们双击打开这个源文件,填上代码:

hello-java-source

保存,然后选中文件Hello.java,点击右键,在弹出的菜单中选中“Run As…”-“Java Application”:

run-as-java-application

Console窗口中就可以看到运行结果:

console

如果没有在主界面中看到Console窗口,请选中菜单“Window”-“Show View”-“Console”,即可显示。

我们先剖析一个完整的Java程序,它的基本结构是什么:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 可以用来自动创建文档的注释
*/
public class Hello {
public static void main(String[] args) {
// 向屏幕输出文本:
System.out.println("Hello, world!");
/* 多行注释开始
注释内容
注释结束 */
}
} // class定义结束

因为Java是面向对象的语言,一个程序的基本单位就是classclass是关键字,这里定义的class名字就是Hello

1
2
3
public class Hello { // 类名是Hello
// ...
} // class定义结束

类名要求:

  • 类名必须以英文字母开头,后接字母,数字和下划线的组合
  • 习惯以大写字母开头

要注意遵守命名习惯,好的类命名:

  • Hello
  • NoteBook
  • VRPlayer

不好的类命名:

  • hello
  • Good123
  • Note_Book
  • _World

注意到public是访问修饰符,表示该class是公开的。

不写public,也能正确编译,但是这个类将无法从命令行执行。

class内部,可以定义若干方法(method):

1
2
3
4
5
public class Hello {
public static void main(String[] args) { // 方法名是main
// 方法代码...
} // 方法定义结束
}

方法定义了一组执行语句,方法内部的代码将会被依次顺序执行。

这里的方法名是main,返回值是void,表示没有任何返回值。

我们注意到public除了可以修饰class外,也可以修饰方法。而关键字static是另一个修饰符,它表示静态方法,后面我们会讲解方法的类型,目前,我们只需要知道,Java入口程序规定的方法必须是静态方法,方法名必须为main,括号内的参数必须是String数组。

方法名也有命名规则,命名和class一样,但是首字母小写:

好的方法命名:

  • main
  • goodMorning
  • playVR

不好的方法命名:

  • Main
  • good123
  • good_morning
  • _playVR

在方法内部,语句才是真正的执行代码。Java的每一行语句必须以分号结束:

1
2
3
4
5
public class Hello {
public static void main(String[] args) {
System.out.println("Hello, world!"); // 语句
}
}

在Java程序中,注释是一种给人阅读的文本,不是程序的一部分,所以编译器会自动忽略注释。

Java有3种注释,第一种是单行注释,以双斜线开头,直到这一行的结尾结束:

1
// 这是注释...

而多行注释以/*星号开头,以*/结束,可以有多行:

1
2
3
4
5
/*
这是注释
blablabla...
这也是注释
*/

还有一种特殊的多行注释,以/**开头,以*/结束,如果有多行,每行通常以星号开头:

1
2
3
4
5
6
7
8
9
10
/**
* 可以用来自动创建文档的注释
*
* @auther liaoxuefeng
*/
public class Hello {
public static void main(String[] args) {
System.out.println("Hello, world!");
}
}

这种特殊的多行注释需要写在类和方法的定义处,可以用于自动创建文档。

Java程序对格式没有明确的要求,多几个空格或者回车不影响程序的正确性,但是我们要养成良好的编程习惯,注意遵守Java社区约定的编码格式。

那约定的编码格式有哪些要求呢?其实我们在前面介绍的Eclipse IDE提供了快捷键Ctrl+Shift+F(macOS是⌘+⇧+F)帮助我们快速格式化代码的功能,Eclipse就是按照约定的编码格式对代码进行格式化的,所以只需要看看格式化后的代码长啥样就行了。具体的代码格式要求可以在Eclipse的设置中Java-Code Style查看。

变量

什么是变量?

变量就是初中数学的代数的概念,例如一个简单的方程,x,y都是变量:

在Java中,变量分为两种:基本类型的变量和引用类型的变量。

我们先讨论基本类型的变量。

在Java中,变量必须先定义后使用,在定义变量的时候,可以给它一个初始值。例如:

1
int x = 1;

上述语句定义了一个整型int类型的变量,名称为x,初始值为1

不写初始值,就相当于给它指定了默认值。默认值总是0

来看一个完整的定义变量,然后打印变量值的例子:

1
2
3
4
5
6
7
// 定义并打印变量
public class Main {
public static void main(String[] args) {
int x = 100; // 定义int类型变量x,并赋予初始值100
System.out.println(x); // 打印该变量的值
}
}

变量的一个重要特点是可以重新赋值。例如,对变量x,先赋值100,再赋值200,观察两次打印的结果:

1
2
3
4
5
6
7
8
9
// 重新赋值变量
public class Main {
public static void main(String[] args) {
int x = 100; // 定义int类型变量x,并赋予初始值100
System.out.println(x); // 打印该变量的值,观察是否为100
x = 200; // 重新赋值为200
System.out.println(x); // 打印该变量的值,观察是否为200
}
}

注意到第一次定义变量x的时候,需要指定变量类型int,因此使用语句int x = 100;。而第二次重新赋值的时候,变量x已经存在了,不能再重复定义,因此不能指定变量类型int,必须使用语句x = 200;

变量不但可以重新赋值,还可以赋值给其他变量。让我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 变量之间的赋值
public class Main {
public static void main(String[] args) {
int n = 100; // 定义变量n,同时赋值为100
System.out.println("n = " + n); // 打印n的值

n = 200; // 变量n赋值为200
System.out.println("n = " + n); // 打印n的值

int x = n; // 变量x赋值为n(n的值为200,因此赋值后x的值也是200)
System.out.println("x = " + x); // 打印x的值

x = x + 100; // 变量x赋值为x+100(x的值为200,因此赋值后x的值是200+100=300)
System.out.println("x = " + x); // 打印x的值
System.out.println("n = " + n); // 再次打印n的值,n应该是200还是300?
}
}

我们一行一行地分析代码执行流程:

执行int n = 100;,该语句定义了变量n,同时赋值为100,因此,JVM在内存中为变量n分配一个“存储单元”,填入值100

1
2
3
4
5
6
      n


┌───┬───┬───┬───┬───┬───┬───┐
│ │100│ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┘

执行n = 200;时,JVM把200写入变量n的存储单元,因此,原有的值被覆盖,现在n的值为200

1
2
3
4
5
6
      n


┌───┬───┬───┬───┬───┬───┬───┐
│ │200│ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┘

执行int x = n;时,定义了一个新的变量x,同时对x赋值,因此,JVM需要新分配一个存储单元给变量x,并写入和变量n一样的值,结果是变量x的值也变为200

1
2
3
4
5
6
      n           x
│ │
▼ ▼
┌───┬───┬───┬───┬───┬───┬───┐
│ │200│ │ │200│ │ │
└───┴───┴───┴───┴───┴───┴───┘

执行x = x + 100;时,JVM首先计算等式右边的值x + 100,结果为300(因为此刻x的值为200),然后,将结果300写入x的存储单元,因此,变量x最终的值变为300

1
2
3
4
5
6
      n           x
│ │
▼ ▼
┌───┬───┬───┬───┬───┬───┬───┐
│ │200│ │ │300│ │ │
└───┴───┴───┴───┴───┴───┴───┘

可见,变量可以反复赋值。注意,等号=是赋值语句,不是数学意义上的相等,否则无法解释x = x + 100

基本数据类型

基本数据类型是CPU可以直接进行运算的类型。Java定义了以下几种基本数据类型:

  • 整数类型:byte,short,int,long
  • 浮点数类型:float,double
  • 字符类型:char
  • 布尔类型:boolean

Java定义的这些基本数据类型有什么区别呢?要了解这些区别,我们就必须简单了解一下计算机内存的基本结构。

计算机内存的最小存储单元是字节(byte),一个字节就是一个8位二进制数,即8个bit。它的二进制表示范围从00000000~11111111,换算成十进制是0~255,换算成十六进制是00~ff

内存单元从0开始编号,称为内存地址。每个内存单元可以看作一间房间,内存地址就是门牌号。

1
2
3
4
  0   1   2   3   4   5   6  ...
┌───┬───┬───┬───┬───┬───┬───┐
│ │ │ │ │ │ │ │...
└───┴───┴───┴───┴───┴───┴───┘

一个字节是1byte,1024字节是1K,1024K是1M,1024M是1G,1024G是1T。一个拥有4T内存的计算机的字节数量就是:

1
2
3
4
5
4T = 4 x 1024G
= 4 x 1024 x 1024M
= 4 x 1024 x 1024 x 1024K
= 4 x 1024 x 1024 x 1024 x 1024
= 4398046511104

不同的数据类型占用的字节数不一样。我们看一下Java基本数据类型占用的字节数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
       ┌───┐
byte │ │
└───┘
┌───┬───┐
short │ │ │
└───┴───┘
┌───┬───┬───┬───┐
int │ │ │ │ │
└───┴───┴───┴───┘
┌───┬───┬───┬───┬───┬───┬───┬───┐
long │ │ │ │ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
┌───┬───┬───┬───┐
float │ │ │ │ │
└───┴───┴───┴───┘
┌───┬───┬───┬───┬───┬───┬───┬───┐
double │ │ │ │ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
┌───┬───┐
char │ │ │
└───┴───┘

byte恰好就是一个字节,而longdouble需要8个字节。

整型

对于整型类型,Java只定义了带符号的整型,因此,最高位的bit表示符号位(0表示正数,1表示负数)。各种整型能表示的最大范围如下:

  • byte:-128 ~ 127
  • short: -32768 ~ 32767
  • int: -2147483648 ~ 2147483647
  • long: -9223372036854775808 ~ 9223372036854775807

我们来看定义整型的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 定义整型
public class Main {
public static void main(String[] args) {
int i = 2147483647;
int i2 = -2147483648;
int i3 = 2_000_000_000; // 加下划线更容易识别
int i4 = 0xff0000; // 十六进制表示的16711680
int i5 = 0b1000000000; // 二进制表示的512

long n1 = 9000000000000000000L; // long型的结尾需要加L
long n2 = 900; // 没有加L,此处900为int,但int类型可以赋值给long
int i6 = 900L; // 错误:不能把long型赋值给int
}
}

特别注意:同一个数的不同进制的表示是完全相同的,例如15=0xf0b1111

浮点型

浮点类型的数就是小数,因为小数用科学计数法表示的时候,小数点是可以“浮动”的,如1234.5可以表示成12.345x102,也可以表示成1.2345x103,所以称为浮点数。

下面是定义浮点数的例子:

1
2
3
4
5
6
7
float f1 = 3.14f;
float f2 = 3.14e38f; // 科学计数法表示的3.14x10^38
float f3 = 1.0; // 错误:不带f结尾的是double类型,不能赋值给float

double d = 1.79e308;
double d2 = -1.79e308;
double d3 = 4.9e-324; // 科学计数法表示的4.9x10^-324

对于float类型,需要加上f后缀。

浮点数可表示的范围非常大,float类型可最大表示3.4x1038,而double类型可最大表示1.79x10308。

布尔类型

布尔类型boolean只有truefalse两个值,布尔类型总是关系运算的计算结果:

1
2
3
4
5
boolean b1 = true;
boolean b2 = false;
boolean isGreater = 5 > 3; // 计算结果为true
int age = 12;
boolean isAdult = age >= 18; // 计算结果为false

Java语言对布尔类型的存储并没有做规定,因为理论上存储布尔类型只需要1 bit,但是通常JVM内部会把boolean表示为4字节整数。

字符类型

字符类型char表示一个字符。Java的char类型除了可表示标准的ASCII外,还可以表示一个Unicode字符:

1
2
3
4
5
6
7
8
9
// 字符类型
public class Main {
public static void main(String[] args) {
char a = 'A';
char zh = '中';
System.out.println(a);
System.out.println(zh);
}
}

注意char类型使用单引号',且仅有一个字符,要和双引号"的字符串类型区分开。

引用类型

除了上述基本类型的变量,剩下的都是引用类型。例如,引用类型最常用的就是String字符串:

1
String s = "hello";

引用类型的变量类似于C语言的指针,它内部存储一个“地址”,指向某个对象在内存的位置,后续我们介绍类的概念时会详细讨论。

常量

定义变量的时候,如果加上final修饰符,这个变量就变成了常量:

1
2
3
4
final double PI = 3.14; // PI是一个常量
double r = 5.0;
double area = PI * r * r;
PI = 300; // compile error!

常量在定义时进行初始化后就不可再次赋值,再次赋值会导致编译错误。

常量的作用是用有意义的变量名来避免魔术数字(Magic number),例如,不要在代码中到处写3.14,而是定义一个常量。如果将来需要提高计算精度,我们只需要在常量的定义处修改,例如,改成3.1416,而不必在所有地方替换3.14

为了和变量区分开来,根据习惯,常量名通常全部大写

var关键字

有些时候,类型的名字太长,写起来比较麻烦。例如:

1
StringBuilder sb = new StringBuilder();

这个时候,如果想省略变量类型,可以使用var关键字:

1
var sb = new StringBuilder();

编译器会根据赋值语句自动推断出变量sb的类型是StringBuilder。对编译器来说,语句:

1
var sb = new StringBuilder();

实际上会自动变成:

1
StringBuilder sb = new StringBuilder();

因此,使用var定义变量,仅仅是少写了变量类型而已。

变量的作用范围

在Java中,多行语句用{ ... }括起来。很多控制语句,例如条件判断和循环,都以{ ... }作为它们自身的范围,例如:

1
2
3
4
5
6
7
8
9
10
11
if (...) { // if开始
...
while (...) { // while 开始
...
if (...) { // if开始
...
} // if结束
...
} // while结束
...
} // if结束

只要正确地嵌套这些{ ... },编译器就能识别出语句块的开始和结束。而在语句块中定义的变量,它有一个作用域,就是从定义处开始,到语句块结束。超出了作用域引用这些变量,编译器会报错。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
...
int i = 0; // 变量i从这里开始定义
...
{
...
int x = 1; // 变量x从这里开始定义
...
{
...
String s = "hello"; // 变量s从这里开始定义
...
} // 变量s作用域到此结束
...
// 注意,这是一个新的变量s,它和上面的变量同名,
// 但是因为作用域不同,它们是两个不同的变量:
String s = "hi";
...
} // 变量x和s作用域到此结束
...
} // 变量i作用域到此结束

定义变量时,要遵循作用域最小化原则,尽量将变量定义在尽可能小的作用域,并且,不要重复使用变量名。

小结

Java提供了两种变量类型:基本类型和引用类型

基本类型包括整型,浮点型,布尔型,字符型。

变量可重新赋值,等号是赋值语句,不是数学意义的等号。

常量在初始化后不可重新赋值,使用常量便于理解程序意图。

Java的整数运算遵循四则运算规则,可以使用任意嵌套的小括号。四则运算规则和初等数学一致。例如:

1
2
3
4
5
6
7
8
9
// 四则运算
public class Main {
public static void main(String[] args) {
int i = (100 + 200) * (99 - 88); // 3300
int n = 7 * (5 + (i - 9)); // 23072
System.out.println(i);
System.out.println(n);
}
}

整数的数值表示不但是精确的,而且整数运算永远是精确的,即使是除法也是精确的,因为两个整数相除只能得到结果的整数部分:

1
int x = 12345 / 67; // 184

求余运算使用%

1
int y = 12345 % 67; // 12345÷67的余数是17

特别注意:整数的除法对于除数为0时运行时将报错,但编译不会报错。

溢出

要特别注意,整数由于存在范围限制,如果计算结果超出了范围,就会产生溢出,而溢出不会出错,却会得到一个奇怪的结果:

1
2
3
4
5
6
7
8
9
// 运算溢出
public class Main {
public static void main(String[] args) {
int x = 2147483640;
int y = 15;
int sum = x + y;
System.out.println(sum); // -2147483641
}
}

要解释上述结果,我们把整数214748364015换成二进制做加法:

1
2
3
4
  0111 1111 1111 1111 1111 1111 1111 1000
+ 0000 0000 0000 0000 0000 0000 0000 1111
-----------------------------------------
1000 0000 0000 0000 0000 0000 0000 0111

由于最高位计算结果为1,因此,加法结果变成了一个负数。

要解决上面的问题,可以把int换成long类型,由于long可表示的整型范围更大,所以结果就不会溢出:

1
2
3
4
long x = 2147483640;
long y = 15;
long sum = x + y;
System.out.println(sum); // 2147483655

还有一种简写的运算符,即+=-=*=/=,它们的使用方法如下:

1
2
n += 100; // 3409, 相当于 n = n + 100;
n -= 100; // 3309, 相当于 n = n - 100;

自增/自减

Java还提供了++运算和--运算,它们可以对一个整数进行加1和减1的操作:

1
2
3
4
5
6
7
8
9
10
// 自增/自减运算
public class Main {
public static void main(String[] args) {
int n = 3300;
n++; // 3301, 相当于 n = n + 1;
n--; // 3300, 相当于 n = n - 1;
int y = 100 + (++n); // 不要这么写
System.out.println(y);
}
}

注意++写在前面和后面计算结果是不同的,++n表示先加1再引用n,n++表示先引用n再加1。不建议把++运算混入到常规运算中,容易自己把自己搞懵了。

移位运算

在计算机中,整数总是以二进制的形式表示。例如,int类型的整数7使用4字节表示的二进制如下:

1
00000000 0000000 0000000 00000111

可以对整数进行移位运算。对整数7左移1位将得到整数14,左移两位将得到整数28

1
2
3
4
5
int n = 7;       // 00000000 00000000 00000000 00000111 = 7
int a = n << 1; // 00000000 00000000 00000000 00001110 = 14
int b = n << 2; // 00000000 00000000 00000000 00011100 = 28
int c = n << 28; // 01110000 00000000 00000000 00000000 = 1879048192
int d = n << 29; // 11100000 00000000 00000000 00000000 = -536870912

左移29位时,由于最高位变成1,因此结果变成了负数。

类似的,对整数28进行右移,结果如下:

1
2
3
4
int n = 7;       // 00000000 00000000 00000000 00000111 = 7
int a = n >> 1; // 00000000 00000000 00000000 00000011 = 3
int b = n >> 2; // 00000000 00000000 00000000 00000001 = 1
int c = n >> 3; // 00000000 00000000 00000000 00000000 = 0

如果对一个负数进行右移,最高位的1不动,结果仍然是一个负数:

1
2
3
4
5
int n = -536870912;
int a = n >> 1; // 11110000 00000000 00000000 00000000 = -268435456
int b = n >> 2; // 11111000 00000000 00000000 00000000 = -134217728
int c = n >> 28; // 11111111 11111111 11111111 11111110 = -2
int d = n >> 29; // 11111111 11111111 11111111 11111111 = -1

还有一种无符号的右移运算,使用>>>,它的特点是不管符号位,右移后高位总是补0,因此,对一个负数进行>>>右移,它会变成正数,原因是最高位的1变成了0

1
2
3
4
5
int n = -536870912;
int a = n >>> 1; // 01110000 00000000 00000000 00000000 = 1879048192
int b = n >>> 2; // 00111000 00000000 00000000 00000000 = 939524096
int c = n >>> 29; // 00000000 00000000 00000000 00000111 = 7
int d = n >>> 31; // 00000000 00000000 00000000 00000001 = 1

byteshort类型进行移位时,会首先转换为int再进行位移。

仔细观察可发现,左移实际上就是不断地×2,右移实际上就是不断地÷2。

位运算

位运算是按位进行与、或、非和异或的运算。我们先来看看针对单个bit的位运算。

与运算的规则是,必须两个数同时为1,结果才为1

1
2
3
4
n = 0 & 0; // 0
n = 0 & 1; // 0
n = 1 & 0; // 0
n = 1 & 1; // 1

或运算的规则是,只要任意一个为1,结果就为1

1
2
3
4
n = 0 | 0; // 0
n = 0 | 1; // 1
n = 1 | 0; // 1
n = 1 | 1; // 1

非运算的规则是,01互换:

1
2
n = ~0; // 1
n = ~1; // 0

异或运算的规则是,如果两个数不同,结果为1,否则为0

1
2
3
4
n = 0 ^ 0; // 0
n = 0 ^ 1; // 1
n = 1 ^ 0; // 1
n = 1 ^ 1; // 0

Java没有单个bit的数据类型。在Java中,对两个整数进行位运算,实际上就是按位对齐,然后依次对每一位进行运算。例如:

1
2
3
4
5
6
7
8
9
10
// 位运算
public class Main {
public static void main(String[] args) {
int i = 167776589; // 00001010 00000000 00010001 01001101
int n = 167776512; // 00001010 00000000 00010001 00000000
// & -----------------------------------
// 00001010 00000000 00010001 00000000
System.out.println(i & n); // 167776512
}
}

上述按位与运算实际上可以看作两个整数表示的IP地址10.0.17.7710.0.17.0,通过与运算,可以快速判断一个IP是否在给定的网段内。

运算优先级

在Java的计算表达式中,运算优先级从高到低依次是:

  • ()
  • ! ~ ++ --
  • * / %
  • + -
  • << >> >>>
  • &
  • |
  • += -= *= /=

记不住也没关系,只需要加括号就可以保证运算的优先级正确。

类型自动提升与强制转型

在运算过程中,如果参与运算的两个数类型不一致,那么计算结果为较大类型的整型。例如,shortint计算,结果总是int,原因是short首先自动被转型为int

1
2
3
4
5
6
7
8
9
// 类型自动提升与强制转型
public class Main {
public static void main(String[] args) {
short s = 1234;
int i = 123456;
int x = s + i; // s自动转型为int
short y = s + i; // 编译错误!
}
}

也可以将结果强制转型,即将大范围的整数转型为小范围的整数。强制转型使用(类型),例如,将int强制转型为short

1
2
int i = 12345;
short s = (short) i; // 12345

要注意,超出范围的强制转型会得到错误的结果,原因是转型时,int的两个高位字节直接被扔掉,仅保留了低位的两个字节:

1
2
3
4
5
6
7
8
9
10
11
// 强制转型
public class Main {
public static void main(String[] args) {
int i1 = 1234567;
short s1 = (short) i1; // -10617
System.out.println(s1);
int i2 = 12345678;
short s2 = (short) i2; // 24910
System.out.println(s2);
}
}

因此,强制转型的结果很可能是错的。

练习

计算前N个自然数的和可以根据公式:

请根据公式计算前N个自然数的和:

1
2
3
4
5
6
7
8
9
10
// 计算前N个自然数的和
public class Main {
public static void main(String[] args) {
int n = 100;
// TODO: sum = 1 + 2 + ... + n
int sum = ???;
System.out.println(sum);
System.out.println(sum == 5050 ? "测试通过" : "测试失败");
}
}

下载练习

小结

整数运算的结果永远是精确的;

运算结果会自动提升;

可以强制转型,但超出范围的强制转型会得到错误的结果;

应该选择合适范围的整型(intlong),没有必要为了节省内存而使用byteshort进行整数运算。

浮点数运算和整数运算相比,只能进行加减乘除这些数值计算,不能做位运算和移位运算。

在计算机中,浮点数虽然表示的范围大,但是,浮点数有个非常重要的特点,就是浮点数常常无法精确表示。

举个例子:

浮点数0.1在计算机中就无法精确表示,因为十进制的0.1换算成二进制是一个无限循环小数,很显然,无论使用float还是double,都只能存储一个0.1的近似值。但是,0.5这个浮点数又可以精确地表示。

因为浮点数常常无法精确表示,因此,浮点数运算会产生误差:

1
2
3
4
5
6
7
8
9
10
// 浮点数运算误差
public class Main {
public static void main(String[] args) {
double x = 1.0 / 10;
double y = 1 - 9.0 / 10;
// 观察x和y是否相等:
System.out.println(x);
System.out.println(y);
}
}

由于浮点数存在运算误差,所以比较两个浮点数是否相等常常会出现错误的结果。正确的比较方法是判断两个浮点数之差的绝对值是否小于一个很小的数:

1
2
3
4
5
6
7
8
// 比较x和y是否相等,先计算其差的绝对值:
double r = Math.abs(x - y);
// 再判断绝对值是否足够小:
if (r < 0.00001) {
// 可以认为相等
} else {
// 不相等
}

浮点数在内存的表示方法和整数比更加复杂。Java的浮点数完全遵循IEEE-754标准,这也是绝大多数计算机平台都支持的浮点数标准表示方法。

类型提升

如果参与运算的两个数其中一个是整型,那么整型可以自动提升到浮点型:

1
2
3
4
5
6
7
8
// 类型提升
public class Main {
public static void main(String[] args) {
int n = 5;
double d = 1.2 + 24.0 / n; // 6.0
System.out.println(d);
}
}

需要特别注意,在一个复杂的四则运算中,两个整数的运算不会出现自动提升的情况。例如:

1
double d = 1.2 + 24 / 5; // 结果不是 6.0 而是 5.2

计算结果为5.2,原因是编译器计算24 / 5这个子表达式时,按两个整数进行运算,结果仍为整数4

要修复这个计算结果,可以将24 / 5改为24.0 / 5。由于24.0是浮点数,因此,计算除法时自动将5提升为浮点数。

溢出

整数运算在除数为0时会报错,而浮点数运算在除数为0时,不会报错,但会返回几个特殊值:

  • NaN表示Not a Number
  • Infinity表示无穷大
  • -Infinity表示负无穷大

例如:

1
2
3
double d1 = 0.0 / 0; // NaN
double d2 = 1.0 / 0; // Infinity
double d3 = -1.0 / 0; // -Infinity

这三种特殊值在实际运算中很少碰到,我们只需要了解即可。

强制转型

可以将浮点数强制转型为整数。在转型时,浮点数的小数部分会被丢掉。如果转型后超过了整型能表示的最大范围,将返回整型的最大值。例如:

1
2
3
4
5
int n1 = (int) 12.3; // 12
int n2 = (int) 12.7; // 12
int n3 = (int) -12.7; // -12
int n4 = (int) (12.7 + 0.5); // 13
int n5 = (int) 1.2e20; // 2147483647

如果要进行四舍五入,可以对浮点数加上0.5再强制转型:

1
2
3
4
5
6
7
8
// 四舍五入
public class Main {
public static void main(String[] args) {
double d = 2.6;
int n = (int) (d + 0.5);
System.out.println(n);
}
}

练习

根据一元二次方程 ax2+bx+c=0ax^2+bx+c=0 的求根公式:

计算出一元二次方程的两个解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 一元二次方程
public class Main {
public static void main(String[] args) {
double a = 1.0;
double b = 3.0;
double c = -4.0;
// 求平方根可用 Math.sqrt():
// System.out.println(Math.sqrt(2)); ==> 1.414
// TODO:
double r1 = 0;
double r2 = 0;
System.out.println(r1);
System.out.println(r2);
System.out.println(r1 == 1 && r2 == -4 ? "测试通过" : "测试失败");
}
}

下载练习

小结

浮点数常常无法精确表示,并且浮点数的运算结果可能有误差;

比较两个浮点数通常比较它们的差的绝对值是否小于一个特定值;

整型和浮点型运算时,整型会自动提升为浮点型;

可以将浮点型强制转为整型,但超出范围后将始终返回整型的最大值。

布尔运算

对于布尔类型boolean,永远只有truefalse两个值。

布尔运算是一种关系运算,包括以下几类:

  • 比较运算符:>>=<<===!=
  • 与运算 &&
  • 或运算 ||
  • 非运算 !

下面是一些示例:

1
2
3
4
5
6
boolean isGreater = 5 > 3; // true
int age = 12;
boolean isZero = age == 0; // false
boolean isNonZero = !isZero; // true
boolean isAdult = age >= 18; // false
boolean isTeenager = age >6 && age <18; // true

关系运算符的优先级从高到低依次是:

  • !
  • >>=<<=
  • ==!=
  • &&
  • ||

短路运算

布尔运算的一个重要特点是短路运算。如果一个布尔运算的表达式能提前确定结果,则后续的计算不再执行,直接返回结果。

因为false && x的结果总是false,无论xtrue还是false,因此,与运算在确定第一个值为false后,不再继续计算,而是直接返回false

我们考察以下代码:

1
2
3
4
5
6
7
8
// 短路运算
public class Main {
public static void main(String[] args) {
boolean b = 5 < 3;
boolean result = b && (5 / 0 > 0); // 此处 5 / 0 不会报错
System.out.println(result);
}
}

如果没有短路运算,&&后面的表达式会由于除数为0而报错,但实际上该语句并未报错,原因在于与运算是短路运算符,提前计算出了结果false

如果变量b的值为true,则表达式变为true && (5 / 0 > 0)。因为无法进行短路运算,该表达式必定会由于除数为0而报错,可以自行测试。

类似的,对于||运算,只要能确定第一个值为true,后续计算也不再进行,而是直接返回true

1
boolean result = true || (5 / 0 > 0); // true

三元运算符

Java还提供一个三元运算符b ? x : y,它根据第一个布尔表达式的结果,分别返回后续两个表达式之一的计算结果。示例:

1
2
3
4
5
6
7
8
// 三元运算
public class Main {
public static void main(String[] args) {
int n = -100;
int x = n >= 0 ? n : -n;
System.out.println(x);
}
}

上述语句的意思是,判断n >= 0是否成立,如果为true,则返回n,否则返回-n。这实际上是一个求绝对值的表达式。

注意到三元运算b ? x : y会首先计算b,如果btrue,则只计算x,否则,只计算y。此外,xy的类型必须相同,因为返回值不是boolean,而是xy之一。

练习

判断指定年龄是否是小学生(6~12岁):

1
2
3
4
5
6
7
8
9
// 布尔运算
public class Main {
public static void main(String[] args) {
int age = 7;
// primary student的定义: 6~12岁
boolean isPrimaryStudent = ???;
System.out.println(isPrimaryStudent ? "Yes" : "No");
}
}

下载练习

小结

与运算和或运算是短路运算;

三元运算b ? x : y后面的类型必须相同,三元运算也是“短路运算”,只计算xy



在Java中,字符和字符串是两个不同的类型。

字符类型

字符类型char是基本数据类型,它是character的缩写。一个char保存一个Unicode字符:

1
2
char c1 = 'A';
char c2 = '中';

因为Java在内存中总是使用Unicode表示字符,所以,一个英文字符和一个中文字符都用一个char类型表示,它们都占用两个字节。要显示一个字符的Unicode编码,只需将char类型直接赋值给int类型即可:

1
2
int n1 = 'A'; // 字母“A”的Unicodde编码是65
int n2 = '中'; // 汉字“中”的Unicode编码是20013

还可以直接用转义字符\u+Unicode编码来表示一个字符:

1
2
3
// 注意是十六进制:
char c3 = '\u0041'; // 'A',因为十六进制0041 = 十进制65
char c4 = '\u4e2d'; // '中',因为十六进制4e2d = 十进制20013

字符串类型

char类型不同,字符串类型String是引用类型,我们用双引号"..."表示字符串。一个字符串可以存储0个到任意个字符:

1
2
3
4
String s = ""; // 空字符串,包含0个字符
String s1 = "A"; // 包含一个字符
String s2 = "ABC"; // 包含3个字符
String s3 = "中文 ABC"; // 包含6个字符,其中有一个空格

因为字符串使用双引号"..."表示开始和结束,那如果字符串本身恰好包含一个"字符怎么表示?例如,"abc"xyz",编译器就无法判断中间的引号究竟是字符串的一部分还是表示字符串结束。这个时候,我们需要借助转义字符\

1
String s = "abc\"xyz"; // 包含7个字符: a, b, c, ", x, y, z

因为\是转义字符,所以,两个\\表示一个\字符:

1
String s = "abc\\xyz"; // 包含7个字符: a, b, c, \, x, y, z

常见的转义字符包括:

  • \" 表示字符"
  • \' 表示字符'
  • \\ 表示字符\
  • \n 表示换行符
  • \r 表示回车符
  • \t 表示Tab
  • \u#### 表示一个Unicode编码的字符

例如:

1
String s = "ABC\n\u4e2d\u6587"; // 包含6个字符: A, B, C, 换行符, 中, 文

字符串连接

Java的编译器对字符串做了特殊照顾,可以使用+连接任意字符串和其他数据类型,这样极大地方便了字符串的处理。例如:

1
2
3
4
5
6
7
8
9
// 字符串连接
public class Main {
public static void main(String[] args) {
String s1 = "Hello";
String s2 = "world";
String s = s1 + " " + s2 + "!";
System.out.println(s); // Hello world!
}
}

如果用+连接字符串和其他数据类型,会将其他数据类型先自动转型为字符串,再连接:

1
2
3
4
5
6
7
8
// 字符串连接
public class Main {
public static void main(String[] args) {
int age = 25;
String s = "age is " + age;
System.out.println(s); // age is 25
}
}

多行字符串

如果我们要表示多行字符串,使用+号连接会非常不方便:

1
2
3
String s = "first line \n"
+ "second line \n"
+ "end";

从Java 13开始,字符串可以用"""..."""表示多行字符串(Text Blocks)了。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
// 多行字符串
public class Main {
public static void main(String[] args) {
String s = """
SELECT * FROM
users
WHERE id > 100
ORDER BY name DESC
""";
System.out.println(s);
}
}

上述多行字符串实际上是5行,在最后一个DESC后面还有一个\n。如果我们不想在字符串末尾加一个\n,就需要这么写:

1
2
3
4
5
String s = """ 
SELECT * FROM
users
WHERE id > 100
ORDER BY name DESC""";

还需要注意到,多行字符串前面共同的空格会被去掉,即:

1
2
3
4
5
6
String s = """
...........SELECT * FROM
........... users
...........WHERE id > 100
...........ORDER BY name DESC
...........""";

.标注的空格都会被去掉。

如果多行字符串的排版不规则,那么,去掉的空格就会变成这样:

1
2
3
4
5
6
String s = """
......... SELECT * FROM
......... users
.........WHERE id > 100
......... ORDER BY name DESC
......... """;

即总是以最短的行首空格为基准。

不可变特性

Java的字符串除了是一个引用类型外,还有个重要特点,就是字符串不可变。考察以下代码:

1
2
3
4
5
6
7
8
9
// 字符串不可变
public class Main {
public static void main(String[] args) {
String s = "hello";
System.out.println(s); // 显示 hello
s = "world";
System.out.println(s); // 显示 world
}
}

观察执行结果,难道字符串s变了吗?其实变的不是字符串,而是变量s的“指向”。

执行String s = "hello";时,JVM虚拟机先创建字符串"hello",然后,把字符串变量s指向它:

1
2
3
4
5
6
      s


┌───┬───────────┬───┐
│ │ "hello" │ │
└───┴───────────┴───┘

紧接着,执行s = "world";时,JVM虚拟机先创建字符串"world",然后,把字符串变量s指向它:

1
2
3
4
5
6
      s ──────────────┐


┌───┬───────────┬───┬───────────┬───┐
│ │ "hello" │ │ "world" │ │
└───┴───────────┴───┴───────────┴───┘

原来的字符串"hello"还在,只是我们无法通过变量s访问它而已。因此,字符串的不可变是指字符串内容不可变。至于变量,可以一会指向字符串"hello",一会指向字符串"world"

理解了引用类型的“指向”后,试解释下面的代码输出:

1
2
3
4
5
6
7
8
9
// 字符串不可变
public class Main {
public static void main(String[] args) {
String s = "hello";
String t = s;
s = "world";
System.out.println(t); // t是"hello"还是"world"?
}
}

空值null

引用类型的变量可以指向一个空值null,它表示不存在,即该变量不指向任何对象。例如:

1
2
3
String s1 = null; // s1是null
String s2 = s1; // s2也是null
String s3 = ""; // s3指向空字符串,不是null

注意要区分空值null和空字符串"",空字符串是一个有效的字符串对象,它不等于null

练习

请将一组int值视为字符的Unicode编码,然后将它们拼成一个字符串:

1
2
3
4
5
6
7
8
9
10
11
public class Main {
public static void main(String[] args) {
// 请将下面一组int值视为字符的Unicode码,把它们拼成一个字符串:
int a = 72;
int b = 105;
int c = 65281;
// FIXME:
String s = a + b + c;
System.out.println(s);
}
}

下载练习

小结

Java的字符类型char是基本类型,字符串类型String是引用类型;

基本类型的变量是“持有”某个数值,引用类型的变量是“指向”某个对象;

引用类型的变量可以是空值null

要区分空值null和空字符串""

如果我们有一组类型相同的变量,例如,5位同学的成绩,可以这么写:

1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
// 5位同学的成绩:
int n1 = 68;
int n2 = 79;
int n3 = 91;
int n4 = 85;
int n5 = 62;
}
}

但其实没有必要定义5个int变量。可以使用数组来表示“一组”int类型。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 数组
public class Main {
public static void main(String[] args) {
// 5位同学的成绩:
int[] ns = new int[5];
ns[0] = 68;
ns[1] = 79;
ns[2] = 91;
ns[3] = 85;
ns[4] = 62;
}
}

定义一个数组类型的变量,使用数组类型“类型[]”,例如,int[]。和单个基本类型变量不同,数组变量初始化必须使用new int[5]表示创建一个可容纳5个int元素的数组。

Java的数组有几个特点:

  • 数组所有元素初始化为默认值,整型都是0,浮点型是0.0,布尔型是false
  • 数组一旦创建后,大小就不可改变。

要访问数组中的某一个元素,需要使用索引。数组索引从0开始,例如,5个元素的数组,索引范围是0~4

可以修改数组中的某一个元素,使用赋值语句,例如,ns[1] = 79;

可以用数组变量.length获取数组大小:

1
2
3
4
5
6
7
8
// 数组
public class Main {
public static void main(String[] args) {
// 5位同学的成绩:
int[] ns = new int[5];
System.out.println(ns.length); // 5
}
}

数组是引用类型,在使用索引访问数组元素时,如果索引超出范围,运行时将报错:

1
2
3
4
5
6
7
8
9
// 数组
public class Main {
public static void main(String[] args) {
// 5位同学的成绩:
int[] ns = new int[5];
int n = 5;
System.out.println(ns[n]); // 索引n不能超出范围
}
}

也可以在定义数组时直接指定初始化的元素,这样就不必写出数组大小,而是由编译器自动推算数组大小。例如:

1
2
3
4
5
6
7
8
// 数组
public class Main {
public static void main(String[] args) {
// 5位同学的成绩:
int[] ns = new int[] { 68, 79, 91, 85, 62 };
System.out.println(ns.length); // 编译器自动推算数组大小为5
}
}

还可以进一步简写为:

1
int[] ns = { 68, 79, 91, 85, 62 };

注意数组是引用类型,并且数组大小不可变。我们观察下面的代码:

1
2
3
4
5
6
7
8
9
10
11
// 数组
public class Main {
public static void main(String[] args) {
// 5位同学的成绩:
int[] ns;
ns = new int[] { 68, 79, 91, 85, 62 };
System.out.println(ns.length); // 5
ns = new int[] { 1, 2, 3 };
System.out.println(ns.length); // 3
}
}

数组大小变了吗?看上去好像是变了,但其实根本没变。

对于数组ns来说,执行ns = new int[] { 68, 79, 91, 85, 62 };时,它指向一个5个元素的数组:

1
2
3
4
5
6
     ns


┌───┬───┬───┬───┬───┬───┬───┐
│ │68 │79 │91 │85 │62 │ │
└───┴───┴───┴───┴───┴───┴───┘

执行ns = new int[] { 1, 2, 3 };时,它指向一个新的3个元素的数组:

1
2
3
4
5
6
     ns ──────────────────────┐


┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ │68 │79 │91 │85 │62 │ │ 1 │ 2 │ 3 │ │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘

但是,原有的5个元素的数组并没有改变,只是无法通过变量ns引用到它们而已。

字符串数组

如果数组元素不是基本类型,而是一个引用类型,那么,修改数组元素会有哪些不同?

字符串是引用类型,因此我们先定义一个字符串数组:

1
2
3
String[] names = {
"ABC", "XYZ", "zoo"
};

对于String[]类型的数组变量names,它实际上包含3个元素,但每个元素都指向某个字符串对象:

1
2
3
4
5
6
7
8
9
          ┌─────────────────────────┐
names │ ┌─────────────────────┼───────────┐
│ │ │ │ │
▼ │ │ ▼ ▼
┌───┬───┬─┴─┬─┴─┬───┬───────┬───┬───────┬───┬───────┬───┐
│ │░░░│░░░│░░░│ │ "ABC" │ │ "XYZ" │ │ "zoo" │ │
└───┴─┬─┴───┴───┴───┴───────┴───┴───────┴───┴───────┴───┘
│ ▲
└─────────────────┘

names[1]进行赋值,例如names[1] = "cat";,效果如下:

1
2
3
4
5
6
7
8
9
          ┌─────────────────────────────────────────────────┐
names │ ┌─────────────────────────────────┐ │
│ │ │ │ │
▼ │ │ ▼ ▼
┌───┬───┬─┴─┬─┴─┬───┬───────┬───┬───────┬───┬───────┬───┬───────┬───┐
│ │░░░│░░░│░░░│ │ "ABC" │ │ "XYZ" │ │ "zoo" │ │ "cat" │ │
└───┴─┬─┴───┴───┴───┴───────┴───┴───────┴───┴───────┴───┴───────┴───┘
│ ▲
└─────────────────┘

这里注意到原来names[1]指向的字符串"XYZ"并没有改变,仅仅是将names[1]的引用从指向"XYZ"改成了指向"cat",其结果是字符串"XYZ"再也无法通过names[1]访问到了。

对“指向”有了更深入的理解后,试解释如下代码:

1
2
3
4
5
6
7
8
9
// 数组
public class Main {
public static void main(String[] args) {
String[] names = {"ABC", "XYZ", "zoo"};
String s = names[1];
names[1] = "cat";
System.out.println(s); // s是"XYZ"还是"cat"?
}
}

小结

数组是同一数据类型的集合,数组一旦创建后,大小就不可变;

可以通过索引访问数组元素,但索引超出范围将报错;

数组元素可以是值类型(如int)或引用类型(如String),但数组本身是引用类型;

输出

在前面的代码中,我们总是使用System.out.println()来向屏幕输出一些内容。

println是print line的缩写,表示输出并换行。因此,如果输出后不想换行,可以用print()

1
2
3
4
5
6
7
8
9
10
// 输出
public class Main {
public static void main(String[] args) {
System.out.print("A,");
System.out.print("B,");
System.out.print("C.");
System.out.println();
System.out.println("END");
}
}

注意观察上述代码的执行效果。

格式化输出

Java还提供了格式化输出的功能。为什么要格式化输出?因为计算机表示的数据不一定适合人来阅读:

1
2
3
4
5
6
7
// 格式化输出
public class Main {
public static void main(String[] args) {
double d = 12900000;
System.out.println(d); // 1.29E7
}
}

如果要把数据显示成我们期望的格式,就需要使用格式化输出的功能。格式化输出使用System.out.printf(),通过使用占位符%?printf()可以把后面的参数格式化成指定格式:

1
2
3
4
5
6
7
8
// 格式化输出
public class Main {
public static void main(String[] args) {
double d = 3.1415926;
System.out.printf("%.2f\n", d); // 显示两位小数3.14
System.out.printf("%.4f\n", d); // 显示4位小数3.1416
}
}

Java的格式化功能提供了多种占位符,可以把各种数据类型“格式化”成指定的字符串:

占位符 说明
%d 格式化输出整数
%x 格式化输出十六进制整数
%f 格式化输出浮点数
%e 格式化输出科学计数法表示的浮点数
%s 格式化字符串

注意,由于%表示占位符,因此,连续两个%%表示一个%字符本身。

占位符本身还可以有更详细的格式化参数。下面的例子把一个整数格式化成十六进制,并用0补足8位:

1
2
3
4
5
6
7
// 格式化输出
public class Main {
public static void main(String[] args) {
int n = 12345000;
System.out.printf("n=%d, hex=%08x", n, n); // 注意,两个%占位符必须传入两个数
}
}

详细的格式化参数请参考JDK文档java.util.Formatter

输入

和输出相比,Java的输入就要复杂得多。

我们先看一个从控制台读取一个字符串和一个整数的例子:

1
2
3
4
5
6
7
8
9
10
11
12
import java.util.Scanner;

public class Main {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in); // 创建Scanner对象
System.out.print("Input your name: "); // 打印提示
String name = scanner.nextLine(); // 读取一行输入并获取字符串
System.out.print("Input your age: "); // 打印提示
int age = scanner.nextInt(); // 读取一行输入并获取整数
System.out.printf("Hi, %s, you are %d\n", name, age); // 格式化输出
}
}

首先,我们通过import语句导入java.util.Scannerimport是导入某个类的语句,必须放到Java源代码的开头,后面我们在Java的package中会详细讲解如何使用import

然后,创建Scanner对象并传入System.inSystem.out代表标准输出流,而System.in代表标准输入流。直接使用System.in读取用户输入虽然是可以的,但需要更复杂的代码,而通过Scanner就可以简化后续的代码。

有了Scanner对象后,要读取用户输入的字符串,使用scanner.nextLine(),要读取用户输入的整数,使用scanner.nextInt()Scanner会自动转换数据类型,因此不必手动转换。

要测试输入,必须从命令行读取用户输入,因此,需要走编译、执行的流程:

1
$ javac Main.java

这个程序编译时如果有警告,可以暂时忽略它,在后面学习IO的时候再详细解释。编译成功后,执行:

1
2
3
4
$ java Main
Input your name: Bob ◀── 输入 Bob
Input your age: 12 ◀── 输入 12
Hi, Bob, you are 12 ◀── 输出

根据提示分别输入一个字符串和整数后,我们得到了格式化的输出。

练习

请帮小明同学设计一个程序,输入上次考试成绩(int)和本次考试成绩(int),然后输出成绩提高的百分比,保留两位小数位(例如,21.75%)。

下载练习

小结

Java提供的输出包括:System.out.println() / print() / printf(),其中printf()可以格式化输出;

Java提供Scanner对象来方便输入,读取对应的类型可以使用:scanner.nextLine() / nextInt() / nextDouble() / …

在Java程序中,如果要根据条件来决定是否执行某一段代码,就需要if语句。

if语句的基本语法是:

1
2
3
if (条件) {
// 条件满足时执行
}

根据if的计算结果(true还是false),JVM决定是否执行if语句块(即花括号{}包含的所有语句)。

让我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
// 条件判断
public class Main {
public static void main(String[] args) {
int n = 70;
if (n >= 60) {
System.out.println("及格了");
}
System.out.println("END");
}
}

当条件n >= 60计算结果为true时,if语句块被执行,将打印"及格了",否则,if语句块将被跳过。修改n的值可以看到执行效果。

注意到if语句包含的块可以包含多条语句:

1
2
3
4
5
6
7
8
9
10
11
// 条件判断
public class Main {
public static void main(String[] args) {
int n = 70;
if (n >= 60) {
System.out.println("及格了");
System.out.println("恭喜你");
}
System.out.println("END");
}
}

if语句块只有一行语句时,可以省略花括号{}:

1
2
3
4
5
6
7
8
9
// 条件判断
public class Main {
public static void main(String[] args) {
int n = 70;
if (n >= 60)
System.out.println("及格了");
System.out.println("END");
}
}

但是,省略花括号并不总是一个好主意。假设某个时候,突然想给if语句块增加一条语句时:

1
2
3
4
5
6
7
8
9
10
// 条件判断
public class Main {
public static void main(String[] args) {
int n = 50;
if (n >= 60)
System.out.println("及格了");
System.out.println("恭喜你"); // 注意这条语句不是if语句块的一部分
System.out.println("END");
}
}

由于使用缩进格式,很容易把两行语句都看成if语句的执行块,但实际上只有第一行语句是if的执行块。在使用git这些版本控制系统自动合并时更容易出问题,所以不推荐忽略花括号的写法。

else

if语句还可以编写一个else { ... },当条件判断为false时,将执行else的语句块:

1
2
3
4
5
6
7
8
9
10
11
12
// 条件判断
public class Main {
public static void main(String[] args) {
int n = 70;
if (n >= 60) {
System.out.println("及格了");
} else {
System.out.println("挂科了");
}
System.out.println("END");
}
}

修改上述代码n的值,观察if条件为truefalse时,程序执行的语句块。

注意,else不是必须的。

还可以用多个if ... else if ...串联。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 条件判断
public class Main {
public static void main(String[] args) {
int n = 70;
if (n >= 90) {
System.out.println("优秀");
} else if (n >= 60) {
System.out.println("及格了");
} else {
System.out.println("挂科了");
}
System.out.println("END");
}
}

串联的效果其实相当于:

1
2
3
4
5
6
7
8
9
10
11
12
13
if (n >= 90) {
// n >= 90为true:
System.out.println("优秀");
} else {
// n >= 90为false:
if (n >= 60) {
// n >= 60为true:
System.out.println("及格了");
} else {
// n >= 60为false:
System.out.println("挂科了");
}
}

在串联使用多个if时,要特别注意判断顺序。观察下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 条件判断
public class Main {
public static void main(String[] args) {
int n = 100;
if (n >= 60) {
System.out.println("及格了");
} else if (n >= 90) {
System.out.println("优秀");
} else {
System.out.println("挂科了");
}
}
}

执行发现,n = 100时,满足条件n >= 90,但输出的不是"优秀",而是"及格了",原因是if语句从上到下执行时,先判断n >= 60成功后,后续else不再执行,因此,if (n >= 90)没有机会执行了。

正确的方式是按照判断范围从大到小依次判断:

1
2
3
4
5
6
7
8
// 从大到小依次判断:
if (n >= 90) {
// ...
} else if (n >= 60) {
// ...
} else {
// ...
}

或者改写成从小到大依次判断:

1
2
3
4
5
6
7
8
// 从小到大依次判断:
if (n < 60) {
// ...
} else if (n < 90) {
// ...
} else {
// ...
}

使用if时,还要特别注意边界条件。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 条件判断
public class Main {
public static void main(String[] args) {
int n = 90;
if (n > 90) {
System.out.println("优秀");
} else if (n >= 60) {
System.out.println("及格了");
} else {
System.out.println("挂科了");
}
}
}

假设我们期望90分或更高为“优秀”,上述代码输出的却是“及格”,原因是>>=效果是不同的。

前面讲过了浮点数在计算机中常常无法精确表示,并且计算可能出现误差,因此,判断浮点数相等用==判断不靠谱:

1
2
3
4
5
6
7
8
9
10
11
// 条件判断
public class Main {
public static void main(String[] args) {
double x = 1 - 9.0 / 10;
if (x == 0.1) {
System.out.println("x is 0.1");
} else {
System.out.println("x is NOT 0.1");
}
}
}

正确的方法是利用差值小于某个临界值来判断:

1
2
3
4
5
6
7
8
9
10
11
// 条件判断
public class Main {
public static void main(String[] args) {
double x = 1 - 9.0 / 10;
if (Math.abs(x - 0.1) < 0.00001) {
System.out.println("x is 0.1");
} else {
System.out.println("x is NOT 0.1");
}
}
}

判断引用类型相等

在Java中,判断值类型的变量是否相等,可以使用==运算符。但是,判断引用类型的变量是否相等,==表示“引用是否相等”,或者说,是否指向同一个对象。例如,下面的两个String类型,它们的内容是相同的,但是,分别指向不同的对象,用==判断,结果为false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 条件判断
public class Main {
public static void main(String[] args) {
String s1 = "hello";
String s2 = "HELLO".toLowerCase();
System.out.println(s1);
System.out.println(s2);
if (s1 == s2) {
System.out.println("s1 == s2");
} else {
System.out.println("s1 != s2");
}
}
}

要判断引用类型的变量内容是否相等,必须使用equals()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 条件判断
public class Main {
public static void main(String[] args) {
String s1 = "hello";
String s2 = "HELLO".toLowerCase();
System.out.println(s1);
System.out.println(s2);
if (s1.equals(s2)) {
System.out.println("s1 equals s2");
} else {
System.out.println("s1 not equals s2");
}
}
}

注意:执行语句s1.equals(s2)时,如果变量s1null,会报NullPointerException

1
2
3
4
5
6
7
8
9
// 条件判断
public class Main {
public static void main(String[] args) {
String s1 = null;
if (s1.equals("hello")) {
System.out.println("hello");
}
}
}

要避免NullPointerException错误,可以利用短路运算符&&

1
2
3
4
5
6
7
8
9
// 条件判断
public class Main {
public static void main(String[] args) {
String s1 = null;
if (s1 != null && s1.equals("hello")) {
System.out.println("hello");
}
}
}

还可以把一定不是null的对象"hello"放到前面:例如:if ("hello".equals(s)) { ... }

练习

请用if ... else编写一个程序,用于计算体质指数BMI,并打印结果。

1
BMI = 体重(kg) / 身高(m)的平方

BMI结果:

  • 过轻:低于18.5
  • 正常:18.5 ~ 25
  • 过重:25 ~ 28
  • 肥胖:28 ~ 32
  • 非常肥胖:高于32

下载练习

小结

if ... else可以做条件判断,else是可选的;

不推荐省略花括号{}

多个if ... else串联要特别注意判断顺序;

要注意if的边界条件;

要注意浮点数判断相等不能直接用==运算符;

引用类型判断内容相等要使用equals(),注意避免NullPointerException

除了if语句外,还有一种条件判断,是根据某个表达式的结果,分别去执行不同的分支。

例如,在游戏中,让用户选择选项:

  1. 单人模式
  2. 多人模式
  3. 退出游戏

这时,switch语句就派上用场了。

switch语句根据switch (表达式)计算的结果,跳转到匹配的case结果,然后继续执行后续语句,直到遇到break结束执行。

我们看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// switch
public class Main {
public static void main(String[] args) {
int option = 1;
switch (option) {
case 1:
System.out.println("Selected 1");
break;
case 2:
System.out.println("Selected 2");
break;
case 3:
System.out.println("Selected 3");
break;
}
}
}

修改option的值分别为123,观察执行结果。

如果option的值没有匹配到任何case,例如option = 99,那么,switch语句不会执行任何语句。这时,可以给switch语句加一个default,当没有匹配到任何case时,执行default

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// switch
public class Main {
public static void main(String[] args) {
int option = 99;
switch (option) {
case 1:
System.out.println("Selected 1");
break;
case 2:
System.out.println("Selected 2");
break;
case 3:
System.out.println("Selected 3");
break;
default:
System.out.println("Selected other");
break;
}
}
}

如果把switch语句翻译成if语句,那么上述的代码相当于:

1
2
3
4
5
6
7
8
9
if (option == 1) {
System.out.println("Selected 1");
} else if (option == 2) {
System.out.println("Selected 2");
} else if (option == 3) {
System.out.println("Selected 3");
} else {
System.out.println("Selected other");
}

对比if ... else if语句,对于多个==判断的情况,使用switch结构更加清晰。

同时注意,上述“翻译”只有在switch语句中对每个case正确编写了break语句才能对应得上。

使用switch时,注意case语句并没有花括号{},而且,case语句具有“穿透性”,漏写break将导致意想不到的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// switch
public class Main {
public static void main(String[] args) {
int option = 2;
switch (option) {
case 1:
System.out.println("Selected 1");
case 2:
System.out.println("Selected 2");
case 3:
System.out.println("Selected 3");
default:
System.out.println("Selected other");
}
}
}

option = 2时,将依次输出"Selected 2""Selected 3""Selected other",原因是从匹配到case 2开始,后续语句将全部执行,直到遇到break语句。因此,任何时候都不要忘记写break

如果有几个case语句执行的是同一组语句块,可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// switch
public class Main {
public static void main(String[] args) {
int option = 2;
switch (option) {
case 1:
System.out.println("Selected 1");
break;
case 2:
case 3:
System.out.println("Selected 2, 3");
break;
default:
System.out.println("Selected other");
break;
}
}
}

使用switch语句时,只要保证有breakcase的顺序不影响程序逻辑:

1
2
3
4
5
6
7
8
9
10
11
switch (option) {
case 3:
...
break;
case 2:
...
break;
case 1:
...
break;
}

但是仍然建议按照自然顺序排列,便于阅读。

switch语句还可以匹配字符串。字符串匹配时,是比较“内容相等”。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// switch
public class Main {
public static void main(String[] args) {
String fruit = "apple";
switch (fruit) {
case "apple":
System.out.println("Selected apple");
break;
case "pear":
System.out.println("Selected pear");
break;
case "mango":
System.out.println("Selected mango");
break;
default:
System.out.println("No fruit selected");
break;
}
}
}

switch语句还可以使用枚举类型,枚举类型我们在后面讲解。

编译检查

使用IDE时,可以自动检查是否漏写了break语句和default语句,方法是打开IDE的编译检查。

在Eclipse中,选择Preferences - Java - Compiler - Errors/Warnings - Potential programming problems,将以下检查标记为Warning:

  • ‘switch’ is missing ‘default’ case:缺少default语句时警告;
  • ‘switch’ case fall-through:某个case缺少break时警告。

在Idea中,选择Preferences - Editor - Inspections - Java - Control flow issues,将以下检查标记为Warning:

  • ‘switch’ statement without ‘default’ branch:缺少default语句时警告;
  • Fallthrough in ‘switch’ statement:某个case缺少break时警告。

switch语句存在问题时,即可在IDE中获得警告提示。

switch-note

switch表达式

使用switch时,如果遗漏了break,就会造成严重的逻辑错误,而且不易在源代码中发现错误。从Java 12开始,switch语句升级为更简洁的表达式语法,使用类似模式匹配(Pattern Matching)的方法,保证只有一种路径会被执行,并且不需要break语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// switch
public class Main {
public static void main(String[] args) {
String fruit = "apple";
switch (fruit) {
case "apple" -> System.out.println("Selected apple");
case "pear" -> System.out.println("Selected pear");
case "mango" -> {
System.out.println("Selected mango");
System.out.println("Good choice!");
}
default -> System.out.println("No fruit selected");
}
}
}

注意新语法使用->,如果有多条语句,需要用{}括起来。不要写break语句,因为新语法只会执行匹配的语句,没有穿透效应。

很多时候,我们还可能用switch语句给某个变量赋值。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
int opt;
switch (fruit) {
case "apple":
opt = 1;
break;
case "pear":
case "mango":
opt = 2;
break;
default:
opt = 0;
break;
}

使用新的switch语法,不但不需要break,还可以直接返回值。把上面的代码改写如下:

1
2
3
4
5
6
7
8
9
10
11
12
// switch
public class Main {
public static void main(String[] args) {
String fruit = "apple";
int opt = switch (fruit) {
case "apple" -> 1;
case "pear", "mango" -> 2;
default -> 0;
}; // 注意赋值语句要以;结束
System.out.println("opt = " + opt);
}
}

这样可以获得更简洁的代码。

yield

大多数时候,在switch表达式内部,我们会返回简单的值。

但是,如果需要复杂的语句,我们也可以写很多语句,放到{...}里,然后,用yield返回一个值作为switch语句的返回值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// yield
public class Main {
public static void main(String[] args) {
String fruit = "orange";
int opt = switch (fruit) {
case "apple" -> 1;
case "pear", "mango" -> 2;
default -> {
int code = fruit.hashCode();
yield code; // switch语句返回值
}
};
System.out.println("opt = " + opt);
}
}

练习

使用switch实现一个简单的石头、剪子、布游戏。

下载练习

小结

switch语句可以做多重选择,然后执行匹配的case语句后续代码;

switch的计算结果必须是整型、字符串或枚举类型;

注意千万不要漏写break,建议打开fall-through警告;

总是写上default,建议打开missing default警告;

从Java 14开始,switch语句正式升级为表达式,不再需要break,并且允许使用yield返回值。

while循环

循环语句就是让计算机根据条件做循环计算,在条件满足时继续循环,条件不满足时退出循环。

例如,计算从1到100的和:

1
1 + 2 + 3 + 4 + … + 100 = ?

除了用数列公式外,完全可以让计算机做100次循环累加。因为计算机的特点是计算速度非常快,我们让计算机循环一亿次也用不到1秒,所以很多计算的任务,人去算是算不了的,但是计算机算,使用循环这种简单粗暴的方法就可以快速得到结果。

我们先看Java提供的while条件循环。它的基本用法是:

1
2
3
4
while (条件表达式) {
循环语句
}
// 继续执行后续代码

while循环在每次循环开始前,首先判断条件是否成立。如果计算结果为true,就把循环体内的语句执行一遍,如果计算结果为false,那就直接跳到while循环的末尾,继续往下执行。

我们用while循环来累加1到100,可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
// while
public class Main {
public static void main(String[] args) {
int sum = 0; // 累加的和,初始化为0
int n = 1;
while (n <= 100) { // 循环条件是n <= 100
sum = sum + n; // 把n累加到sum中
n ++; // n自身加1
}
System.out.println(sum); // 5050
}
}

注意到while循环是先判断循环条件,再循环,因此,有可能一次循环都不做。

对于循环条件判断,以及自增变量的处理,要特别注意边界条件。思考一下下面的代码为何没有获得正确结果:

1
2
3
4
5
6
7
8
9
10
11
12
// while
public class Main {
public static void main(String[] args) {
int sum = 0;
int n = 0;
while (n <= 100) {
n ++;
sum = sum + n;
}
System.out.println(sum);
}
}

如果循环条件永远满足,那这个循环就变成了死循环。死循环将导致100%的CPU占用,用户会感觉电脑运行缓慢,所以要避免编写死循环代码。

如果循环条件的逻辑写得有问题,也会造成意料之外的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
// while
public class Main {
public static void main(String[] args) {
int sum = 0;
int n = 1;
while (n > 0) {
sum = sum + n;
n ++;
}
System.out.println(n); // -2147483648
System.out.println(sum);
}
}

表面上看,上面的while循环是一个死循环,但是,Java的int类型有最大值,达到最大值后,再加1会变成负数,结果,意外退出了while循环。

练习

使用while计算从mn的和:

1
2
3
4
5
6
7
8
9
10
11
12
// while
public class Main {
public static void main(String[] args) {
int sum = 0;
int m = 20;
int n = 100;
// 使用while计算M+...+N:
while (false) {
}
System.out.println(sum);
}
}

下载练习

小结

while循环先判断循环条件是否满足,再执行循环语句;

while循环可能一次都不执行;

编写循环时要注意循环条件,并避免死循环。



在Java中,while循环是先判断循环条件,再执行循环。而另一种do while循环则是先执行循环,再判断条件,条件满足时继续循环,条件不满足时退出。它的用法是:

1
2
3
do {
执行循环语句
} while (条件表达式);

可见,do while循环会至少循环一次。

我们把对1到100的求和用do while循环改写一下:

1
2
3
4
5
6
7
8
9
10
11
12
// do-while
public class Main {
public static void main(String[] args) {
int sum = 0;
int n = 1;
do {
sum = sum + n;
n ++;
} while (n <= 100);
System.out.println(sum);
}
}

使用do while循环时,同样要注意循环条件的判断。

练习

使用do while循环计算从mn的和。

1
2
3
4
5
6
7
8
9
10
11
12
// do while
public class Main {
public static void main(String[] args) {
int sum = 0;
int m = 20;
int n = 100;
// 使用do while计算M+...+N:
do {
} while (false);
System.out.println(sum);
}
}

下载练习

小结

do while先执行循环,再判断条件;

do while循环会至少执行一次。

除了whiledo while循环,Java使用最广泛的是for循环。

for循环的功能非常强大,它使用计数器实现循环。for循环会先初始化计数器,然后,在每次循环前检测循环条件,在每次循环后更新计数器。计数器变量通常命名为i

我们把1到100求和用for循环改写一下:

1
2
3
4
5
6
7
8
9
10
// for
public class Main {
public static void main(String[] args) {
int sum = 0;
for (int i=1; i<=100; i++) {
sum = sum + i;
}
System.out.println(sum);
}
}

for循环执行前,会先执行初始化语句int i=1,它定义了计数器变量i并赋初始值为1,然后,循环前先检查循环条件i<=100,循环后自动执行i++,因此,和while循环相比,for循环把更新计数器的代码统一放到了一起。在for循环的循环体内部,不需要去更新变量i

因此,for循环的用法是:

1
2
3
for (初始条件; 循环检测条件; 循环后更新计数器) {
// 执行语句
}

如果我们要对一个整型数组的所有元素求和,可以用for循环实现:

1
2
3
4
5
6
7
8
9
10
11
12
// for
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 4, 9, 16, 25 };
int sum = 0;
for (int i=0; i<ns.length; i++) {
System.out.println("i = " + i + ", ns[i] = " + ns[i]);
sum = sum + ns[i];
}
System.out.println("sum = " + sum);
}
}

上面代码的循环条件是i<ns.length。因为ns数组的长度是5,因此,当循环5次后,i的值被更新为5,就不满足循环条件,因此for循环结束。

思考

如果把循环条件改为i<=ns.length,会出现什么问题?

注意for循环的初始化计数器总是会被执行,并且for循环也可能循环0次。

使用for循环时,千万不要在循环体内修改计数器!在循环体中修改计数器常常导致莫名其妙的逻辑错误。对于下面的代码:

1
2
3
4
5
6
7
8
9
10
// for
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 4, 9, 16, 25 };
for (int i=0; i<ns.length; i++) {
System.out.println(ns[i]);
i = i + 1;
}
}
}

虽然不会报错,但是,数组元素只打印了一半,原因是循环内部的i = i + 1导致了计数器变量每次循环实际上加了2(因为for循环还会自动执行i++)。因此,在for循环中,不要修改计数器的值。计数器的初始化、判断条件、每次循环后的更新条件统一放到for()语句中可以一目了然。

如果希望只访问索引号为偶数的数组元素,应该把for循环改写为:

1
2
3
4
int[] ns = { 1, 4, 9, 16, 25 };
for (int i=0; i<ns.length; i=i+2) {
System.out.println(ns[i]);
}

通过更新计数器的语句i=i+2就达到了这个效果,从而避免了在循环体内去修改变量i

使用for循环时,计数器变量i要尽量定义在for循环中:

1
2
3
4
5
6
int[] ns = { 1, 4, 9, 16, 25 };
for (int i=0; i<ns.length; i++) {
System.out.println(ns[i]);
}
// 无法访问i
int n = i; // compile error!

如果变量i定义在for循环外:

1
2
3
4
5
6
7
int[] ns = { 1, 4, 9, 16, 25 };
int i;
for (i=0; i<ns.length; i++) {
System.out.println(ns[i]);
}
// 仍然可以使用i
int n = i;

那么,退出for循环后,变量i仍然可以被访问,这就破坏了变量应该把访问范围缩到最小的原则。

灵活使用for循环

for循环还可以缺少初始化语句、循环条件和每次循环更新语句,例如:

1
2
3
4
// 不设置结束条件:
for (int i=0; ; i++) {
...
}
1
2
3
4
// 不设置结束条件和更新语句:
for (int i=0; ;) {
...
}
1
2
3
4
// 什么都不设置:
for (;;) {
...
}

通常不推荐这样写,但是,某些情况下,是可以省略for循环的某些语句的。

for each循环

for循环经常用来遍历数组,因为通过计数器可以根据索引来访问数组的每个元素:

1
2
3
4
int[] ns = { 1, 4, 9, 16, 25 };
for (int i=0; i<ns.length; i++) {
System.out.println(ns[i]);
}

但是,很多时候,我们实际上真正想要访问的是数组每个元素的值。Java还提供了另一种for each循环,它可以更简单地遍历数组:

1
2
3
4
5
6
7
8
9
// for each
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 4, 9, 16, 25 };
for (int n : ns) {
System.out.println(n);
}
}
}

for循环相比,for each循环的变量n不再是计数器,而是直接对应到数组的每个元素。for each循环的写法也更简洁。但是,for each循环无法指定遍历顺序,也无法获取数组的索引。

除了数组外,for each循环能够遍历所有“可迭代”的数据类型,包括后面会介绍的ListMap等。

练习1

给定一个数组,请用for循环倒序输出每一个元素:

1
2
3
4
5
6
7
8
9
// for
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 4, 9, 16, 25 };
for (int i=?; ???; ???) {
System.out.println(ns[i]);
}
}
}

练习2

利用for each循环对数组每个元素求和:

1
2
3
4
5
6
7
8
9
10
11
// for each
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 4, 9, 16, 25 };
int sum = 0;
for (???) {
// TODO
}
System.out.println(sum); // 55
}
}

练习3

圆周率π可以使用公式计算:

请利用for循环计算π:

1
2
3
4
5
6
7
8
9
10
// for
public class Main {
public static void main(String[] args) {
double pi = 0;
for (???) {
// TODO
}
System.out.println(pi);
}
}

下载练习

小结

for循环通过计数器可以实现复杂循环;

for each循环可以直接遍历数组的每个元素;

最佳实践:计数器变量定义在for循环内部,循环体内部不修改计数器;

break和continue

无论是while循环还是for循环,有两个特别的语句可以使用,就是break语句和continue语句。

break

在循环过程中,可以使用break语句跳出当前循环。我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
// break
public class Main {
public static void main(String[] args) {
int sum = 0;
for (int i=1; ; i++) {
sum = sum + i;
if (i == 100) {
break;
}
}
System.out.println(sum);
}
}

使用for循环计算从1到100时,我们并没有在for()中设置循环退出的检测条件。但是,在循环内部,我们用if判断,如果i==100,就通过break退出循环。

因此,break语句通常都是配合if语句使用。要特别注意,break语句总是跳出自己所在的那一层循环。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// break
public class Main {
public static void main(String[] args) {
for (int i=1; i<=10; i++) {
System.out.println("i = " + i);
for (int j=1; j<=10; j++) {
System.out.println("j = " + j);
if (j >= i) {
break;
}
}
// break跳到这里
System.out.println("breaked");
}
}
}

上面的代码是两个for循环嵌套。因为break语句位于内层的for循环,因此,它会跳出内层for循环,但不会跳出外层for循环。

continue

break会跳出当前循环,也就是整个循环都不会执行了。而continue则是提前结束本次循环,直接继续执行下次循环。我们看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// continue
public class Main {
public static void main(String[] args) {
int sum = 0;
for (int i=1; i<=10; i++) {
System.out.println("begin i = " + i);
if (i % 2 == 0) {
continue; // continue语句会结束本次循环
}
sum = sum + i;
System.out.println("end i = " + i);
}
System.out.println(sum); // 25
}
}

注意观察continue语句的效果。当i为奇数时,完整地执行了整个循环,因此,会打印begin i=1end i=1。在i为偶数时,continue语句会提前结束本次循环,因此,会打印begin i=2但不会打印end i=2

在多层嵌套的循环中,continue语句同样是结束本次自己所在的循环。

小结

break语句可以跳出当前循环;

break语句通常配合if,在满足条件时提前结束整个循环;

break语句总是跳出最近的一层循环;

continue语句可以提前结束本次循环;

continue语句通常配合if,在满足条件时提前结束本次循环。



我们在Java程序基础里介绍了数组这种数据类型。有了数组,我们还需要来操作它。而数组最常见的一个操作就是遍历。

通过for循环就可以遍历数组。因为数组的每个元素都可以通过索引来访问,因此,使用标准的for循环可以完成一个数组的遍历:

1
2
3
4
5
6
7
8
9
10
// 遍历数组
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 4, 9, 16, 25 };
for (int i=0; i<ns.length; i++) {
int n = ns[i];
System.out.println(n);
}
}
}

为了实现for循环遍历,初始条件为i=0,因为索引总是从0开始,继续循环的条件为i<ns.length,因为当i=ns.length时,i已经超出了索引范围(索引范围是0 ~ ns.length-1),每次循环后,i++

第二种方式是使用for each循环,直接迭代数组的每个元素:

1
2
3
4
5
6
7
8
9
// 遍历数组
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 4, 9, 16, 25 };
for (int n : ns) {
System.out.println(n);
}
}
}

注意:在for (int n : ns)循环中,变量n直接拿到ns数组的元素,而不是索引。

显然for each循环更加简洁。但是,for each循环无法拿到数组的索引,因此,到底用哪一种for循环,取决于我们的需要。

打印数组内容

直接打印数组变量,得到的是数组在JVM中的引用地址:

1
2
int[] ns = { 1, 1, 2, 3, 5, 8 };
System.out.println(ns); // 类似 [I@7852e922

这并没有什么意义,因为我们希望打印的数组的元素内容。因此,使用for each循环来打印它:

1
2
3
4
int[] ns = { 1, 1, 2, 3, 5, 8 };
for (int n : ns) {
System.out.print(n + ", ");
}

使用for each循环打印也很麻烦。幸好Java标准库提供了Arrays.toString(),可以快速打印数组内容:

1
2
3
4
5
6
7
8
9
// 遍历数组
import java.util.Arrays;

public class Main {
public static void main(String[] args) {
int[] ns = { 1, 1, 2, 3, 5, 8 };
System.out.println(Arrays.toString(ns));
}
}

练习

请按倒序遍历数组并打印每个元素:

1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 4, 9, 16, 25 };
// 倒序打印数组元素:
for (???) {
System.out.println(???);
}
}
}

下载练习

小结

遍历数组可以使用for循环,for循环可以访问数组索引,for each循环直接迭代每个数组元素,但无法获取索引;

使用Arrays.toString()可以快速获取数组内容。

对数组进行排序是程序中非常基本的需求。常用的排序算法有冒泡排序、插入排序和快速排序等。

我们来看一下如何使用冒泡排序算法对一个整型数组从小到大进行排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 冒泡排序
import java.util.Arrays;

public class Main {
public static void main(String[] args) {
int[] ns = { 28, 12, 89, 73, 65, 18, 96, 50, 8, 36 };
// 排序前:
System.out.println(Arrays.toString(ns));
for (int i = 0; i < ns.length - 1; i++) {
for (int j = 0; j < ns.length - i - 1; j++) {
if (ns[j] > ns[j+1]) {
// 交换ns[j]和ns[j+1]:
int tmp = ns[j];
ns[j] = ns[j+1];
ns[j+1] = tmp;
}
}
}
// 排序后:
System.out.println(Arrays.toString(ns));
}
}

冒泡排序的特点是,每一轮循环后,最大的一个数被交换到末尾,因此,下一轮循环就可以“刨除”最后的数,每一轮循环都比上一轮循环的结束位置靠前一位。

另外,注意到交换两个变量的值必须借助一个临时变量。像这么写是错误的:

1
2
3
4
5
int x = 1;
int y = 2;

x = y; // x现在是2
y = x; // y现在还是2

正确的写法是:

1
2
3
4
5
6
int x = 1;
int y = 2;

int t = x; // 把x的值保存在临时变量t中, t现在是1
x = y; // x现在是2
y = t; // y现在是t的值1

实际上,Java的标准库已经内置了排序功能,我们只需要调用JDK提供的Arrays.sort()就可以排序:

1
2
3
4
5
6
7
8
9
10
// 排序
import java.util.Arrays;

public class Main {
public static void main(String[] args) {
int[] ns = { 28, 12, 89, 73, 65, 18, 96, 50, 8, 36 };
Arrays.sort(ns);
System.out.println(Arrays.toString(ns));
}
}

必须注意,对数组排序实际上修改了数组本身。例如,排序前的数组是:

1
int[] ns = { 9, 3, 6, 5 };

在内存中,这个整型数组表示如下:

1
2
3
      ┌───┬───┬───┬───┐
ns───▶│ 9 │ 3 │ 6 │ 5 │
└───┴───┴───┴───┘

当我们调用Arrays.sort(ns);后,这个整型数组在内存中变为:

1
2
3
      ┌───┬───┬───┬───┐
ns───▶│ 3 │ 5 │ 6 │ 9 │
└───┴───┴───┴───┘

即变量ns指向的数组内容已经被改变了。

如果对一个字符串数组进行排序,例如:

1
String[] ns = { "banana", "apple", "pear" };

排序前,这个数组在内存中表示如下:

1
2
3
4
5
6
7
8
                   ┌──────────────────────────────────┐
┌───┼──────────────────────┐ │
│ │ ▼ ▼
┌───┬─┴─┬─┴─┬───┬────────┬───┬───────┬───┬──────┬───┐
ns ─────▶│░░░│░░░│░░░│ │"banana"│ │"apple"│ │"pear"│ │
└─┬─┴───┴───┴───┴────────┴───┴───────┴───┴──────┴───┘
│ ▲
└─────────────────┘

调用Arrays.sort(ns);排序后,这个数组在内存中表示如下:

1
2
3
4
5
6
7
8
                   ┌──────────────────────────────────┐
┌───┼──────────┐ │
│ │ ▼ ▼
┌───┬─┴─┬─┴─┬───┬────────┬───┬───────┬───┬──────┬───┐
ns ─────▶│░░░│░░░│░░░│ │"banana"│ │"apple"│ │"pear"│ │
└─┬─┴───┴───┴───┴────────┴───┴───────┴───┴──────┴───┘
│ ▲
└──────────────────────────────┘

原来的3个字符串在内存中均没有任何变化,但是ns数组的每个元素指向变化了。

练习

请思考如何实现对数组进行降序排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 降序排序
import java.util.Arrays;

public class Main {
public static void main(String[] args) {
int[] ns = { 28, 12, 89, 73, 65, 18, 96, 50, 8, 36 };
// 排序前:
System.out.println(Arrays.toString(ns));
// TODO:
// 排序后:
System.out.println(Arrays.toString(ns));
if (Arrays.toString(ns).equals("[96, 89, 73, 65, 50, 36, 28, 18, 12, 8]")) {
System.out.println("测试成功");
} else {
System.out.println("测试失败");
}
}
}

下载练习

小结

常用的排序算法有冒泡排序、插入排序和快速排序等;

冒泡排序使用两层for循环实现排序;

交换两个变量的值需要借助一个临时变量;

可以直接使用Java标准库提供的Arrays.sort()进行排序;

对数组排序会直接修改数组本身。

二维数组

二维数组就是数组的数组。定义一个二维数组如下:

1
2
3
4
5
6
7
8
9
10
11
// 二维数组
public class Main {
public static void main(String[] args) {
int[][] ns = {
{ 1, 2, 3, 4 },
{ 5, 6, 7, 8 },
{ 9, 10, 11, 12 }
};
System.out.println(ns.length); // 3
}
}

因为ns包含3个数组,因此,ns.length3。实际上ns在内存中的结构如下:

1
2
3
4
5
6
7
8
9
                    ┌───┬───┬───┬───┐
┌───┐ ┌──▶│ 1 │ 2 │ 3 │ 4 │
ns ─────▶│░░░│──┘ └───┴───┴───┴───┘
├───┤ ┌───┬───┬───┬───┐
│░░░│─────▶│ 5 │ 6 │ 7 │ 8 │
├───┤ └───┴───┴───┴───┘
│░░░│──┐ ┌───┬───┬───┬───┐
└───┘ └──▶│ 9 │10 │11 │12 │
└───┴───┴───┴───┘

如果我们定义一个普通数组arr0,然后把ns[0]赋值给它:

1
2
3
4
5
6
7
8
9
10
11
12
// 二维数组
public class Main {
public static void main(String[] args) {
int[][] ns = {
{ 1, 2, 3, 4 },
{ 5, 6, 7, 8 },
{ 9, 10, 11, 12 }
};
int[] arr0 = ns[0];
System.out.println(arr0.length); // 4
}
}

实际上arr0就获取了ns数组的第0个元素。因为ns数组的每个元素也是一个数组,因此,arr0指向的数组就是{ 1, 2, 3, 4 }。在内存中,结构如下:

1
2
3
4
5
6
7
8
9
10
11
            arr0 ─────┐

┌───┬───┬───┬───┐
┌───┐ ┌──▶│ 1 │ 2 │ 3 │ 4 │
ns ─────▶│░░░│──┘ └───┴───┴───┴───┘
├───┤ ┌───┬───┬───┬───┐
│░░░│─────▶│ 5 │ 6 │ 7 │ 8 │
├───┤ └───┴───┴───┴───┘
│░░░│──┐ ┌───┬───┬───┬───┐
└───┘ └──▶│ 9 │10 │11 │12 │
└───┴───┴───┴───┘

访问二维数组的某个元素需要使用array[row][col],例如:

1
System.out.println(ns[1][2]); // 7

二维数组的每个数组元素的长度并不要求相同,例如,可以这么定义ns数组:

1
2
3
4
5
int[][] ns = {
{ 1, 2, 3, 4 },
{ 5, 6 },
{ 7, 8, 9 }
};

这个二维数组在内存中的结构如下:

1
2
3
4
5
6
7
8
9
                    ┌───┬───┬───┬───┐
┌───┐ ┌──▶│ 1 │ 2 │ 3 │ 4 │
ns ─────▶│░░░│──┘ └───┴───┴───┴───┘
├───┤ ┌───┬───┐
│░░░│─────▶│ 5 │ 6 │
├───┤ └───┴───┘
│░░░│──┐ ┌───┬───┬───┐
└───┘ └──▶│ 7 │ 8 │ 9 │
└───┴───┴───┘

要打印一个二维数组,可以使用两层嵌套的for循环:

1
2
3
4
5
6
7
for (int[] arr : ns) {
for (int n : arr) {
System.out.print(n);
System.out.print(', ');
}
System.out.println();
}

或者使用Java标准库的Arrays.deepToString()

1
2
3
4
5
6
7
8
9
10
11
12
13
// 二维数组
import java.util.Arrays;

public class Main {
public static void main(String[] args) {
int[][] ns = {
{ 1, 2, 3, 4 },
{ 5, 6 },
{ 7, 8, 9 }
};
System.out.println(Arrays.deepToString(ns));
}
}

三维数组

三维数组就是二维数组的数组。可以这么定义一个三维数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int[][][] ns = {
{
{1, 2, 3},
{4, 5, 6},
{7, 8, 9}
},
{
{10, 11},
{12, 13}
},
{
{14, 15, 16},
{17, 18}
}
};

它在内存中的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
                              ┌───┬───┬───┐
┌───┐ ┌──▶│ 1 │ 2 │ 3 │
┌──▶│░░░│──┘ └───┴───┴───┘
│ ├───┤ ┌───┬───┬───┐
│ │░░░│─────▶│ 4 │ 5 │ 6 │
│ ├───┤ └───┴───┴───┘
│ │░░░│──┐ ┌───┬───┬───┐
┌───┐ │ └───┘ └──▶│ 7 │ 8 │ 9 │
ns ────▶│░░░│──┘ └───┴───┴───┘
├───┤ ┌───┐ ┌───┬───┐
│░░░│─────▶│░░░│─────▶│10 │11 │
├───┤ ├───┤ └───┴───┘
│░░░│──┐ │░░░│──┐ ┌───┬───┐
└───┘ │ └───┘ └──▶│12 │13 │
│ └───┴───┘
│ ┌───┐ ┌───┬───┬───┐
└──▶│░░░│─────▶│14 │15 │16 │
├───┤ └───┴───┴───┘
│░░░│──┐ ┌───┬───┐
└───┘ └──▶│17 │18 │
└───┴───┘

如果我们要访问三维数组的某个元素,例如,ns[2][0][1],只需要顺着定位找到对应的最终元素15即可。

理论上,我们可以定义任意的N维数组。但在实际应用中,除了二维数组在某些时候还能用得上,更高维度的数组很少使用。

练习

使用二维数组可以表示一组学生的各科成绩,请计算所有学生的平均分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Main {
public static void main(String[] args) {
// 用二维数组表示的学生成绩:
int[][] scores = {
{ 82, 90, 91 }, // 学生甲的语数英成绩
{ 68, 72, 64 }, // 学生乙的语数英成绩
{ 95, 91, 89 }, // ...
{ 67, 52, 60 },
{ 79, 81, 85 },
};
// TODO:
double average = 0;
System.out.println(average);
if (Math.abs(average - 77.733333) < 0.000001) {
System.out.println("测试成功");
} else {
System.out.println("测试失败");
}
}
}

下载练习

小结

二维数组就是数组的数组,三维数组就是二维数组的数组;

多维数组的每个数组元素长度都不要求相同;

打印多维数组可以使用Arrays.deepToString()

最常见的多维数组是二维数组,访问二维数组的一个元素使用array[row][col]

Java程序的入口是main方法,而main方法可以接受一个命令行参数,它是一个String[]数组。

这个命令行参数由JVM接收用户输入并传给main方法:

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

我们可以利用接收到的命令行参数,根据不同的参数执行不同的代码。例如,实现一个-version参数,打印程序版本号:

1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
for (String arg : args) {
if ("-version".equals(arg)) {
System.out.println("v 1.0");
break;
}
}
}
}

上面这个程序必须在命令行执行,我们先编译它:

1
$ javac Main.java

然后,执行的时候,给它传递一个-version参数:

1
2
$ java Main -version
v 1.0

这样,程序就可以根据传入的命令行参数,作出不同的响应。

小结

命令行参数类型是String[]数组;

命令行参数由JVM接收用户输入并传给main方法;

如何解析命令行参数需要由程序自己实现。

留言與分享

CentOs7.3 安装 JDK1.8

分類 编程语言, Java

下载

下载Linux环境下的jdk1.8,请去(官网)中下载jdk的安装文件

上传在 /opt 目录

解压

1
2
$ cd /opt
$ tar zxvf jdk-8u144-linux-x64.tar.gz

重命名

1
2
$ cd /opt
$ mv jdk1.8.0_144/ /lib/jvm

配置环境变量

如果是对所有的用户都生效就修改vi /etc/profile 文件

如果只针对当前用户生效就修改 vi ~/.bahsrc 文件

在文件底部添加如下代码,如果上一步的路径和我的不一致要改一下

1
$ vi /etc/profile

环境变量配置内容

1
2
3
4
5
#jdk
export JAVA_HOME=/lib/jvm
export JRE_HOME=${JAVA_HOME}/jre
export CLASSPATH=.:${JAVA_HOME}/lib:${JRE_HOME}/lib
export PATH=${JAVA_HOME}/bin:$PATH

使环境变量生效

运行 source /etc/profile使/etc/profile文件生效

1
$ source /etc/profile

或者

1
$ source ~/.bashrc

验证

使用 java -version 和 javac -version 命令查看jdk版本及其相关信息,不会出现command not found错误,且显示的版本信息与前面安装的一致

1
2
3
4
[root@localhost ~]# java -version
java version "1.8.0_144"
Java(TM) SE Runtime Environment (build 1.8.0_144-b01)
Java HotSpot(TM) 64-Bit Server VM (build 25.144-b01, mixed mode)

留言與分享

Spring Boot 中使用 kafka

分類 database, hadoop

Kafka 是一种高吞吐的分布式发布订阅消息系统,能够替代传统的消息队列用于解耦合数据处理,缓存未处理消息等,同时具有更高的吞吐率,支持分区、多副本、冗余,因此被广泛用于大规模消息数据处理应用。Kafka 支持Java 及多种其它语言客户端,可与Hadoop、Storm、Spark等其它大数据工具结合使用。

环境安装

搭建高吞吐量 Kafka 分布式发布订阅消息 集群

测试用例

Github 代码

代码我已放到 Github ,导入spring-boot-kafka 项目

github https://github.com/souyunku/spring-boot-examples/tree/master/spring-boot-kafka

添加依赖

在项目中添加 kafka-clients 依赖

1
2
3
4
5
6
7
8
9
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>0.10.2.0</version>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>

启用 kafka

1
2
3
4
5
@Configuration
@EnableKafka
public class KafkaConfiguration {

}

消息生产者

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
35
36
@Component
public class MsgProducer {

private static final Logger log = LoggerFactory.getLogger(MsgProducer.class);

@Autowired
private KafkaTemplate<String, String> kafkaTemplate;

public void sendMessage(String topicName, String jsonData) {
log.info("向kafka推送数据:[{}]", jsonData);
try {
kafkaTemplate.send(topicName, jsonData);
} catch (Exception e) {
log.error("发送数据出错!!!{}{}", topicName, jsonData);
log.error("发送数据出错=====>", e);
}

//消息发送的监听器,用于回调返回信息
kafkaTemplate.setProducerListener(new ProducerListener<String, String>() {
@Override
public void onSuccess(String topic, Integer partition, String key, String value, RecordMetadata recordMetadata) {
}

@Override
public void onError(String topic, Integer partition, String key, String value, Exception exception) {
}

@Override
public boolean isInterestedInSuccess() {
log.info("数据发送完毕");
return false;
}
});
}

}

消息消费者

1
2
3
4
5
6
7
8
9
10
@Component
public class MsgConsumer {

@KafkaListener(topics = {"topic-1","topic-2"})
public void processMessage(String content) {

System.out.println("消息被消费"+content);
}

}

参数配置

application.properties

1
2
3
4
5
6
7
8
9
10
11
#kafka
# 指定kafka 代理地址,可以多个
spring.kafka.bootstrap-servers=YZ-PTEST-APP-HADOOP-02:9092,YZ-PTEST-APP-HADOOP-04:9092
# 指定listener 容器中的线程数,用于提高并发量
spring.kafka.listener.concurrency=3
# 每次批量发送消息的数量
spring.kafka.producer.batch-size=1000
# 指定默认消费者group id
spring.kafka.consumer.group-id=myGroup
# 指定默认topic id
spring.kafka.template.default-topic=topic-1

启动服务

1
2
3
4
5
6
7
8
@SpringBootApplication
@ComponentScan(value = {"io.ymq.kafka"})
public class Startup {

public static void main(String[] args) {
SpringApplication.run(Startup.class, args);
}
}

单元测试

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
import io.ymq.kafka.MsgProducer;
import io.ymq.kafka.run.Startup;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
/**
* 描述: 测试 kafka
*
* @author yanpenglei
* @create 2017-10-16 18:45
**/
@RunWith(SpringRunner.class)
@SpringBootTest(classes = Startup.class)
public class BaseTest {

@Autowired
private MsgProducer msgProducer;

@Test
public void test() throws Exception {

msgProducer.sendMessage("topic-1", "topic--------1");
msgProducer.sendMessage("topic-2", "topic--------2");
}
}

消息生产者,响应

1
2
3
4
2017-10-17 15:54:44.814  INFO 2960 --- [           main] io.ymq.kafka.MsgProducer                 : 向kafka推送数据:[topic--------1]
2017-10-17 15:54:44.860 INFO 2960 --- [ main] io.ymq.kafka.MsgProducer : 向kafka推送数据:[topic--------2]
2017-10-17 15:54:44.878 INFO 2960 --- [ad | producer-1] io.ymq.kafka.MsgProducer : 数据发送完毕
2017-10-17 15:54:44.878 INFO 2960 --- [ad | producer-1] io.ymq.kafka.MsgProducer : 数据发送完毕

消息消费者,响应

1
2
消息被消费topic--------1
消息被消费topic--------2

代码我已放到 Github ,导入spring-boot-kafka 项目

github https://github.com/souyunku/spring-boot-examples/tree/master/spring-boot-kafka

遇到一些坑

1
[2017-10-16 19:20:08.340] - 14884 严重 [main] --- org.springframework.kafka.support.LoggingProducerListener: Exception thrown when sending a message with key='null' and payload='topic--------2' to topic topic-2:

经调试发现 kafka 连接是用的主机名,所以修改 hosts

1
2
3
4
C:\Windows\System32\drivers\etc\hosts

10.32.32.149 YZ-PTEST-APP-HADOOP-02
10.32.32.154 YZ-PTEST-APP-HADOOP-04

留言與分享

搭建高吞吐量 Kafka 分布式发布订阅消息 集群

简介

Kafka 是一种高吞吐的分布式发布订阅消息系统,能够替代传统的消息队列用于解耦合数据处理,缓存未处理消息等,同时具有更高的吞吐率,支持分区、多副本、冗余,因此被广泛用于大规模消息数据处理应用。Kafka 支持Java 及多种其它语言客户端,可与Hadoop、Storm、Spark等其它大数据工具结合使用。

环境

Zookeeper集群: 192.168.252.121:2181,192.168.252.122:2181,192.168.252.123:2181

kafka 集群: 192.168.252.124 , 192.168.252.125 , 192.168.252.126

kafka-manager: 192.168.252.127

主机名修改

CentOs7.3 修改主机名

ssh 免密登录

CentOs7.3 ssh 免密登录

安装 JDK1.8

CentOs7.3 安装 JDK1.8

搭建 Zookeeper 集群

CentOs7.3 搭建 ZooKeeper-3.4.9 Cluster 集群服务

Zookeeper集群: 192.168.252.121:2181,192.168.252.122:2181,192.168.252.123:2181

主机名依次被我修改成: node1,node2,node3

搭建 kafka 集群

kafka 集群: 192.168.252.124 , 192.168.252.125 , 192.168.252.126

主机名依次被我修改成: node4,node5,node6

1.下载代码

kafka 官网下载 http://kafka.apache.org/downloads

下载最新版本的kafka ,

清华镜像:https://mirrors.tuna.tsinghua.edu.cn/apache/kafka/

阿里镜像:https://mirrors.aliyun.com/apache/kafka/

1
2
3
4
$ cd /opt
$ wget https://mirrors.tuna.tsinghua.edu.cn/apache/kafka/0.11.0.0/kafka_2.12-0.11.0.0.tgz
$ tar -zxvf kafka_2.12-0.11.0.0.tgz
$ cd kafka_2.12-0.11.0.0

2.修改配置

在 node4 操作

1
$ vi /opt/kafka_2.12-0.11.0.0/config/server.properties
1
2
3
4
broker.id=0  每台服务器不能重复

#设置zookeeper的集群地址
zookeeper.connect=192.168.252.121:2181,192.168.252.122:2181,192.168.252.123:2181

把配置复制到 node5,node6 集群

1
$ for a in {5..6} ; do scp -r /opt/kafka_2.12-0.11.0.0/ node$a:/opt/kafka_2.12-0.11.0.0 ; done

修改 node5,node6 集群的broker.id

1
$ vi /opt/kafka_2.12-0.11.0.0/config/server.properties

3.启动kafka

在 node1,启动 Kafka使用的 ZooKeeper,所以先启动ZooKeeper服务器

1
$ for a in {1..3} ; do ssh node$a "source /etc/profile;  /opt/zookeeper-3.4.9/bin/zkServer.sh start" ; done

现在 node4 启动Kafka服务器

1
$ for a in {4..6} ; do ssh node$a "source /etc/profile;  /opt/kafka_2.12-0.11.0.0/bin/kafka-server-start.sh /opt/kafka_2.12-0.11.0.0/config/server.properties" ; done

或者后台启动运行,日志查看去Kafka解压目录有个log 文件夹查看

1
$ for a in {4..6} ; do ssh node$a "source /etc/profile; nohup  /opt/kafka_2.12-0.11.0.0/bin/kafka-server-start.sh /opt/kafka_2.12-0.11.0.0/config/server.properties > /dev/null 2>&1 &" ; done

查看进程,Kafka 是否启动成功

1
2
3
$ jps
3825 Kafka
6360 Jps

如果报错删除

1
2
3
kafka.common.KafkaException: Failed to acquire lock on file .lock in /tmp/kafka-logs. A Kafka instance in another process or thread is using this directory.

$ rm -rf /tmp/kafka-logs

4.创建主题

1
$ /opt/kafka_2.12-0.11.0.0/bin/kafka-topics.sh --create --zookeeper 192.168.252.121:2181,192.168.252.122:2181,192.168.252.123:2181 --replication-factor 2 --partitions 1 --topic ymq

--replication-factor 2 #复制两份
--partitions 1 #创建1个分区
--topic #主题为ymq

运行list topic命令,可以看到该主题:

1
$ /opt/kafka_2.12-0.11.0.0/bin/kafka-topics.sh --list --zookeeper 192.168.252.121:2181,192.168.252.122:2181,192.168.252.123:2181

5.生产消息

Kafka附带一个命令行客户端,它将从文件或标准输入中输入,并将其作为消息发送到Kafka群集。默认情况下,每行将作为单独的消息发送。

node5 运行生产者,然后在控制台中输入一些消息以发送到服务器。

1
2
$ /opt/kafka_2.12-0.11.0.0/bin/kafka-console-producer.sh --broker-list localhost:9092 --topic ymq
>www.ymq.io

6.消费消息

node6 运行消费者,将把消息转储到标准输出。

1
2
3
$ /opt/kafka_2.12-0.11.0.0/bin/kafka-console-consumer.sh --zookeeper 192.168.252.121:2181,192.168.252.122:2181,192.168.252.123:2181  --topic ymq --from-beginning
Using the ConsoleConsumer with old consumer is deprecated and will be removed in a future major release. Consider using the new consumer by passing [bootstrap-server] instead of [zookeeper].
www.ymq.io

7.topic详情

用describe 查看集群中topic每个节点情况

1
2
3
$ /opt/kafka_2.12-0.11.0.0/bin/kafka-topics.sh --describe --zookeeper 192.168.252.121:2181,192.168.252.122:2181,192.168.252.123:2181 --topic ymq
Topic:ymq PartitionCount:1 ReplicationFactor:2 Configs:
Topic: ymq Partition: 0 Leader: 1 Replicas: 1,3 Isr: 3,1

以下是输出的说明。第一行给出了所有分区的摘要,每个附加行提供有关一个分区的信息。由于我们这个主题只有一个分区,只有一行。

leader负责给定分区的读取和写入分配节点编号,每个分区的部分数据会随机指定不同的节点
replicas是复制此分区的日志的节点列表
isr一组正在同步的副本列表

8.删除topic

1
2
3
$ /opt/kafka_2.12-0.11.0.0/bin/kafka-topics.sh --delete --zookeeper 192.168.252.121:2181,192.168.252.122:2181,192.168.252.123:2181 --topic ymq
Topic ymq is marked for deletion.
Note: This will have no impact if delete.topic.enable is not set to true.

9.停止kafka

1
$ for a in {4..6} ; do ssh node$a "source /etc/profile;  /opt/kafka_2.12-0.11.0.0/bin/kafka-server-stop.sh /opt/kafka_2.12-0.11.0.0/config/server.properties " ; done

部署 Kafka Manager

Yahoo开源Kafka集群管理器Kafka Manager

作为一个分布式的消息发布-订阅系统,Apache Kafka在Yahoo内部已经被很多团队所使用,例如媒体分析团队就将其应用到了实时分析流水线中,同时,Yahoo整个Kafka集群处理的峰值带宽超过了20Gbps(压缩数据)。为了让开发者和服务工程师能够更加简单地维护Kafka集群,Yahoo构建了一个基于Web的管理工具,称为Kafka Manager,日前该项目已经在GitHub上开源。

通过Kafka Manager用户能够更容易地发现集群中哪些主题或者分区分布不均匀,同时能够管理多个集群,能够更容易地检查集群的状态,能够创建主题,执行首选的副本选择,能够基于集群当前的状态生成分区分配,并基于生成的分配执行分区的重分配,此外,Kafka Manager还是一个非常好的可以快速查看集群状态的工具。

Kafka Manager使用Scala语言编写,其Web控制台基于Play Framework实现,除此之外,Yahoo还迁移了一些Apache Kafka的帮助程序以便能够与Apache Curator框架一起工作。

一、它支持以下内容:

  • 管理多个群集
  • 容易检查集群状态(主题,消费者,偏移量,经纪人,副本分发,分区分配)
  • 运行首选副本选举
  • 使用选项生成分区分配,以选择要使用的代理
  • 运行分区的重新分配(基于生成的分配)
  • 创建可选主题配置的主题(0.8.1.1具有不同于0.8.2+的配置)
  • 删除主题(仅支持0.8.2+,并记住在代理配​​置中设置delete.topic.enable = true)
  • 主题列表现在表示标记为删除的主题(仅支持0.8.2+)
  • 批量生成多个主题的分区分配,并选择要使用的代理
  • 批量运行多个主题的分区重新分配
  • 将分区添加到现有主题
  • 更新现有主题的配置
  • 可选地,启用JMX轮询代理级和主题级度量。
  • 可选地筛选出在zookeeper中没有ids / owner /&offset /目录的消费者。

源码,并编译打包

在 kafka-manager: 192.168.252.127 node7 部署

编译超级慢

1
2
3
4
5
$ yum install git
$ cd /opt/
$ git clone https://github.com/yahoo/kafka-manager
$ cd kafka-manager/
$ ./sbt clean dist

下载编译好的包

1
2
3
$ yum install unzip
$ unzip kafka-manager-1.3.2.1.zip
$ vi /opt/kafka-manager-1.3.2.1/conf/application.conf

修改这个 zk 地址

1
kafka-manager.zkhosts="192.168.252.121:2181,192.168.252.122:2181,192.168.252.123:2181"

启动 kafka-manager

默认端口 NettyServer - Listening for HTTP on /0:0:0:0:0:0:0:0:9000

1
$ /opt/kafka-manager-1.3.2.1/bin/kafka-manager -Dconfig.file=conf/application.conf

或者后台运行 并且配置端口

1
$ nohup bin/kafka-manager  -Dconfig.file=/home/hadoop/app/kafka-manager-1.3.2.1/conf/application.conf -Dhttp.port=9000 &

访问: http://ip:9000

图片描述

留言與分享

CentOs7.3 搭建 ZooKeeper-3.4.9 Cluster 集群服务

Zookeeper 概述

zookeeper实际上是yahoo开发的,用于分布式中一致性处理的框架。最初其作为研发Hadoop时的副产品。由于分布式系统中一致性处理较为困难,其他的分布式系统没有必要 费劲重复造轮子,故随后的分布式系统中大量应用了zookeeper,以至于zookeeper成为了各种分布式系统的基础组件,其地位之重要,可想而知。著名的hadoop,kafka,dubbo 都是基于zookeeper而构建

要想理解zookeeper到底是做啥的,那首先得理解清楚,什么是一致性?

所谓的一致性,实际上就是围绕着“看见”来的。谁能看见?能否看见?什么时候看见?举个例子:淘宝后台卖家,在后台上架一件大促的商品,通过服务器A提交到主数据库,假设刚提交后立马就有用户去通过应用服务器B去从数据库查询该商品,就会出现一个现象,卖家已经更新成功了,然而买家却看不到;而经过一段时间后,主数据库的数据同步到了从数据库,买家就能查到了。

假设卖家更新成功之后买家立马就能看到卖家的更新,则称为强一致性

如果卖家更新成功后买家不能看到卖家更新的内容,则称为弱一致性

而卖家更新成功后,买家经过一段时间最终能看到卖家的更新,则称为最终一致性

《一致性协议》

《ZooKeeper应用场景》

《分布式架构》

《分布式 ZooKeeper 系列》

环境

VMware版本号:12.0.0

CentOS版本:CentOS 7.3.1611

ZooKeeper版本:ZooKeeper-3.4.9.tar.gz

虚拟机IP:192.168.252.101,192.168.252.102,192.168.252.103

集群主机名称:node1,node2,node3

集群主机用户:都是用root用户

集群JDK环境:jdk-8u144-linux-x64.tar.gz JDK 1.8 安装 具体参考《CentOs7.3 安装 JDK1.8》

集群主机之间设置免密登陆:

注意事项

关闭防火墙

centos 6.x 关闭 iptables

1
$ service iptables stop # 关闭命令:

centos 7.x 关闭firewall

1
$ systemctl stop firewalld.service # 停止firewall

ZooKeeper 安装

1.下载ZooKeeper

下载最新版本的ZooKeeper ,我在北京我就选择,清华镜像比较快

清华镜像:https://mirrors.tuna.tsinghua.edu.cn/apache/zookeeper/

阿里镜像:https://mirrors.aliyun.com/apache/zookeeper/

1
2
$ cd /opt/
$ wget https://mirrors.tuna.tsinghua.edu.cn/apache/zookeeper/zookeeper-3.4.9/zookeeper-3.4.9.tar.gz

或者在浏览器下载上传至opt 目录

2.提取tar文件

1
2
3
$ cd /opt/
$ tar -zxf zookeeper-3.4.9.tar.gz
$ cd zookeeper-3.4.9

创建data文件夹 用于存储数据文件

1
$ mkdir /opt/zookeeper-3.4.9/data

创建logs文件夹 用于存储日志

1
$ mkdir /opt/zookeeper-3.4.9/logs  

3.创建配置文件

zoo.cfg

zookeeper的主要配置文件,因为Zookeeper是一个集群服务,集群的每个节点都需要这个配置文件。为了避免出差错,zoo.cfg这个配置文件里没有跟特定节点相关的配置,所以每个节点上的这个zoo.cfg都是一模一样的配置。这样就非常便于管理了,比如我们可以把这个文件提交到版本控制里管理起来。其实这给我们设计集群系统的时候也是个提示:集群系统一般有很多配置,应该尽量将通用的配置和特定每个服务的配置(比如服务标识)分离,这样通用的配置在不同服务之间copy就ok了

1
$ vi /opt/zookeeper-3.4.9/conf/zoo.cfg

编辑内容如下

1
2
3
4
5
6
7
8
9
10
11
tickTime = 2000
dataDir = /opt/zookeeper-3.4.9/data
dataLogDir = /opt/zookeeper-3.4.9/logs
tickTime = 2000
clientPort = 2181
initLimit = 5
syncLimit = 2

server.1=node1:2888:3888
server.2=node2:2888:3888
server.3=node3:2888:3888

配置文件描述

tickTime

  • tickTime则是上述两个超时配置的基本单位,例如对于initLimit,其配置值为5,说明其超时时间为 2000ms * 5 = 10秒。

dataDir

  • 其配置的含义跟单机模式下的含义类似,不同的是集群模式下还有一个myid文件。myid文件的内容只有一行,且内容只能为1 - 255之间的数字,这个数字亦即上面介绍server.id中的id,表示zk进程的id。

dataLogDir

  • 如果没提供的话使用的则是dataDir。zookeeper的持久化都存储在这两个目录里。dataLogDir里是放到的顺序日志(WAL)。而dataDir里放的是内存数据结构的snapshot,便于快速恢复。为了达到性能最大化,一般建议把dataDir和dataLogDir分到不同的磁盘上,这样就可以充分利用磁盘顺序写的特性。

initLimit

  • ZooKeeper集群模式下包含多个zk进程,其中一个进程为leader,余下的进程为follower。

当follower最初与leader建立连接时,它们之间会传输相当多的数据,尤其是follower的数据落后leader很多。initLimit配置follower与leader之间建立连接后进行同步的最长时间。

syncLimit

  • 配置follower和leader之间发送消息,请求和应答的最大时间长度。

server.id=host:port1:port2

server.id 其中id为一个数字,表示zk进程的id,这个id也是data目录下myid文件的内容

host 是该zk进程所在的IP地址

port1 表示follower和leader交换消息所使用的端口

port2 表示选举leader所使用的端口

4.创建myid 文件

在data里会放置一个myid文件,里面就一个数字,用来唯一标识这个服务。这个id是很重要的,一定要保证整个集群中唯一

ZooKeeper会根据这个id来取出server.x上的配置。比如当前id为1,则对应着zoo.cfg里的server.1的配置

1
$ echo "1" > /opt/zookeeper-3.4.9/data/myid

这样一台node1机器就配置完了

5.复制集群配置

在集群node1 上执行,复制配置好的zookeeper到其他两台主机上

1
$ for a in {2..3} ; do scp -r /opt/zookeeper-3.4.9/ node$a:/opt/zookeeper-3.4.9 ; done

在集群node1 上执行 ,批量修改myid 文件

1
$ for a in {1..3} ; do ssh node$a "source /etc/profile; echo $a > /opt/zookeeper-3.4.9/data/myid" ; done

集群操作

在集群任意一台机器上执行

启动集群

1
$ for a in {1..3} ; do ssh node$a "source /etc/profile; /opt/zookeeper-3.4.9/bin/zkServer.sh start" ; done

响应

1
2
3
4
5
6
7
8
9
ZooKeeper JMX enabled by default
Using config: /opt/zookeeper-3.4.9/bin/../conf/zoo.cfg
Starting zookeeper ... STARTED
ZooKeeper JMX enabled by default
Using config: /opt/zookeeper-3.4.9/bin/../conf/zoo.cfg
Starting zookeeper ... STARTED
ZooKeeper JMX enabled by default
Using config: /opt/zookeeper-3.4.9/bin/../conf/zoo.cfg
Starting zookeeper ... STARTED

连接集群

1
$ /opt/zookeeper-3.4.9/bin/zkCli.sh -server node1:2181,node2:2181,node3:2181

响应

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
Connecting to node1:2181,node2:2181,node3:2181
2017-08-23 11:08:10,323 [myid:] - INFO [main:Environment@100] - Client environment:zookeeper.version=3.4.9-1757313, built on 08/23/2016 06:50 GMT
2017-08-23 11:08:10,328 [myid:] - INFO [main:Environment@100] - Client environment:host.name=node1
2017-08-23 11:08:10,329 [myid:] - INFO [main:Environment@100] - Client environment:java.version=1.8.0_144
2017-08-23 11:08:10,331 [myid:] - INFO [main:Environment@100] - Client environment:java.vendor=Oracle Corporation
2017-08-23 11:08:10,331 [myid:] - INFO [main:Environment@100] - Client environment:java.home=/usr/lib/jvm/jre
2017-08-23 11:08:10,331 [myid:] - INFO [main:Environment@100] - Client environment:java.class.path=/opt/zookeeper-3.4.9/bin/../build/classes:/opt/zookeeper-3.4.9/bin/../build/lib/*.jar:/opt/zookeeper-3.4.9/bin/../lib/slf4j-log4j12-1.6.1.jar:/opt/zookeeper-3.4.9/bin/../lib/slf4j-api-1.6.1.jar:/opt/zookeeper-3.4.9/bin/../lib/netty-3.10.5.Final.jar:/opt/zookeeper-3.4.9/bin/../lib/log4j-1.2.16.jar:/opt/zookeeper-3.4.9/bin/../lib/jline-0.9.94.jar:/opt/zookeeper-3.4.9/bin/../zookeeper-3.4.9.jar:/opt/zookeeper-3.4.9/bin/../src/java/lib/*.jar:/opt/zookeeper-3.4.9/bin/../conf:.:/lib/jvm/lib:/lib/jvm/jre/lib
2017-08-23 11:08:10,331 [myid:] - INFO [main:Environment@100] - Client environment:java.library.path=/usr/java/packages/lib/amd64:/usr/lib64:/lib64:/lib:/usr/lib
2017-08-23 11:08:10,331 [myid:] - INFO [main:Environment@100] - Client environment:java.io.tmpdir=/tmp
2017-08-23 11:08:10,331 [myid:] - INFO [main:Environment@100] - Client environment:java.compiler=<NA>
2017-08-23 11:08:10,331 [myid:] - INFO [main:Environment@100] - Client environment:os.name=Linux
2017-08-23 11:08:10,331 [myid:] - INFO [main:Environment@100] - Client environment:os.arch=amd64
2017-08-23 11:08:10,331 [myid:] - INFO [main:Environment@100] - Client environment:os.version=3.10.0-514.26.2.el7.x86_64
2017-08-23 11:08:10,331 [myid:] - INFO [main:Environment@100] - Client environment:user.name=root
2017-08-23 11:08:10,332 [myid:] - INFO [main:Environment@100] - Client environment:user.home=/root
2017-08-23 11:08:10,332 [myid:] - INFO [main:Environment@100] - Client environment:user.dir=/root
2017-08-23 11:08:10,333 [myid:] - INFO [main:ZooKeeper@438] - Initiating client connection, connectString=node1:2181,node2:2181,node3:2181 sessionTimeout=30000 watcher=org.apache.zookeeper.ZooKeeperMain$MyWatcher@506c589e
2017-08-23 11:08:10,361 [myid:] - INFO [main-SendThread(node3:2181):ClientCnxn$SendThread@1032] - Opening socket connection to server node3/192.168.252.123:2181. Will not attempt to authenticate using SASL (unknown error)
Welcome to ZooKeeper!
JLine support is enabled
2017-08-23 11:08:10,474 [myid:] - INFO [main-SendThread(node3:2181):ClientCnxn$SendThread@876] - Socket connection established to node3/192.168.252.123:2181, initiating session
[zk: node1:2181,node2:2181,node3:2181(CONNECTING) 0] 2017-08-23 11:08:10,535 [myid:] - INFO [main-SendThread(node3:2181):ClientCnxn$SendThread@1299] - Session establishment complete on server node3/192.168.252.123:2181, sessionid = 0x35e0d0716340000, negotiated timeout = 30000

WATCHER::

WatchedEvent state:SyncConnected type:None path:null

[zk: node1:2181,node2:2181,node3:2181(CONNECTED) 0]

从日志可以看出客户端成功连接的是node3 连接上哪台机器的zk进程是随机的

1
2
3
4

2017-08-23 11:08:10,361 [myid:] - INFO [main-SendThread(node3:2181):ClientCnxn$SendThread@1032] - Opening socket connection to server node3/192.168.252.123:2181. Will not attempt to authenticate using SASL (unknown error)
Welcome to ZooKeeper!
JLine support is enabled

集群状态

1
$ for a in {1..3} ; do ssh node$a "source /etc/profile; /opt/zookeeper-3.4.9/bin/zkServer.sh status" ; done

响应

1
2
3
4
5
6
7
8
9
ZooKeeper JMX enabled by default
Using config: /opt/zookeeper-3.4.9/bin/../conf/zoo.cfg
Mode: follower
ZooKeeper JMX enabled by default
Using config: /opt/zookeeper-3.4.9/bin/../conf/zoo.cfg
Mode: leader
ZooKeeper JMX enabled by default
Using config: /opt/zookeeper-3.4.9/bin/../conf/zoo.cfg
Mode: follower

通过日志我可以看到 node2 leader (ps 是老大),其他 node1 ,node2 follower (ps 都是小弟)

Leader 怎么选举的可以参考《Zookeeper的Leader选举》

停止集群

1
$ for a in {1..3} ; do ssh node$a "source /etc/profile; /opt/zookeeper-3.4.9/bin/zkServer.sh stop" ; done

响应

1
2
3
4
5
6
7
8
9
ZooKeeper JMX enabled by default
Using config: /opt/zookeeper-3.4.9/bin/../conf/zoo.cfg
Stopping zookeeper ... STOPPED
ZooKeeper JMX enabled by default
Using config: /opt/zookeeper-3.4.9/bin/../conf/zoo.cfg
Stopping zookeeper ... STOPPED
ZooKeeper JMX enabled by default
Using config: /opt/zookeeper-3.4.9/bin/../conf/zoo.cfg
Stopping zookeeper ... STOPPED

留言與分享

Hive-2.3.0 快速搭建与使用

分類 database, hadoop, Hive

Hive 简介

Hive 是一个基于 hadoop 的开源数据仓库工具,用于存储和处理海量结构化数据。它把海量数据存储于 hadoop 文件系统,而不是数据库,但提供了一套类数据库的数据存储和处理机制,并采用 HQL (类 SQL )语言对这些数据进行自动化管理和处理。我们可以把 Hive 中海量结构化数据看成一个个的表,而实际上这些数据是分布式存储在 HDFS 中的。 Hive 经过对语句进行解析和转换,最终生成一系列基于 hadoop 的 map/reduce 任务,通过执行这些任务完成数据处理。

Hive 诞生于 facebook 的日志分析需求,面对海量的结构化数据, Hive 以较低的成本完成了以往需要大规模数据库才能完成的任务,并且学习门槛相对较低,应用开发灵活而高效。

Hive 自 2009.4.29 发布第一个官方稳定版 0.3.0 至今,不过一年的时间,正在慢慢完善,网上能找到的相关资料相当少,尤其中文资料更少,本文结合业务对 Hive 的应用做了一些探索,并把这些经验做一个总结,所谓前车之鉴,希望读者能少走一些弯路。

准备工作

环境

1
2
3
4
5
6
7
8
9
JDK:1.8  
Hadoop Release:2.7.4
centos:7.3

node1(master) 主机: 192.168.252.121
node2(slave1) 从机: 192.168.252.122
node3(slave2) 从机: 192.168.252.123

node4(mysql) 从机: 192.168.252.124

依赖环境

安装**Apache Hive**前提是要先安装hadoop集群,并且hive只需要在hadoop的namenode节点集群里安装即可(需要在有的namenode上安装),可以不在datanode节点的机器上安装。还需要说明的是,虽然修改配置文件并不需要把hadoop运行起来,但是本文中用到了hadoop的hdfs命令,在执行这些命令时你必须确保hadoop是正在运行着的,而且启动hive的前提也需要hadoop在正常运行着,所以建议先把hadoop集群启动起来。

安装**MySQL** 用于存储 Hive 的元数据(也可以用 Hive 自带的嵌入式数据库 Derby,但是 Hive 的生产环境一般不用 Derby),这里只需要安装 MySQL 单机版即可,如果想保证高可用的化,也可以部署 MySQL 主从模式;

Hadoop

Hadoop-2.7.4 集群快速搭建

MySQL 随意任选其一

CentOs7.3 安装 MySQL 5.7.19 二进制版本

搭建 MySQL 5.7.19 主从复制,以及复制实现细节分析

安装

下载解压

1
2
3
4
5
su hadoop
cd /home/hadoop/
wget https://mirrors.tuna.tsinghua.edu.cn/apache/hive/hive-2.3.0/apache-hive-2.3.0-bin.tar.gz
tar -zxvf apache-hive-2.3.0-bin.tar.gz
mv apache-hive-2.3.0-bin hive-2.3.0

环境变量

如果是对所有的用户都生效就修改vi /etc/profile 文件
如果只针对当前用户生效就修改 vi ~/.bahsrc 文件

1
sudo vi /etc/profile
1
2
3
#hive
export PATH=${HIVE_HOME}/bin:$PATH
export HIVE_HOME=/home/hadoop/hive-2.3.0/

使环境变量生效,运行 source /etc/profile使/etc/profile文件生效

Hive 配置 Hadoop HDFS

复制 hive-site.xml

1
2
cd /home/hadoop/hive-2.3.0/conf
cp hive-default.xml.template hive-site.xml

新建 hdfs 目录

使用 hadoop 新建 hdfs 目录,因为在 hive-site.xml 中有默认如下配置:

1
2
3
4
5
6
<property>
<name>hive.metastore.warehouse.dir</name>
<value>/user/hive/warehouse</value>
<description>location of default database for the warehouse</description>
</property>
<property>

进入 hadoop 安装目录 执行hadoop命令新建/user/hive/warehouse目录,并授权,用于存储文件

1
2
3
4
5
6
7
8
cd /home/hadoop/hadoop-2.7.4

bin/hadoop fs -mkdir -p /user/hive/warehouse
bin/hadoop fs -mkdir -p /user/hive/tmp
bin/hadoop fs -mkdir -p /user/hive/log
bin/hadoop fs -chmod -R 777 /user/hive/warehouse
bin/hadoop fs -chmod -R 777 /user/hive/tmp
bin/hadoop fs -chmod -R 777 /user/hive/log

用以下命令检查目录是否创建成功

1
bin/hadoop fs -ls /user/hive

修改 hive-site.xml

搜索hive.exec.scratchdir,将该name对应的value修改为/user/hive/tmp

1
2
3
4
<property>  
<name>hive.exec.scratchdir</name>
<value>/user/hive/tmp</value>
</property>

搜索hive.querylog.location,将该name对应的value修改为/user/hive/log/hadoop

1
2
3
4
5
<property>
<name>hive.querylog.location</name>
<value>/user/hive/log/hadoop</value>
<description>Location of Hive run time structured log file</description>
</property>

搜索javax.jdo.option.connectionURL,将该name对应的value修改为MySQL的地址

1
2
3
4
5
6
7
8
9
<property>
<name>javax.jdo.option.ConnectionURL</name>
<value>jdbc:mysql://192.168.252.124:3306/hive?createDatabaseIfNotExist=true</value>
<description>
JDBC connect string for a JDBC metastore.
To use SSL to encrypt/authenticate the connection, provide database-specific SSL flag in the connection URL.
For example, jdbc:postgresql://myhost/db?ssl=true for postgres database.
</description>
</property>

搜索javax.jdo.option.ConnectionDriverName,将该name对应的value修改为MySQL驱动类路径

1
2
3
4
5
<property>
<name>javax.jdo.option.ConnectionDriverName</name>
<value>com.mysql.jdbc.Driver</value>
<description>Driver class name for a JDBC metastore</description>
</property>

搜索javax.jdo.option.ConnectionUserName,将对应的value修改为MySQL数据库登录名

1
2
3
4
5
<property>
<name>javax.jdo.option.ConnectionUserName</name>
<value>root</value>
<description>Username to use against metastore database</description>
</property>

搜索javax.jdo.option.ConnectionPassword,将对应的value修改为MySQL数据库的登录密码

1
2
3
4
5
<property>
<name>javax.jdo.option.ConnectionPassword</name>
<value>mima</value>
<description>password to use against metastore database</description>
</property>

创建 tmp 文件

1
mkdir /home/hadoop/hive-2.3.0/tmp

并在 hive-site.xml 中修改

{system:java.io.tmpdir} 改成 /home/hadoop/hive-2.3.0/tmp

{system:user.name} 改成 {user.name}

新建 hive-env.sh

1
2
3
4
5
6
7
cp hive-env.sh.template hive-env.sh

vi hive-env.sh

HADOOP_HOME=/home/hadoop/hadoop-2.7.4/
export HIVE_CONF_DIR=/home/hadoop/hive-2.3.0/conf
export HIVE_AUX_JARS_PATH=/home/hadoop/hive-2.3.0/lib

下载 mysql 驱动包

1
2
3
cd /home/hadoop/hive-2.3.0/lib

wget http://central.maven.org/maven2/mysql/mysql-connector-java/5.1.38/mysql-connector-java-5.1.38.jar

初始化 mysql

MySQL数据库进行初始化

首先确保 mysql 中已经创建 hive

1
2
cd /home/hadoop/hive-2.3.0/bin
./schematool -initSchema -dbType mysql

如果看到如下,表示初始化成功

1
2
3
4
Starting metastore schema initialization to 2.3.0
Initialization script hive-schema-2.3.0.mysql.sql
Initialization script completed
schemaTool completed

查看 mysql 数据库

1
/usr/local/mysql/bin/mysql -uroot -p
1
2
3
4
5
6
7
8
9
10
11
mysql> show databases;
+--------------------+
| Database |
+--------------------+
| information_schema |
| hive |
| mysql |
| performance_schema |
| sys |
+--------------------+
5 rows in set (0.00 sec)
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
mysql> use hive;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql> show tables;
+---------------------------+
| Tables_in_hive |
+---------------------------+
| AUX_TABLE |
| BUCKETING_COLS |
| CDS |
| COLUMNS_V2 |
| COMPACTION_QUEUE |
| COMPLETED_COMPACTIONS |
| COMPLETED_TXN_COMPONENTS |
| DATABASE_PARAMS |
| DBS |
| DB_PRIVS |
| DELEGATION_TOKENS |
| FUNCS |
| FUNC_RU |
| GLOBAL_PRIVS |
| HIVE_LOCKS |
| IDXS |
| INDEX_PARAMS |
| KEY_CONSTRAINTS |
| MASTER_KEYS |
| NEXT_COMPACTION_QUEUE_ID |
| NEXT_LOCK_ID |
| NEXT_TXN_ID |
| NOTIFICATION_LOG |
| NOTIFICATION_SEQUENCE |
| NUCLEUS_TABLES |
| PARTITIONS |
| PARTITION_EVENTS |
| PARTITION_KEYS |
| PARTITION_KEY_VALS |
| PARTITION_PARAMS |
| PART_COL_PRIVS |
| PART_COL_STATS |
| PART_PRIVS |
| ROLES |
| ROLE_MAP |
| SDS |
| SD_PARAMS |
| SEQUENCE_TABLE |
| SERDES |
| SERDE_PARAMS |
| SKEWED_COL_NAMES |
| SKEWED_COL_VALUE_LOC_MAP |
| SKEWED_STRING_LIST |
| SKEWED_STRING_LIST_VALUES |
| SKEWED_VALUES |
| SORT_COLS |
| TABLE_PARAMS |
| TAB_COL_STATS |
| TBLS |
| TBL_COL_PRIVS |
| TBL_PRIVS |
| TXNS |
| TXN_COMPONENTS |
| TYPES |
| TYPE_FIELDS |
| VERSION |
| WRITE_SET |
+---------------------------+
57 rows in set (0.00 sec)

启动 Hive

简单测试

启动Hive

1
2
3
cd /home/hadoop/hive-2.3.0/bin

./hive

创建 hive 库

1
2
3
hive>  create database ymq;
OK
Time taken: 0.742 seconds

选择库

1
2
3
hive> use ymq;
OK
Time taken: 0.036 seconds

创建表

1
2
3
hive> create table test (mykey string,myval string);
OK
Time taken: 0.569 seconds

插入数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
hive> insert into test values("1","www.ymq.io");

WARNING: Hive-on-MR is deprecated in Hive 2 and may not be available in the future versions. Consider using a different execution engine (i.e. spark, tez) or using Hive 1.X releases.
Query ID = hadoop_20170922011126_abadfa44-8ebe-4ffc-9615-4241707b3c03
Total jobs = 3
Launching Job 1 out of 3
Number of reduce tasks is set to 0 since there's no reduce operator
Starting Job = job_1506006892375_0001, Tracking URL = http://node1:8088/proxy/application_1506006892375_0001/
Kill Command = /home/hadoop/hadoop-2.7.4//bin/hadoop job -kill job_1506006892375_0001
Hadoop job information for Stage-1: number of mappers: 1; number of reducers: 0
2017-09-22 01:12:12,763 Stage-1 map = 0%, reduce = 0%
2017-09-22 01:12:20,751 Stage-1 map = 100%, reduce = 0%, Cumulative CPU 1.24 sec
MapReduce Total cumulative CPU time: 1 seconds 240 msec
Ended Job = job_1506006892375_0001
Stage-4 is selected by condition resolver.
Stage-3 is filtered out by condition resolver.
Stage-5 is filtered out by condition resolver.
Moving data to directory hdfs://node1:9000/user/hive/warehouse/ymq.db/test/.hive-staging_hive_2017-09-22_01-11-26_242_8022847052615616955-1/-ext-10000
Loading data to table ymq.test
MapReduce Jobs Launched:
Stage-Stage-1: Map: 1 Cumulative CPU: 1.24 sec HDFS Read: 4056 HDFS Write: 77 SUCCESS
Total MapReduce CPU Time Spent: 1 seconds 240 msec
OK
Time taken: 56.642 seconds

查询数据

1
2
3
4
hive> select * from test;
OK
1 www.ymq.io
Time taken: 0.253 seconds, Fetched: 1 row(s)

页面数据

在界面上查看刚刚写入的hdfs数据

图片描述

图片描述

留言與分享

CentOs7.3 hadoop ssh 免密登录

分類 database, hadoop

环境

三台虚拟机(IP):

  • 192.168.252.121
  • 192.168.252.122
  • 192.168.252.123

1.修改主机名

修改三台主机名,以此类推,node1,node3,node3

命令格式

1
hostnamectl set-hostname <hostname>
1
sudo hostnamectl set-hostname node1

剩下的虚拟机依次修改hostnamectl set-hostname[1-3]

重启操作系统

1
$ reboot

2.修改映射关系

1.在 node1 的 /etc/hosts 文件下添加如下内容

1
2
su hadoop
vi /etc/hosts

2.查看修改后的/etc/hosts 文件内容

1
2
3
4
5
6
7
$ cat /etc/hosts
127.0.0.1 localhost localhost.localdomain localhost4 localhost4.localdomain4
::1 localhost localhost.localdomain localhost6 localhost6.localdomain6
# 以下是添加的
192.168.252.121 node1
192.168.252.122 node2
192.168.252.123 node3

2.将集群node1 上的文件hosts文件 通过 scp 命令复制发送到集群的每一个节点

1
for a in {1..3} ; do sudo scp /etc/hosts hadoop@node$a:/etc/hosts ; done

3.检查是否集群每一个节点的 hosts 文件都已经修改过来了

1
for a in {1..3} ; do sudo ssh hadoop@node$a cat /etc/hosts ; done

3.启动 ssh 无密登录

1.在集群node1的 /etc/ssh/sshd_config 文件去掉以下选项的注释

1
sudo vi /etc/ssh/sshd_config 
1
2
RSAAuthentication yes      #开启私钥验证
PubkeyAuthentication yes #开启公钥验证

2.将集群node1 修改后的 /etc/ssh/sshd_config 通过 scp 命令复制发送到集群的每一个节点

1
for a in {1..3} ; do  sudo scp /etc/ssh/sshd_config hadoop@node$a:/etc/ssh/sshd_config ; done

4.生成公钥、私钥

1.在集群的每一个节点节点输入命令 ssh-keygen -t rsa -P '',生成 key,一律回车

1
ssh-keygen -t rsa -P ''
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Generating public/private rsa key pair.
Enter file in which to save the key (/home/hadoop/.ssh/id_rsa):
Created directory '/home/hadoop/.ssh'.
Your identification has been saved in /home/hadoop/.ssh/id_rsa.
Your public key has been saved in /home/hadoop/.ssh/id_rsa.pub.
The key fingerprint is:
aa:be:0e:46:9a:e8:d5:dc:79:ea:5a:b8:9b:08:e2:dd hadoop@node2
The key's randomart image is:
+--[ RSA 2048]----+
| |
| |
| |
| |
| . S |
|.+ o o.. |
|=.o. +.+ . |
|+.+.o.+ o |
| o ==E+o |
+-----------------+

2.在集群的node1 节点输入命令

将集群每一个节点的公钥id_rsa.pub放入到自己的认证文件中authorized_keys;

1
for a in {1..3}; do sudo ssh hadoop@node$a cat /home/hadoop/.ssh/id_rsa.pub >> /home/hadoop/.ssh/authorized_keys; done

3.在集群的node1 节点输入命令

将自己的认证文件 authorized_keys 通过 scp 命令复制发送到每一个节点上去: /home/hadoop/.ssh/authorized_keys`

1
for a in {1..3}; do sudo scp /home/hadoop/.ssh/authorized_keys hadoop@node$a:/home/hadoop/.ssh/authorized_keys ; done

4.非ROOT 用户需赋权限

1
2
3
chmod 700 /home/hadoop/.ssh/
chmod 700 /home/hadoop/
chmod 600 /home/hadoop/.ssh/authorized_keys

5.在集群的每一个节点节点输入命令

接重启ssh服务

1
sudo systemctl restart sshd.service

6.验证 ssh 无密登录

开一个其他窗口测试下能否免密登陆

例如:在node3

1
ssh hadoop@node2

exit 退出

1
2
3
[hadoop@node1 ~]# exit
logout
Connection to node1 closed.

注意:开新的其他窗口测试下能否免密登陆,把当前窗口都关了

留言與分享

HBase 深入浅出

分類 database, hadoop, HBase

HBase 深入浅出

HBase 在大数据生态圈中的位置

提到大数据的存储,大多数人首先联想到的是 Hadoop 和 Hadoop 中的 HDFS 模块。大家熟知的 Spark、以及 Hadoop 的 MapReduce,可以理解为一种计算框架。而 HDFS,我们可以认为是为计算框架服务的存储层。因此不管是 Spark 还是 MapReduce,都需要使用 HDFS 作为默认的持久化存储层。那么 HBase 又是什么,可以用在哪里,解决什么样的问题?简单地,我们可以认为 HBase 是一种类似于数据库的存储层,也就是说 HBase 适用于结构化的存储。并且 HBase 是一种列式的分布式数据库,是由当年的 Google 公布的 BigTable 的论文而生。不过这里也要注意 HBase 底层依旧依赖 HDFS 来作为其物理存储,这点类似于 Hive。

可能有的读者会好奇 HBase 于 Hive 的区别,我们简单的梳理一下 Hive 和 HBase 的应用场景:

Hive 适合用来对一段时间内的数据进行分析查询,例如,用来计算趋势或者网站的日志。Hive 不应该用来进行实时的查询(Hive 的设计目的,也不是支持实时的查询)。因为它需要很长时间才可以返回结果;HBase 则非常适合用来进行大数据的实时查询,例如 Facebook 用 HBase 进行消息和实时的分析。对于 Hive 和 HBase 的部署来说,也有一些区别,Hive 一般只要有 Hadoop 便可以工作。而 HBase 则还需要 Zookeeper 的帮助(Zookeeper,是一个用来进行分布式协调的服务,这些服务包括配置服务,维护元信息和命名空间服务)。再而,HBase 本身只提供了 Java 的 API 接口,并不直接支持 SQL 的语句查询,而 Hive 则可以直接使用 HQL(一种类 SQL 语言)。如果想要在 HBase 上使用 SQL,则需要联合使用 Apache Phonenix,或者联合使用 Hive 和 HBase。但是和上面提到的一样,如果集成使用 Hive 查询 HBase 的数据,则无法绕过 MapReduce,那么实时性还是有一定的损失。Phoenix 加 HBase 的组合则不经过 MapReduce 的框架,因此当使用 Phoneix 加 HBase 的组成,实时性上会优于 Hive 加 HBase 的组合,我们后续也会示例性介绍如何使用两者。最后我们再提下 Hive 和 HBase 所使用的存储层,默认情况下 Hive 和 HBase 的存储层都是 HDFS。但是 HBase 在一些特殊的情况下也可以直接使用本机的文件系统。例如 Ambari 中的 AMS 服务直接在本地文件系统上运行 HBase。

HBase 与传统关系数据库的区别

首先让我们了解下什么是 ACID。ACID 是指数据库事务正确执行的四个基本要素的缩写,其包含:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)以及持久性(Durability)。对于一个支持事务(Transaction)的数据库系统,必需要具有这四种特性,否则在事务过程(Transaction Processing)当中无法保证数据的正确性,交易过程极可能达不到交易方的要求。下面,我们就简单的介绍下这 4 个特性的含义。

  • 原子性(Atomicity)是指一个事务要么全部执行,要么全部不执行。换句话说,一个事务不可能只执行了一半就停止了。比如一个事情分为两步完成才可以完成,那么这两步必须同时完成,要么一步也不执行,绝不会停留在某一个中间状态。如果事物执行过程中,发生错误,系统会将事物的状态回滚到最开始的状态。
  • 一致性(Consistency)是指事务的运行并不改变数据库中数据的一致性。也就是说,无论并发事务有多少个,但是必须保证数据从一个一致性的状态转换到另一个一致性的状态。例如有 a、b 两个账户,分别都是 10。当 a 增加 5 时,b 也会随着改变,总值 20 是不会改变的。
  • 隔离性(Isolation)是指两个以上的事务不会出现交错执行的状态。因为这样可能会导致数据不一致。如果有多个事务,运行在相同的时间内,执行相同的功能,事务的隔离性将确保每一事务在系统中认为只有该事务在使用系统。这种属性有时称为串行化,为了防止事务操作间的混淆,必须串行化或序列化请求,使得在同一时间仅有一个请求用于同一数据。
  • 持久性(Durability)指事务执行成功以后,该事务对数据库所作的更改便是持久的保存在数据库之中,不会无缘无故的回滚。

在具体的介绍 HBase 之前,我们先简单对比下 HBase 与传统关系数据库的(RDBMS,全称为 Relational Database Management System)区别。如表 1 所示。

表 1. HBase 与 RDBMS 的区别

HBase RDBMS
硬件架构 类似于 Hadoop 的分布式集群,硬件成本低廉 传统的多核系统,硬件成本昂贵
容错性 由软件架构实现,由于由多个节点组成,所以不担心一点或几点宕机 一般需要额外硬件设备实现 HA 机制
数据库大小 PB GB、TB
数据排布方式 稀疏的、分布的多维的 Map 以行和列组织
数据类型 Bytes 丰富的数据类型
事物支持 ACID 只支持单个 Row 级别 全面的 ACID 支持,对 Row 和表
查询语言 只支持 Java API (除非与其他框架一起使用,如 Phoenix、Hive) SQL
索引 只支持 Row-key,除非与其他技术一起应用,如 Phoenix、Hive 支持
吞吐量 百万查询/每秒 数千查询/每秒

理解了上面的表格之后,我们在看看数据是如何在 HBase 以及 RDBMS 中排布的。首先,数据在 RDBMS 的排布大致如表 2。

表 2. 数据在 RDBMS 中的排布示例

ID 密码 时间戳
1 111 20160719
2 222 20160720

那么数据在 HBase 中的排布会是什么样子呢?如表 3 所示(这只是逻辑上的排布)。

表 3. 数据在 HBase 中的排布(逻辑上)

Row-Key Value(CF、Qualifier、Version)
1 info{‘姓’: ‘张’,‘名’:‘三’}
pwd{‘密码’: ‘111’}
2 Info{‘姓’: ‘李’,‘名’:‘四’}
pwd{‘密码’: ‘222’}

从上面示例表中,我们可以看出,在 HBase 中首先会有 Column Family 的概念,简称为 CF。CF 一般用于将相关的列(Column)组合起来。在物理上 HBase 其实是按 CF 存储的,只是按照 Row-key 将相关 CF 中的列关联起来。物理上的数据排布大致可以如表 4 所示。

表 4. 数据在 HBase 中的排布

Row-Key CF:Column-Key 时间戳 Cell Value
1 info:fn 123456789
1 info:ln 123456789
2 info:fn 123456789
2 info:ln 123456789

我们已经提到 HBase 是按照 CF 来存储数据的。在表 3 中,我们看到了两个 CF,分别是 info 和 pwd。info 存储着姓名相关列的数据,而 pwd 则是密码相关的数据。上表便是 info 这个 CF 存储在 Hbase 中的数据排布。Pwd 的数据排布是类似的。上表中的 fn 和 ln 称之为 Column-key 或者 Qulifimer。在 Hbase 中,Row-key 加上 CF 加上 Qulifier 再加上一个时间戳才可以定位到一个单元格数据(Hbase 中每个单元格默认有 3 个时间戳的版本数据)。初学者,在一开始接触这些概念是很容易混淆。其实不管是 CF 还是 Qulifier 都是客户定义出来的。也就是说在 HBase 中创建表格时,就需要指定表格的 CF、Row-key 以及 Qulifier。我们会在后续的介绍中,尝试指定这些相关的概念,以便加深理解。这里我们先通过下图理解下 HBase 中,逻辑上的数据排布与物理上的数据排布之间的关系。

图 1. Hbase 中逻辑上数据的排布与物理上排布的关联

图片描述

从上图我们看到 Row1 到 Row5 的数据分布在两个 CF 中,并且每个 CF 对应一个 HFile。并且逻辑上每一行中的一个单元格数据,对应于 HFile 中的一行,然后当用户按照 Row-key 查询数据的时候,HBase 会遍历两个 HFile,通过相同的 Row-Key 标识,将相关的单元格组织成行返回,这样便有了逻辑上的行数据。讲解到这,我们就大致了解 HBase 中的数据排布格式,以及与 RDBMS 的一些区别。

对于 RDBMS 来说,一般都是以 SQL 作为为主要的访问方式。而 HBase 是一种"NoSQL"数据库。"NoSQL"是一个通用词表示该数据库并

是 RDBMS 。现在的市面上有许多种 NoSQL 数据库,如 BerkeleyDB 是本地 NoSQL 数据库的例子, HBase 则为大型分布式 NoSql 数据库。从技术上来说,Hbase 更像是"数据存储"而非"数据库"(HBase 和 HDFS 都属于大数据的存储层)。因此,HBase 缺少很多 RDBMS 特性,如列类型,二级索引,触发器和高级查询语言等。然而, HBase 也具有许多其他特征同时支持线性化和模块化扩充。最明显的方式,我们可以通过增加 Region Server 的数量扩展 HBase。并且 HBase 可以放在普通的服务器中,例如将集群从 5 个扩充到 10 个 Region Server 时,存储空间和处理容量都可以同时翻倍。当然 RDBMS 也能很好的扩充,但仅对一个点,尤其是对一个单独数据库服务器而言,为了更好的性能,往往需要特殊的硬件和存储设备(往往价格也非常昂贵)。

HBase 相关模块以及 HBase 表格特性

在这里,让我们了解下 HBase 都有哪些模块,以及大致的工作流程。前面我们提到过 HBase 也是构建于 HDFS 之上,这是正确的,但也不是完全正确。HBase 其实也支持直接在本地文件系统之上运行,不过这样的 HBase 只能运行在一台机器上,那么对于分布式大数据的环境是没有意义的(这也是所谓的 HBase 的单机模式)。一般只用于测试或者验证某一个 HBase 的功能,后面我们在详细的介绍 HBase 的几种运行模式。这里我们只要记得在分布式的生产环境中,HBase 需要运行在 HDFS 之上,以 HDFS 作为其基础的存储设施。HBase 上层提供了访问的数据的 Java API 层,供应用访问存储在 HBase 的数据。在 HBase 的集群中主要由 Master 和 Region Server 组成,以及 Zookeeper,具体模块如下图所示。

图 2. HBase 的相关模块

图片描述

接下来,我们简单的一一介绍下 HBase 中相关模块的作用。

  • Master

    HBase Master 用于协调多个 Region Server,侦测各个 Region Server 之间的状态,并平衡 Region Server 之间的负载。HBase Master 还有一个职责就是负责分配 Region 给 Region Server。HBase 允许多个 Master 节点共存,但是这需要 Zookeeper 的帮助。不过当多个 Master 节点共存时,只有一个 Master 是提供服务的,其他的 Master 节点处于待命的状态。当正在工作的 Master 节点宕机时,其他的 Master 则会接管 HBase 的集群。

  • Region Server

    对于一个 Region Server 而言,其包括了多个 Region。Region Server 的作用只是管理表格,以及实现读写操作。Client 直接连接 Region Server,并通信获取 HBase 中的数据。对于 Region 而言,则是真实存放 HBase 数据的地方,也就说 Region 是 HBase 可用性和分布式的基本单位。如果当一个表格很大,并由多个 CF 组成时,那么表的数据将存放在多个 Region 之间,并且在每个 Region 中会关联多个存储的单元(Store)。

  • Zookeeper

    对于 HBase 而言,Zookeeper 的作用是至关重要的。首先 Zookeeper 是作为 HBase Master 的 HA 解决方案。也就是说,是 Zookeeper 保证了至少有一个 HBase Master 处于运行状态。并且 Zookeeper 负责 Region 和 Region Server 的注册。其实 Zookeeper 发展到目前为止,已经成为了分布式大数据框架中容错性的标准框架。不光是 HBase,几乎所有的分布式大数据相关的开源框架,都依赖于 Zookeeper 实现 HA。

一个完整分布式的 HBase 的工作原理示意图如下:

图 3. HBase 的工作原理

图片描述

在上面的图中,我们需要注意几个我们之前没有提到的概念:Store、MemStore、StoreFile 以及 HFile。带着这几个新的概念,我们完整的梳理下整个 HBase 的工作流程。

首先我们需要知道 HBase 的集群是通过 Zookeeper 来进行机器之前的协调,也就是说 HBase Master 与 Region Server 之间的关系是依赖 Zookeeper 来维护。当一个 Client 需要访问 HBase 集群时,Client 需要先和 Zookeeper 来通信,然后才会找到对应的 Region Server。每一个 Region Server 管理着很多个 Region。对于 HBase 来说,Region 是 HBase 并行化的基本单元。因此,数据也都存储在 Region 中。这里我们需要特别注意,每一个 Region 都只存储一个 Column Family 的数据,并且是该 CF 中的一段(按 Row 的区间分成多个 Region)。Region 所能存储的数据大小是有上限的,当达到该上限时(Threshold),Region 会进行分裂,数据也会分裂到多个 Region 中,这样便可以提高数据的并行化,以及提高数据的容量。每个 Region 包含着多个 Store 对象。每个 Store 包含一个 MemStore,和一个或多个 HFile。MemStore 便是数据在内存中的实体,并且一般都是有序的。当数据向 Region 写入的时候,会先写入 MemStore。当 MemStore 中的数据需要向底层文件系统倾倒(Dump)时(例如 MemStore 中的数据体积到达 MemStore 配置的最大值),Store 便会创建 StoreFile,而 StoreFile 就是对 HFile 一层封装。所以 MemStore 中的数据会最终写入到 HFile 中,也就是磁盘 IO。由于 HBase 底层依靠 HDFS,因此 HFile 都存储在 HDFS 之中。这便是整个 HBase 工作的原理简述。

我们了解了 HBase 大致的工作原理,那么在 HBase 的工作过程中,如何保证数据的可靠性呢?带着这个问题,我们理解下 HLog 的作用。HBase 中的 HLog 机制是 WAL 的一种实现,而 WAL(一般翻译为预写日志)是事务机制中常见的一致性的实现方式。每个 Region Server 中都会有一个 HLog 的实例,Region Server 会将更新操作(如 Put,Delete)先记录到 WAL(也就是 HLog)中,然后将其写入到 Store 的 MemStore,最终 MemStore 会将数据写入到持久化的 HFile 中(MemStore 到达配置的内存阀值)。这样就保证了 HBase 的写的可靠性。如果没有 WAL,当 Region Server 宕掉的时候,MemStore 还没有写入到 HFile,或者 StoreFile 还没有保存,数据就会丢失。或许有的读者会担心 HFile 本身会不会丢失,这是由 HDFS 来保证的。在 HDFS 中的数据默认会有 3 份。因此这里并不考虑 HFile 本身的可靠性。

前面,我们很多次提到了 HFile,也就是 HBase 持久化的存储文件。也许有的读者还不能完全理解 HFile,这里我们便详细的看看 HFile 的结构,如下图。

图 4. HFile 的结构

图片描述

从图中我们可以看到 HFile 由很多个数据块(Block)组成,并且有一个固定的结尾块。其中的数据块是由一个 Header 和多个 Key-Value 的键值对组成。在结尾的数据块中包含了数据相关的索引信息,系统也是通过结尾的索引信息找到 HFile 中的数据。HFile 中的数据块大小默认为 64KB。如果访问 HBase 数据库的场景多为有序的访问,那么建议将该值设置的大一些。如果场景多为随机访问,那么建议将该值设置的小一些。一般情况下,通过调整该值可以提高 HBase 的性能。

如果要用很短的一句话总结 HBase,我们可以认为 HBase 就是一个有序的多维 Map,其中每一个 Row-key 映射了许多数据,这些数据存储在 CF 中的 Column。我们可以用下图来表示这句话。

图 5. HBase 的数据映射关系

图片描述

HBase 的使用建议

之前我介绍了很多 HBase 与 RDBMS 的区别,以及一些优势的地方。那么什么时候最需要 HBase,或者说 HBase 是否可以替代原有的 RDBMS?对于这个问题,我们必须时刻谨记——HBase 并不适合所有问题,其设计目标并不是替代 RDBMS,而是对 RDBMS 的一个重要补充,尤其是对大数据的场景。当需要考量 HBase 作为一个备选项时,我们需要进行如下的调研工作。

首先,要确信有足够多数据,如果有上亿或上千亿行数据,HBase 才会是一个很好的备选。其次,需要确信业务上可以不依赖 RDBMS 的额外特性,例如,列数据类型, 二级索引,SQL 查询语言等。再而,需要确保有足够硬件。且不说 HBase,一般情况下当 HDFS 的集群小于 5 个数据节点时,也干不好什么事情 (HDFS 默认会将每一个 Block 数据备份 3 分),还要加上一个 NameNode。

以下我给了一些使用 HBase 时候对表格设计的一些建议,读者也可以理解背后的含义。不过我并不希望这些建议成为使用 HBase 的教条,毕竟也有不尽合理的地方。首先,一个 HBase 数据库是否高效,很大程度会和 Row-Key 的设计有关。因此,如何设计 Row-key 是使用 HBase 时,一个非常重要的话题。随着数据访问方式的不同,Row-Key 的设计也会有所不同。不过概括起来的宗旨只有一个,那就是尽可能选择一个 Row-Key,可以使你的数据均匀的分布在集群中。这也很容易理解,因为 HBase 是一个分布式环境,Client 会访问不同 Region Server 获取数据。如果数据排布均匀在不同的多个节点,那么在批量的 Client 便可以从不同的 Region Server 上获取数据,而不是瓶颈在某一个节点,性能自然会有所提升。对于具体的建议我们一般有几条:

  1. 当客户端需要频繁的写一张表,随机的 RowKey 会获得更好的性能。
  2. 当客户端需要频繁的读一张表,有序的 RowKey 则会获得更好的性能。
  3. 对于时间连续的数据(例如 log),有序的 RowKey 会很方便查询一段时间的数据(Scan 操作)。

上面我们谈及了对 Row-Key 的设计,接着我们需要想想是否 Column Family 也会在不同的场景需要不同的设计方案呢。答案是肯定的,不过 CF 跟 Row-key 比较的话,确实也简单一些,但这并不意味着 CF 的设计就是一个琐碎的话题。在 RDBMS(传统关系数据库)系统中,我们知道如果当用户的信息分散在不同的表中,便需要根据一个 Key 进行 Join 操作。而在 HBase 中,我们需要设计 CF 来聚合用户所有相关信息。简单来说,就是需要将数据按类别(或者一个特性)聚合在一个或多个 CF 中。这样,便可以根据 CF 获取这类信息。上面,我们讲解过一个 Region 对应于一个 CF。那么设想,如果在一个表中定义了多个 CF 时,就必然会有多个 Region。当 Client 查询数据时,就不得不查询多个 Region。这样性能自然会有所下降,尤其当 Region 夸机器的时候。因此在大多数的情况下,一个表格不会超过 2 到 3 个 CF,而且很多情况下都是 1 个 CF 就足够了。

Phoenix 的使用

当一个新业务需要使用 HBase 时,是完全可以使用 Java API 开发 HBase 的应用,从而实现具体的业务逻辑。但是如果对于习惯使用 RDBMS 的 SQL,或者想要将原来使用 JDBC 的应用直接迁移到 HBase,这就是不可能的。由于这种缅怀过去的情怀,便催生了 Phoenix 的诞生。那么 Phoenix 都能提供哪些功能呢?简单来说 Phoenix 在 HBase 之上提供了 OLTP 相关的功能,例如完全的 ACID 支持、SQL、二级索引等,此外 Phoenix 还提供了标准的 JDBC 的 API。在 Phoenix 的帮助下,RDBMS 的用户可以很容易的使用 HBase,并且迁移原有的业务到 HBase 之中。下来就让我们简单了解一下,如何在 HBase 之上使用 Phoenix。

首先我们需要在 Phoenix 的网站下载与 HBase 版本对应的 Phoenix 安装包。我环境的 HBase 是通过 Ambari HDP2.4 部署的,其中的 HBase 是 1.1 的版本,因此我下载的是下图中的 phoenix-4.7.0-HBase-1.1。

图 6. Phoenix 的下载页面

图片描述

下载之后需要解压 Phoenix 的 tar 包,并将所有的 jar 文件拷贝到每台 Region Server 机器的 $HBASE_HOME/lib 下面,并重启所有的 Region Server。对于 Ambari 部署的 HBase,其 HBASE_HOME 目录便是/usr/hdp/2.4.0.0-169/hbase/lib/,添加 Jar 包到该目录之后,可以直接在 Ambari 的 WEB 中,重启整个 HBase。重启之后,我们便尽可以进入到刚才解压的 Phoenix 目录,进入其子目录 bin。在这个目录中 Phoenix 提供了 sqlline.py 脚本。我们可以通过该脚本连接 HBase,并测试相关的 SQL 语句。我们可以在 bin 目录中看到文件 hbase-site.xml,如果需要对 Phoenix 设置相关参数,就需要更改该文件,并将该文件同步给 HBase 中。Sqlline.py 最简单的使用方法,就是直接以 Zookeeper 机器名为参数即可,如下图:

图 7. Sqlline.py 使用示意图

图片描述

上图中,我们还使用了 sqlline.py 支持的 table 命令,该命令可以列出 HBase 中所有的表。这里需要注意 Phoenix 不支持直接显示 HBase Shell(HBase 自带一个 CLI 访问工具,后续文章在介绍)中创建的表格。原因很简单,当在 Phoenix 创建一张表时,Phoenix 是将表进行了重组装。而对 HBase Shell 创建的表 Phoenix 并未进行加工,所以无法直接显示。如果需要将 HBase Shell 中创建的表格关联到 Phoenix 中查看,就需要在 Phoenix 中创建一个视图(View)做关联。例如,我们现在 HBase Shell 中创建了一张表"table1",并插入了几行数据,如下。

然后我们在 Sqlline.py 的终端中执行"!table"命令,我们发现并没有 table1 这张表。接下来我们执行如下的命令:

然后再使用!table 命令,这时候结果如下:

图 8. Phoenix 执行表查询结果

图片描述

我们可以看到结果中多了一个 table1 的视图,这样 Phoenix 就将 table1 表的内容关联到了 Phoenix 的视图当中。我们可以使用 select 等语句访问其中的内容,如下:

图 9. Phoenix 执行查询结果

图片描述

最后我们再回头解释下刚才创建视图的命令。在创建关联的视图时,我们需要确保视图和列的名称与原表的名称完全一致。Phoenix 默认使用大写字母,因此,当 HBase Shell 中使用的是小写,我们便需要使用双引号引用相关的名称。如果原名称是大写,就可以省去双引号。Pk 是我们定义的一个主键名(可以随便定义),这是由于在 HBase Shell 中并没有主键的概念,所以 Row-key 是没有一个名称的。cf1 和 name 加起来用于指向 HBase 中的一个单元格(Cell),示例的命令中我关联了两个单元格(如果你愿意,可以只关联一个)。在安装了 Phoenix 之后,我们应尽量避免直接使用 HBase Shell 来创建表,取而代之的便是直接使用 Phoenix。例如下图中,我使用 Phoenix 创建了一张表 t1,包含了 name 和 age 两个列,并插入了两行数据。具体的命令如下图:

图 10. 如何在 Phoenix 中创建表
图片描述

看到这些命令之后,熟悉 SQL 的读者肯定不会觉得陌生。这便是 Phoenix 提供的最重要的功能之一——SQL 的支持。我们可以看到在 Phoenix 中,我们使用了丰富的数据类型,如 INTEGER 和 VARCHAR。这些都是无法直接在 HBase 中使用的。有兴趣的读者可以在 sqlline.py 中尝试更多的 SQL 语句。当需要从 sqlline.py 退出时,可以执行!quit 命令(可以通过使用!help 查看更多的命令)。退出 sqlline.py 之后,让我们在 HBase Shell 中看看 Phoenix 创建的表会是什么样子。如下:

我们可以明显的看到,Phoenix 将如上的数据进行了重组,才形成了图 10 中所展示的样子。到这里,我们就简单的介绍了 Phoenix 简单的用法。需要强调的是,本章所展示的只是 Phoenix 所提供特性的很小一部分。Phoenix 作为一个标准 JDBC API 的支持者,原有建立在 JDBC 之上应用程序可以直接通过 Phoenix 访问 HBase,例如 Squirrel 这样图形化的 SQL 客户端。当然也可以直接在 Java 代码中通过 JDBC 访问 HBase 数据库,而不用使用 HBase 的 Java API 重新开发。想要了解更多 Phoenix 特性的读者,可以从 Apache Phoenix 官方的文档中查看,例如二级索引等。

总结

对于 HBase 还有很多内容需要介绍,例如使用 Java API 开发应用,快速部署使用(涉及 Ambari 以及 HBase 部署模式)、HBase Shell 以及如何集成 Hive 和 HBase 等。目前 HBase 的应用场景很多,尤其是互联网公司的后台信息存储中,例如淘宝等都在使用 HBase。我相信了解 HBase 之后,可以更好的架构使用大数据解决方案,这里篇幅有限,后续再介绍更多内容。

参考资源

Apache Hadoop

Apache Phoneix

Apache HBase

留言與分享

Apache Spark 简介

Apache Spark 是专为大规模数据处理而设计的快速通用的计算引擎。Spark是UC Berkeley AMP lab (加州大学伯克利分校的AMP实验室)所开源的类Hadoop MapReduce的通用并行框架,Spark,拥有Hadoop MapReduce所具有的优点;但不同于MapReduce的是Job中间输出结果可以保存在内存中,从而不再需要读写HDFS,因此Spark能更好地适用于数据挖掘与机器学习等需要迭代的MapReduce的算法。

Spark 是一种与 Hadoop 相似的开源集群计算环境,但是两者之间还存在一些不同之处,这些有用的不同之处使 Spark 在某些工作负载方面表现得更加优越,换句话说,Spark 启用了内存分布数据集,除了能够提供交互式查询外,它还可以优化迭代工作负载。

Spark 是在 Scala 语言中实现的,它将 Scala 用作其应用程序框架。与 Hadoop 不同,Spark 和 Scala 能够紧密集成,其中的 Scala 可以像操作本地集合对象一样轻松地操作分布式数据集。

尽管创建 Spark 是为了支持分布式数据集上的迭代作业,但是实际上它是对 Hadoop 的补充,可以在 Hadoop 文件系统中并行运行。通过名为 Mesos 的第三方集群框架可以支持此行为。Spark 由加州大学伯克利分校 AMP 实验室 (Algorithms, Machines, and People Lab) 开发,可用来构建大型的、低延迟的数据分析应用程序。

准备工作

环境

1
2
3
4
JDK:1.8  
Spark-2.2.0
Hadoop Release:2.7.4
centos:7.3
主机名 ip地址 安装服务
spark-master 192.168.252.121 jdk、hadoop、spark、scala
spark-slave01 192.168.252.122 jdk、hadoop、spark
spark-slave02 192.168.252.123 jdk、hadoop、spark

依赖环境

Spark 是在 Scala 语言中实现的,它将 Scala 用作其应用程序框架。与 Hadoop 不同,Spark 和 Scala 能够紧密集成,其中的 Scala 可以像操作本地集合对象一样轻松地操作分布式数据集。所有我们安装 Scala

Scala

Scala-2.13.0 安装及配置

Hadoop

Hadoop-2.7.4 集群快速搭建

安装

下载解压

1
2
3
4
5
su hadoop
cd /home/hadoop/
wget https://mirrors.tuna.tsinghua.edu.cn/apache/spark/spark-2.2.0/spark-2.2.0-bin-hadoop2.7.tgz
tar -zxvf spark-2.2.0-bin-hadoop2.7.tgz
mv spark-2.2.0-bin-hadoop2.7 spark-2.2.0

环境变量

如果是对所有的用户都生效就修改vi /etc/profile 文件
如果只针对当前用户生效就修改 vi ~/.bahsrc 文件

1
sudo vi /etc/profile
1
2
3
#spark
export PATH=${SPARK_HOME}/bin:$PATH
export SPARK_HOME=/home/hadoop/spark-2.2.0/

使环境变量生效,运行 source /etc/profile使/etc/profile文件生效

修改配置

修改 spark-env.sh

1
cd /home/hadoop/spark-2.2.0/conf
1
2
mv spark-env.sh.template spark-env.sh
vi spark-env.sh
1
2
3
4
5
6
7
8
#java
export JAVA_HOME=/lib/jvm

#Spark主节点的IP
export SPARK_MASTER_IP=192.168.252.121

#Spark主节点的端口号
export SPARK_MASTER_PORT=7077

简单介绍几个变量

  • JAVA_HOME:Java安装目录
  • SCALA_HOME:Scala安装目录
  • HADOOP_HOME:hadoop安装目录
  • HADOOP_CONF_DIR:hadoop集群的配置文件的目录
  • SPARK_MASTER_IP:spark集群的Master节点的ip地址
  • SPARK_WORKER_MEMORY:每个worker节点能够最大分配给exectors的内存大小
  • SPARK_WORKER_CORES:每个worker节点所占有的CPU核数目
  • SPARK_WORKER_INSTANCES:每台机器上开启的worker节点的数目

修改 slaves

1
cd /home/hadoop/spark-2.2.0/conf
1
2
mv slaves.template slaves
vi slaves
1
2
3
node1
node2
node3

配置集群

复制节点

进去 spark 安装目录 ,打包,并发送,到其他节点

1
2
3
4
5
6
cd cd /home/hadoop/

tar zcvf spark.tar.gz spark-2.2.0

scp spark.tar.gz hadoop@node2:/home/hadoop/
scp spark.tar.gz hadoop@node3:/home/hadoop/

进去 node1,node2 节点 解压

1
2
3
cd /home/hadoop/

tar -zxvf spark.tar.gz

环境变量

到这里一步 确保你的每一个节点 环境变量够数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#jdk
export JAVA_HOME=/lib/jvm
export JRE_HOME=${JAVA_HOME}/jre
export CLASSPATH=.:${JAVA_HOME}/lib:${JRE_HOME}/lib
export PATH=${SPARK_HOME}/bin:${SCALA_HOME}/bin:${HADOOP_HOME}/bin:${JAVA_HOME}/bin:$PATH

#hadoop
export HADOOP_HOME=/home/hadoop/hadoop-2.7.4/

#scala
export SCALA_HOME=/lib/scala

#spark
export SPARK_HOME=/home/hadoop/spark-2.2.0/

启动集群

关闭防火墙

1
systemctl stop firewalld.service

启动 Hadoop

1
2
3
cd /home/hadoop/hadoop-2.7.4/sbin

./start-all.sh

启动 Spark

1
2
3
cd /home/hadoop/spark-2.2.0/sbin

./start-all.sh

启动 Spark Shell

1
2
3
cd /home/hadoop/spark-2.2.0/bin

./spark-shell

spark 访问:192.168.252.121:8080

spark-shell 访问:192.168.252.121:4040

图片描述

图片描述

留言與分享

搭建 MySQL 5.7.19 主从复制,以及复制实现细节分析

概念

主从复制可以使MySQL数据库主服务器的主数据库,复制到一个或多个MySQL从服务器从数据库,默认情况下,复制异步; 根据配置,可以复制数据库中的所有数据库,选定的数据库或甚至选定的表。

MySQL中主从复制的优点

横向扩展解决方案

在多个从库之间扩展负载以提高性能。在这种环境中,所有写入和更新在主库上进行。但是,读取可能发生在一个或多个从库上。该模型可以提高写入的性能(由于主库专用于更新),同时在多个从库上读取,可以大大提高读取速度。

数据安全性

由于主库数据被复制到从库,从库可以暂停复制过程,可以在从库上运行备份服务,而不会破坏对应的主库数据。

分析

可以在主库上创建实时数据,而信息分析可以在从库上进行,而不会影响主服务器的性能。

长距离数据分发

可以使用复制创建远程站点使用的数据的本地副本,而无需永久访问主库。

1.准备工作

参考 MySQL官网 - 第16章主从复制

Mysql版本:MySQL 5.7.19
Master-Server : 192.168.252.123
Slave-Server : 192.168.252.124

关闭防火墙

1
$ systemctl stop firewalld.service

安装 MySQL

参考 - CentOs7.3 安装 MySQL 5.7.19 二进制版本

首先在两台机器上装上,保证正常启动,可以使用

2. Master-Server 配置

修改 my.cnf

配置 Master 以使用基于二进制日志文件位置的复制,必须启用二进制日志记录并建立唯一的服务器ID,否则则无法进行主从复制。

停止MySQL服务。

1
$ service mysql.server stop

开启binlog ,每台设置不同的 server-id

1
2
3
4
$ cat /etc/my.cnf
[mysqld]
log-bin=mysql-bin
server-id=1

启动MySQL服务

1
$ service mysql.server start

登录MySQL

1
$ /usr/local/mysql/bin/mysql -uroot -p

创建用户

每个从库使用MySQL用户名和密码连接到主库,因此主库上必须有用户帐户,从库可以连接。任何帐户都可以用于此操作,只要它已被授予 REPLICATION SLAVE权限。可以选择为每个从库创建不同的帐户,或者每个从库使用相同帐户连接到主库

虽然不必专门为复制创建帐户,但应注意,复制用到的用户名和密码会以纯文本格式存储在主信息存储库文件或表中 。因此,需要创建一个单独的帐户,该帐户只具有复制过程的权限,以尽可能减少对其他帐户的危害。

登录MySQL

1
$ /usr/local/mysql/bin/mysql -uroot -p
1
2
mysql> CREATE USER 'replication'@'192.168.252.124' IDENTIFIED BY 'mima';
mysql> GRANT REPLICATION SLAVE ON *.* TO 'replication'@'192.168.252.124';

3.Slave-Server 配置

修改 my.cnf

停止MySQL服务。

1
$ service mysql.server stop
1
2
3
$ cat /etc/my.cnf
[mysqld]
server-id=2

如果要设置多个从库,则每个从库的server-id与主库和其他从库设置不同的唯一值。

启动MySQL服务

1
$ service mysql.server start

登录MySQL

1
$ /usr/local/mysql/bin/mysql -uroot -p

配置主库通信

查看 Master-Server , binlog File 文件名称和 Position值位置 并且记下来

1
2
3
4
5
6
7
mysql>  show master status;
+------------------+----------+--------------+------------------+-------------------+
| File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set |
+------------------+----------+--------------+------------------+-------------------+
| mysql-bin.000001 | 629 | | | |
+------------------+----------+--------------+------------------+-------------------+
1 row in set (0.00 sec)

要设置从库与主库进行通信,进行复制,使用必要的连接信息配置从库在从库上执行以下语句
将选项值替换为与系统相关的实际值

参数格式,请勿执行

1
2
3
4
5
6
mysql> CHANGE MASTER TO
-> MASTER_HOST='master_host_name',
-> MASTER_USER='replication_user_name',
-> MASTER_PASSWORD='replication_password',
-> MASTER_LOG_FILE='recorded_log_file_name',
-> MASTER_LOG_POS=recorded_log_position;
1
2
3
4
5
6
7
mysql> CHANGE MASTER TO
-> MASTER_HOST='192.168.252.123',
-> MASTER_USER='replication',
-> MASTER_PASSWORD='mima',
-> MASTER_LOG_FILE='mysql-bin.000001',
-> MASTER_LOG_POS=629;
Query OK, 0 rows affected, 2 warnings (0.02 sec)

MASTER_LOG_POS=0 写成0 也是可以的

放在一行执行方便

1
CHANGE MASTER TO MASTER_HOST='192.168.252.123', MASTER_USER='replication', MASTER_PASSWORD='mima', MASTER_LOG_FILE='mysql-bin.000001', MASTER_LOG_POS=629;

启动从服务器复制线程

1
2
mysql> START SLAVE;
Query OK, 0 rows affected (0.00 sec)

查看复制状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mysql>  show slave status\G
*************************** 1. row ***************************
Slave_IO_State: Waiting for master to send event
Master_Host: 192.168.252.123
Master_User: replication
Master_Port: 3306
Connect_Retry: 60
Master_Log_File: mysql-bin.000001
Read_Master_Log_Pos: 629
Relay_Log_File: master2-relay-bin.000003
Relay_Log_Pos: 320
Relay_Master_Log_File: mysql-bin.000001
Slave_IO_Running: Yes
Slave_SQL_Running: Yes
......

检查主从复制通信状态

Slave_IO_State #从站的当前状态
Slave_IO_Running: Yes #读取主程序二进制日志的I/O线程是否正在运行
Slave_SQL_Running: Yes #执行读取主服务器中二进制日志事件的SQL线程是否正在运行。与I/O线程一样
Seconds_Behind_Master #是否为0,0就是已经同步了

必须都是 Yes

如果不是原因主要有以下 4 个方面:

1、网络不通
2、密码不对
3、MASTER_LOG_POS 不对 ps
4、mysql 的 auto.cnf server-uuid 一样(可能你是复制的mysql)

1
2
3
4
$ find / -name 'auto.cnf'
$ cat /var/lib/mysql/auto.cnf
[auto]
server-uuid=6b831bf3-8ae7-11e7-a178-000c29cb5cbc # 按照这个16进制格式,修改server-uuid,重启mysql即可

检查复制状态

4.测试主从复制

启动MySQL服务

1
$ service mysql.server start

登录MySQL

1
$ /usr/local/mysql/bin/mysql -uroot -p

在 Master-Server 创建测试库

1
2
3
mysql> CREATE DATABASE `replication_wwww.ymq.io`;
mysql> use `replication_wwww.ymq.io`;
mysql> CREATE TABLE `sync_test` (`id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;

在 Slave-Server 查看是否同步过来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
mysql> show databases;
+-------------------------+
| Database |
+-------------------------+
| information_schema |
| mysql |
| performance_schema |
| replication_wwww.ymq.io |
| sys |
+-------------------------+

mysql> use replication_wwww.ymq.io
mysql> show tables;

+-----------------------------------+
| Tables_in_replication_wwww.ymq.io |
+-----------------------------------+
| sync_test |
+-----------------------------------+
1 row in set (0.00 sec)

一些命令

查看主服务器的运行状态

1
2
3
4
5
6
mysql> show master status;
+------------------+----------+--------------+------------------+-------------------+
| File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set |
+------------------+----------+--------------+------------------+-------------------+
| mysql-bin.000001 | 1190 | | | |
+------------------+----------+--------------+------------------+-------------------+

查看从服务器主机列表

1
2
3
4
5
6
mysql> show slave hosts;
+-----------+------+------+-----------+--------------------------------------+
| Server_id | Host | Port | Master_id | Slave_UUID |
+-----------+------+------+-----------+--------------------------------------+
| 2 | | 3306 | 1 | 6b831bf2-8ae7-11e7-a178-000c29cb5cbc |
+-----------+------+------+-----------+--------------------------------------+

获取binlog文件列表

1
2
3
4
5
6
mysql> show binary logs;
+------------------+-----------+
| Log_name | File_size |
+------------------+-----------+
| mysql-bin.000001 | 1190 |
+------------------+-----------+

只查看第一个binlog文件的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
mysql> mysql> show binlog events;
+------------------+-----+----------------+-----------+-------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Log_name | Pos | Event_type | Server_id | End_log_pos | Info |
+------------------+-----+----------------+-----------+-------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| mysql-bin.000001 | 4 | Format_desc | 1 | 123 | Server ver: 5.7.19-log, Binlog ver: 4 |
| mysql-bin.000001 | 123 | Previous_gtids | 1 | 154 | |
| mysql-bin.000001 | 420 | Anonymous_Gtid | 1 | 485 | SET @@SESSION.GTID_NEXT= 'ANONYMOUS' |
| mysql-bin.000001 | 485 | Query | 1 | 629 | GRANT REPLICATION SLAVE ON *.* TO 'replication'@'192.168.252.124' |
| mysql-bin.000001 | 629 | Anonymous_Gtid | 1 | 694 | SET @@SESSION.GTID_NEXT= 'ANONYMOUS' |
| mysql-bin.000001 | 694 | Query | 1 | 847 | CREATE DATABASE `replication_wwww.ymq.io` |
| mysql-bin.000001 | 847 | Anonymous_Gtid | 1 | 912 | SET @@SESSION.GTID_NEXT= 'ANONYMOUS' |
| mysql-bin.000001 | 912 | Query | 1 | 1190 | use `replication_wwww.ymq.io`; CREATE TABLE `sync_test` (`id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8 |
+------------------+-----+----------------+-----------+-------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

查看指定binlog文件的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
mysql> mysql> show binlog events in 'mysql-bin.000001';
+------------------+-----+----------------+-----------+-------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Log_name | Pos | Event_type | Server_id | End_log_pos | Info |
+------------------+-----+----------------+-----------+-------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| mysql-bin.000001 | 4 | Format_desc | 1 | 123 | Server ver: 5.7.19-log, Binlog ver: 4 |
| mysql-bin.000001 | 123 | Previous_gtids | 1 | 154 | |
| mysql-bin.000001 | 420 | Anonymous_Gtid | 1 | 485 | SET @@SESSION.GTID_NEXT= 'ANONYMOUS' |
| mysql-bin.000001 | 485 | Query | 1 | 629 | GRANT REPLICATION SLAVE ON *.* TO 'replication'@'192.168.252.124' |
| mysql-bin.000001 | 629 | Anonymous_Gtid | 1 | 694 | SET @@SESSION.GTID_NEXT= 'ANONYMOUS' |
| mysql-bin.000001 | 694 | Query | 1 | 847 | CREATE DATABASE `replication_wwww.ymq.io` |
| mysql-bin.000001 | 847 | Anonymous_Gtid | 1 | 912 | SET @@SESSION.GTID_NEXT= 'ANONYMOUS' |
| mysql-bin.000001 | 912 | Query | 1 | 1190 | use `replication_wwww.ymq.io`; CREATE TABLE `sync_test` (`id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8 |
+------------------+-----+----------------+-----------+-------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

启动从库复制线程

1
2
mysql> START SLAVE;
Query OK, 0 rows affected, 1 warning (0.00 sec)

停止从库复制线程

1
2
mysql> STOP SLAVE;
Query OK, 0 rows affected (0.00 sec)

5.复制实现细节分析

MySQL主从复制功能使用三个线程实现一个在主服务器上两个在从服务器上

1.Binlog转储线程。

当从服务器与主服务器连接时,主服务器会创建一个线程将二进制日志内容发送到从服务器。
该线程可以使用 语句 SHOW PROCESSLIST(下面有示例介绍) 在服务器 sql 控制台输出中标识为Binlog Dump线程。

二进制日志转储线程获取服务器上二进制日志上的锁,用于读取要发送到从服务器的每个事件。一旦事件被读取,即使在将事件发送到从服务器之前,锁会被释放。

2.从服务器I/O线程。

当在从服务器sql 控制台发出 START SLAVE语句时,从服务器将创建一个I/O线程,该线程连接到主服务器,并要求它发送记录在主服务器上的二进制更新日志。

从机I/O线程读取主服务器Binlog Dump线程发送的更新 (参考上面 Binlog转储线程 介绍),并将它们复制到自己的本地文件二进制日志中。

该线程的状态显示详情 Slave_IO_running 在输出端 使用 命令SHOW SLAVE STATUS

使用\G语句终结符,而不是分号,是为了,易读的垂直布局

这个命令在上面 查看从服务器状态 用到过

1
mysql> SHOW SLAVE STATUS\G

3.从服务器SQL线程。

从服务器创建一条SQL线程来读取由主服务器I/O线程写入的二级制日志,并执行其中包含的事件。

在前面的描述中,每个主/从连接有三个线程。主服务器为每个当前连接的从服务器创建一个二进制日志转储线程,每个从服务器都有自己的I/O和SQL线程。
从服务器使用两个线程将读取更新与主服务器更新事件,并将其执行为独立任务。因此,如果语句执行缓慢,则读取语句的任务不会减慢。

例如,如果从服务器开始几分钟没有运行,或者即使SQL线程远远落后,它的I/O线程也可以从主服务器建立连接时,快速获取所有二进制日志内容。

如果从服务器在SQL线程执行所有获取的语句之前停止,则I/O线程至少获取已经读取到的内容,以便将语句的安全副本存储在自己的二级制日志文件中,准备下次执行主从服务器建立连接,继续同步。

使用命令 SHOW PROCESSLIST\G 可以查看有关复制的信息

命令 SHOW FULL PROCESSLISTG

在 Master 主服务器 执行的数据示例

1
2
3
4
5
6
7
8
9
10
mysql>  SHOW FULL PROCESSLIST\G
*************************** 1. row ***************************
Id: 22
User: repl
Host: node2:39114
db: NULL
Command: Binlog Dump
Time: 4435
State: Master has sent all binlog to slave; waiting for more updates
Info: NULL

Id: 22是Binlog Dump服务连接的从站的复制线程
Host: node2:39114 是从服务,主机名 级及端口
State: 信息表示所有更新都已同步发送到从服务器,并且主服务器正在等待更多更新发生。
如果Binlog Dump在主服务器上看不到 线程,意味着主从复制没有配置成功; 也就是说,没有从服务器连接主服务器。

命令 SHOW PROCESSLISTG

在 Slave 从服务器 ,查看两个线程的更新状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
mysql> SHOW PROCESSLIST\G
*************************** 1. row ***************************
Id: 6
User: system user
Host:
db: NULL
Command: Connect
Time: 6810
State: Waiting for master to send event
Info: NULL
*************************** 2. row ***************************
Id: 7
User: system user
Host:
db: NULL
Command: Connect
Time: 3069
State: Slave has read all relay log; waiting for more updates
Info: NULL

Id: 6是与主服务器通信的I/O线程
Id: 7是正在处理存储在中继日志中的更新的SQL线程

在 运行 SHOW PROCESSLIST 命令时,两个线程都空闲,等待进一步更新

如果在主服务器上在设置的超时,时间内 Binlog Dump线程没有活动,则主服务器会和从服务器断开连接。超时取决于的 服务器系统变量 值 net_write_timeout(在中止写入之前等待块写入连接的秒数,默认10秒)和 net_retry_count;(如果通信端口上的读取或写入中断,请在重试次数,默认10次) 设置 服务器系统变量

该SHOW SLAVE STATUS语句提供了有关从服务器上复制处理的附加信息。请参见 第16.1.7.1节“检查复制状态”。

6.更多常见主从复制问题:

常见主从复制问题

留言與分享

作者的圖片

Kein Chan

這是獨立全棧工程師Kein Chan的技術博客
分享一些技術教程,命令備忘(cheat-sheet)等


全棧工程師
資深技術顧問
數據科學家
Hit廣島觀光大使


Tokyo/Macau