牛刀小试 – 详细总结Java-IO流的使用
最后更新于:2022-04-01 20:08:54
# 流的概念和作用
流是一组有顺序的,有起点和终点的字节集合,是对数据传输的总称或抽象。即数据在两设备间的传输称为流。
流的本质是数据传输,根据数据传输的不同特性将流抽象封装成不同的类,方便更直观的进行数据操作。
### IO流的分类
-
根据处理数据类型的不同分为:字符流和字节流
-
根据数据流向不同分为:输入流和输出流
### 输入流和输出流
所谓输入流和输出流,实际是相对内存而言。
-
将外部数据读取到Java内存当中就是所谓的输入流
-
而将Java内存当中的数据写入存放到外部设备当中,就是所谓的输出流
### 字符流和字节流
实际上最根本的流都是字节流,因为计算机上数据存储的最终形式实际上都是字节。
而字符流的由来是因为:不同文字的数据编码不同,所以才有了对字符进行高效操作的流对象。
本质其实就是基于字节流读取时,去查了指定的码表。 所以,字节流和字符流的区别在于:
-
读写单位不同:字节流以字节(8bit)为单位,字符流以字符为单位,根据码表映射字符,一次可能读多个字节。
-
处理对象不同:字节流能处理所有类型的数据(如图片、avi等),而字符流只能处理字符类型的数据。
通过一张关系图来了解一下Java中的IO流构成:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-03-15_56e77dbc615d9.jpg)
通过该图,我们除了可以看到IO流体系下,数量众多的具体子类。
还可以注意到:这些子类的命名有一个规律,那就是:
字符流体系当中的顶层类是Reader和Writer;字节流体系当中的顶层类是InputStream和OutputStream。
而它们对应的体系下的子类的命名中,都以该顶层类的命名作为其后缀,而前缀则说明了具体的特征。
### 正确选择合适的IO对象
在上面的Java-IO体系构成图当中,我们已经发现这个体系中拥有众多特定的子类,那么,我们应该如何在如此众多的选择中选取最适合的IO对象使用呢?
究其根本,在众多的子类当中,其实落实下来对于数据的操作方法基本上都很类似,都离不开对于数据的读(read())写(write())方法。
所以,只要了解不同IO流对象的自身特性之后,基本上就可以按照以下的步骤,来根据实际需求选取最合适的IO流对象使用:
(1)、明确你要做的操作是读还是写(也就是明确操作的是数据源还是数据目的)
-
如果是要读取数据(操作数据源):InputStream或Reader
-
如果是要写入数据(操作数据目的):OutputSteam或Writer
(2)、明确要处理的数据是否是纯文本数据
-
如果数据为纯文本:Reader或Writer (字符流对象操作文本数据效率更高)
-
否则使用:InputStream或OutputSteam
(3)、明确源或目的的具体设备
-
硬盘(外部文件) - File
-
内存 - 数组
-
网络数据 - Socket流
(4)、明确是否需要额外功能
假设以一个小的需求:“请复制一个txt文件当中的内容至新的txt文件当中”为例。我们来验证一下上面所说的步骤:
(1)、复制一个文件,自然读写操作都要涉及到。所以这里数据源可选:InputStream或Reader,数据目的可选:OutputSteam或Writer。
(2)、数据源是一个txt文件,其内容是纯文本数据。于是我们可以进一步缩小范围,数据源被确定为:Reader;同理,数据目的为:Writer。
(3)、明确数据涉及的具体设备,我们已经注意到关键词“文件”。那么对于具体的流对象的选择,莫数据源FileReader,数据目的FileWriter莫属了。
到了这里,要选取的IO对象已经明确了,剩下的工作无非就是通过对象调用对应的读写方法来操作数据了。
除此之外,我们说到,有时还需要明确的是:你是否需要一些额外的功能。例如:
如果你想要提高操作数据的效率,可以选择缓冲区流对象(Buffered...)来对原有的流对象进行装饰;
或者,假如你操作的数据源是字节流,而数据目的为字符流,那么可以通过转换流对象(InputStreamReader/OutputStreamWriter),
来让输入流与输出流转换达成一致,从而提高读写操作的效率。
举个例子说:要获取用户在控制台输入的数据,存放到一个文本文件当中去。
这个时候,数据源为系统的输入流:System.in,该输入流属于字节流InputStream体系中。
但实际上我们知道用户通过键盘输入的数据其实就是纯文本数据,所以我们最好选择使用字符流Reader来处理数据,效率更高。
这个时候就可以使用转换流对象InputStreamReader来将原本的字节流转换为字符流来处理:
InputStreamReader isr = newInputStreamReader(System.in);
而同时我们希望加入缓冲区技术,从而使效率进一步提升,最终的代码就变为了:
BufferedReader bufr = new BufferedReader(new InputStreamReader(System.in));
# IO流的具体应用
### 1、输入输出流的常用操作
首先就以“复制文件”为例,分别看一看字符流和字节流的常用操作。
通过字节流对象来完成上述操作,代码为:
~~~
package com.tsr.j2seoverstudy.io;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
/*
* 通过字节流对象:FileInputStream,完成文件内容复制
*/
public class ByteStreamDemo {
private static final String LINE_SEPARATOR = System
.getProperty("line.separator");
private static final int BUFFER_SIZE = 1024;
public static void main(String[] args) {
writeContentInFile();
copyFile_1();
copyFile_2();
}
static void writeContentInFile() {
// 声明输出流对象.
FileOutputStream fos = null;
// 指定输出内容
String content = "first" + LINE_SEPARATOR + "second";
try {
// 构造流对象,指定输出文件
fos = new FileOutputStream("byteStream_1.txt");
// 向指定文件中写入指定内容
fos.write(content.getBytes());
} catch (FileNotFoundException e) {
System.out.println("未查找到对应文件!");
} catch (IOException e) {
System.out.println("书写异常!");
} finally {
if (fos != null)
try {
fos.close();
} catch (IOException e) {
System.out.println("关闭流对象异常.");
}
}
}
static void copyFile_1() {
// 输入流对象
FileInputStream fis = null;
// 输出流对象
FileOutputStream fos = null;
try {
fis = new FileInputStream("byteStream_1.txt");
fos = new FileOutputStream("byteStream_1_copy.txt");
int position = 0;
while ((position = fis.read()) != -1) {
fos.write(position);
fos.flush();
}
} catch (FileNotFoundException e) {
} catch (IOException e) {
} finally {
try {
if (fis != null)
fis.close();
if (fos != null)
fos.close();
} catch (IOException e) {
// TODO: handle exception
}
}
}
static void copyFile_2() {
// 输入流对象
FileInputStream fis = null;
// 输出流对象
FileOutputStream fos = null;
try {
fis = new FileInputStream("byteStream_1.txt");
fos = new FileOutputStream("byteStream_1_copy.txt");
byte[] buffer = new byte[BUFFER_SIZE];
int length = 0;
while ((length = fis.read(buffer)) != -1) {
fos.write(buffer,0,length);
fos.flush();
}
} catch (FileNotFoundException e) {
} catch (IOException e) {
} finally {
try {
if (fis != null)
fis.close();
if (fos != null)
fos.close();
} catch (IOException e) {
// TODO: handle exception
}
}
}
}
~~~
而通过字节流的代码为:
~~~
package com.tsr.j2seoverstudy.io;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
public class CharacterStreamDemo {
private static final String LINE_SEPARATOR = System
.getProperty("line.separator");
private static final int BUFFER_SIZE = 1024;
public static void main(String[] args) {
writeContentInFile();
//copyFile_1();
copyFile_2();
}
private static void copyFile_2() {
FileWriter fw = null;
FileReader fr = null;
try {
fw = new FileWriter("characterStream_1_copy.txt");
fr = new FileReader("characterStream_1.txt");
int length = 0;
char [] buffer = new char[BUFFER_SIZE];
while ((length = fr.read(buffer)) != -1) {
fw.write(buffer,0,length);
fw.flush();
}
} catch (IOException e) {
// TODO: handle exception
} finally {
try {
if (fr != null)
fr.close();
if (fw != null)
fw.close();
} catch (IOException e) {
// TODO: handle exception
}
}
}
private static void copyFile_1() {
FileWriter fw = null;
FileReader fr = null;
try {
fw = new FileWriter("characterStream_1_copy.txt");
fr = new FileReader("characterStream_1.txt");
int position = 0;
while ((position = fr.read()) != -1) {
fw.write(position);
}
} catch (IOException e) {
// TODO: handle exception
} finally {
try {
if (fr != null)
fr.close();
if (fw != null)
fw.close();
} catch (IOException e) {
// TODO: handle exception
}
}
}
private static void writeContentInFile() {
FileWriter fw = null;
String content = "first" + LINE_SEPARATOR + "second";
try {
fw = new FileWriter("characterStream_1.txt");
fw.write(content);
} catch (IOException e) {
// TODO: handle exception
} finally {
try {
if (fw != null) {
fw.close();
}
} catch (IOException e) {
// TODO: handle exception
}
}
}
}
~~~
通过上述的两个例子中,主要想要说明的几点是:
1.可以看到对于所谓的字节流和字符流之间,对于数据的输入输出操作的方法使用,实际上大同小异的。
2.首先可以看到的不同是,字节流文件对象在向外部文件写入内容时,必须通过单个int型的值或byte数组的形式。
而字符流则可以通过int值,char型数组写入之外,还可以直接将字符串对象(String)形式写入。
3.对于流对象的读入数据的操作,都有两种方式:
第一种方式是,每次读取单个数据:字节流每次读取单个字节,字符流每次读入单个字符(2个字节)。
但同样都返回一个int型的值。字符流返回的这个值实际就读取到的字符在Unicode码表中的位置。返回值为-1则代表数据读取完毕。
第二种方式是,每次读取批量的数据到一个指定类型的缓冲区数组当中,从而提高了读取效率。不同的是:
字节流能够接受的缓冲区数组类型为byte型数组,而字符流接受的类型为char型数组。
它们也会返回一个int型的值,该值代表当前读取操作读取到的数据的实际长度,同样返回-1代表读取完毕。
4、调用完成流对象的写入操作之后,一定要记得使用flush刷新该流对象的缓冲,才能真正的将数据写入到外部文件。
flush和close的区别在于:flush用于刷新流的缓冲,但不关闭该流对象。
调用close方法关闭流对象,并且之前会自动的调用一次flush刷新缓冲。
5、通过上述几点的描述,我们也进一步验证了,当操作数据为纯文本时,应当选取字符流,因为效率更高。
但如果数据为非纯文本,例如赋值一张图片操作时,就只能选择字节流来完成。
### 2、缓冲区流对象
我们前面已经说过了,Java中IO体系下的各个类的命名都有很强的目的性。
所以顾名思义,缓冲区流对象就是指该体系下前缀为buffered的几个类。
缓冲区流对象,最大的好处在于:提高数据读写操作的效率。
如果仅仅是了解对于缓冲区流对象的使用方法,实际上并不复杂,其方式就是:
创建一个缓冲区流对象,然后将需要提高操作效率的流对象作为构造参数传递给创建的该缓冲区流对象就搞定了。
~~~
public class BufferedDemo {
public static void main(String[] args) {
try {
FileWriter fw = new FileWriter("buffer.txt");
BufferedWriter bw = new BufferedWriter(fw);
bw.write("缓冲区流对象用于调高读写效率");
bw.close();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
~~~
而我们需要了解的是,缓冲区流对象究竟是怎样实现提高效率的目的的呢?
在前面我们用于说明输出流的使用时,你可能已经看到了这样的代码:
~~~
int length = 0;
char [] buffer = new char[BUFFER_SIZE];
while ((length = fr.read(buffer)) != -1) {
fw.write(buffer,0,length);
fw.flush();
}
~~~
没错,这里我们将输入流读取到的指定数量的数据先存放在一个指定类型的数组当中。
然后再将存放在数组中的数据取出,进行写入操作。这里数组就起到了一个临时存储的作用,也就是所谓的缓冲区。
而Java中的缓冲区流对象,其实现原理其实也是这样。只不过他们将这些较复杂的操作封装了起来,形成一个单独的类,更加方便使用。
了解了原理,其实我们自己也可以定义所谓的缓冲区流对象。例如:
~~~
public class MyBufferedReader extends Reader {
private Reader r;
//定义一个数组作为缓冲区。
private char[] buf = new char[1024];
//定义一个指针用于操作这个数组中的元素。当操作到最后一个元素后,指针应该归零。
private int pos = 0;
//定义一个计数器用于记录缓冲区中的数据个数。 当该数据减到0,就从源中继续获取数据到缓冲区中。
private int count = 0;
MyBufferedReader(Reader r){
this.r = r;
}
/**
* 该方法从缓冲区中一次取一个字符。
* @return
* @throws IOException
*/
public int myRead() throws IOException{
if(count==0){
count = r.read(buf);
pos = 0;
}
if(count<0)
return -1;
char ch = buf[pos++];
count--;
return ch;
/*
//1,从源中获取一批数据到缓冲区中。需要先做判断,只有计数器为0时,才需要从源中获取数据。
if(count==0){
count = r.read(buf);
if(count<0)
return -1;
//每次获取数据到缓冲区后,角标归零.
pos = 0;
char ch = buf[pos];
pos++;
count--;
return ch;
}else if(count>0){
char ch = buf[pos];
pos++;
count--;
return ch;
}*/
}
public String myReadLine() throws IOException{
StringBuilder sb = new StringBuilder();
int ch = 0;
while((ch = myRead())!=-1){
if(ch=='\r')
continue;
if(ch=='\n')
return sb.toString();
//将从缓冲区中读到的字符,存储到缓存行数据的缓冲区中。
sb.append((char)ch);
}
if(sb.length()!=0)
return sb.toString();
return null;
}
public void myClose() throws IOException {
r.close();
}
@Override
public int read(char[] cbuf, int off, int len) throws IOException {
return 0;
}
@Override
public void close() throws IOException {
}
}
~~~
### 3、转换流
转换流是指InputStreamReader和OutputStreamWriter。顾名思义,也就是指用于在字符流与字节流之间做转换工作的流对象。
那么,我么已经知道了字符流与字节流之间的本质区别就是:字节流实际是通过查指定的编码表,一次提取对应数量的字节转换为字符。
所以,既然是转换流,自然就离不开一个重要的概念:编码。而在转换流对象的构造器中,也可以看到提供了指定编码形式的构造器(默认编码为unicode)。
那么,以我们在上面提到过的需求 “将用户从键盘输入的值,存储到一个外部txt文件当中”为例,通过转换流的实现方式为:
~~~
package com.tsr.j2seoverstudy.io;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
public class CastStreamDemo {
public static void main(String[] args) {
// 获取系统输入流
InputStream in = System.in;
// 因为用户从键盘输入的数据肯定是纯文本,所以为了提高效率,我们将其转换为字符流处理
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
try {
//构造输出流
FileWriter fw = new FileWriter("castSteam.txt");
BufferedWriter writer = new BufferedWriter(fw);
String line = null;
//每次读取单行数据写入外部文件
while((line = reader.readLine())!= null){
//如果用户输入quit,代表退出当前程序
if(line.equals("quit"))
break;
writer.write(line);
writer.newLine();
writer.flush();
}
} catch (IOException e) {
}
}
}
~~~
### 4、File类
提到IO流,就不得不提到一个与之紧密相连的类的使用,也就是File类。
File类封装了一系列对于文件或文件夹的操作方法,包括:创建,删除,重命名,获取文件信息等等。
我们依然通过一段代码,来看一看File类的常用操作:
~~~
package com.tsr.j2seoverstudy.io;
import java.io.File;
import java.io.IOException;
import java.text.DateFormat;
import java.util.Date;
public class FileDemo {
public static void main(String[] args) {
creatNewFile();
deleteAllFile(new File("E:\\FileTest"));
getFileInfo();
}
/*
* 创建文件或文件夹
*/
public static void creatNewFile() {
// 创建单个文件夹
File folder = new File("E:\\FileTest");
if (!folder.exists()) {// 判断指定文件夹是否已经存在
boolean b = folder.mkdir();
System.out.println("文件夹创建是否成功?" + b);
}
// 创建多级文件目录
File folders = new File("E:\\FileTest\\1\\2\\3\\4");
if (!folders.exists()) {
boolean b = folders.mkdirs();
System.out.println("多级文件目录创建是否成功?" + b);
}
// 创建文件
File txt = new File("E:\\FileTest\\file.txt");
if (!txt.exists()) {
try {
boolean b = txt.createNewFile();
System.out.println("txt文件是否创建成功?" + b);
} catch (IOException e) {
}
}
}
/*
* 删除文件,分为两种情况:
* 1、删除单个文件或删除删除空的文件目录,这种情况很简单,直接调用delete方法就搞定了
* 2、但是当要删除指定目录下的所有内容(包括该目录下的文件以及其子目录和子目录下的文件), 这时就需要涉及深度的遍历,通过递归来完成。
*/
public static void deleteAllFile(File dir) {
File[] files = dir.listFiles();
System.out.println(files.length);
// 遍历file对象
for (File file : files) {
// 如果是文件夹
if (file.isDirectory()) {
// 递归
deleteAllFile(file);
} else {// 否则直接删除文件
file.delete();
}
}
// 删除文件夹
dir.delete();
}
//重命名
public static void renameToDemo() {
File f1 = new File("c:\\9.mp3");
File f2 = new File("d:\\aa.mp3");
boolean b = f1.renameTo(f2);
System.out.println("b="+b);
}
//文件对象的一些常用判断方法
public static void decideFile(){
File folder = new File("E:\\FileTest");
//判断文件是否存在
boolean b = folder.exists();
//判断是不是文件夹
boolean b1 = folder.isDirectory();
//判断是不是文件
boolean b2 = folder.isFile();
//判断是不是隐藏文件
boolean b3 = folder.isHidden();
//测试指定路径是不是绝对路径
boolean b4 = folder.isAbsolute();
}
//获取File对象信息
public static void getFileInfo(){
File file = new File("E:\\FileTest\\");
String name = file.getName();//返回由此抽象路径名表示的文件或目录的名称
String absPath = file.getAbsolutePath();//绝对路径。
String path = file.getPath();//将此抽象路径名转换为一个路径名字符串。
long len = file.length();//返回由此抽象路径名表示的文件的长度
long time = file.lastModified();//最后修改时间
Date date = new Date(time);
DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.LONG,DateFormat.LONG);
String str_time = dateFormat.format(date);
//返回此抽象路径名父目录的路径名字符串;如果此路径名没有指定父目录,则返回 null
String parentPath = file.getParent();
System.out.println("parent:"+parentPath);
System.out.println("name:"+name);
System.out.println("absPath:"+absPath);
System.out.println("path:"+path);
System.out.println("len:"+len);
System.out.println("time:"+time);
System.out.println("str_time:"+str_time);
}
}
~~~
对于file的使用,需要值得注意的就是:
关于对file对象的删除方法,如果指定的路径所代表的是一个文件目录,并且该目录下还存在其它文件或子目录。
那么该对象代表的文件夹是无法被删除的 ,原理很简单:你可以试着通过cmd命令行删除这样的文件夹,当然是行不通的。
所以如果要删除这样的文件目录,通常会通过递归对该文件目录下的内容进行深度的遍历,逐次将所有内容删除后,才能删除该文件夹。
而关于通过file对象的list或listFile方法进行文件内容的遍历时,还可以通过自定义拦截器(如FileFilter)来进行有条件的遍历。
例如:遍历出所有后缀为".txt"的文件。
### 5、通过Properties,生成你自己的属性文件
在实际的开发中,通常都会通过一系列的属性文件来记录我们软件的相关信息或者客户的使用情况。
例如:我们开发了一个软件,可以提供免费试用5次。当试用次数用完后,就必须付费购买正版。
~~~
public class PropertiesDemo {
/**
* @param args
* @throws IOException
* @throws Exception
*/
public static void main(String[] args) throws IOException {
getAppCount();
}
public static void getAppCount() throws IOException{
//将配置文件封装成File对象。
File confile = new File("count.properties");
if(!confile.exists()){
confile.createNewFile();
}
FileInputStream fis = new FileInputStream(confile);
Properties prop = new Properties();
prop.load(fis);
//从集合中通过键获取次数。
String value = prop.getProperty("time");
//定义计数器。记录获取到的次数。
int count =0;
if(value!=null){
count = Integer.parseInt(value);
if(count>=5){
throw new RuntimeException("您的使用次数已满5次,若喜欢我们的软件,请到指定网站购买正版.");
}
}
count++;
//将改变后的次数重新存储到集合中。
prop.setProperty("time", count+"");
FileOutputStream fos = new FileOutputStream(confile);
prop.store(fos, "");
fos.close();
fis.close();
}
}
~~~
Properties实际上是容器类HashTable的子类,`而Properties` 类表示的是一个持久化的属性集。
`Properties`可保存在流中或从流中加载。属性列表中每个键及其对应值都是一个字符串。
也就是说,这个特殊的子类基本上都是用于与IO流相关的操作,用于一些常规数据的持久化。
它的使用过程大概就是:
通过load方法用于接收一个输入流对象,也就是`指从输入流中读取属性列表(键和元素对)。`
``当读取结束输入流中的属性列表后,就可以通过getProperty获取对应键的值或通过setProperty方法修改对应键的值。
当你修改完接收的属性列表当中的值之后,就可以通过store方法将此`Properties` 表中的属性列表(键和元素对)写入对应的输出流,以更新属性文件。
注:Java编写的软件的属性文件通常都使用“.properties” 作为文件格式后缀。
### 6、序列流
### 序列流的基本应用
来看一看JDK API说明文档中对于序列输入流`SequenceInputStream`的说明:`
`
`SequenceInputStream` 表示其他输入流的逻辑串联。它从输入流的有序集合开始,并从第一个输入流开始读取,直到到达文件末尾。
接着从第二个输入流读取,依次类推,直到到达包含的最后一个输入流的文件末尾为止。
通俗的说,也就是使用该流对象,可以将多个流对象当中的内容进行有序的合并(序列化)。
举例来说,假设我们现在有三个txt文件:a.txt、b.txt、c.txt。而我们需要将这个三个txt文件中的内容合并到一个txt文件当中。
按照基本的思路来说,我们应当先构造3个输入流对象分别关联3个不同的txt文件,然后依次将它们的内容写入到新的txt文件当中。
而通过序列流的使用,我们就可以直接将这个3个输入流进行序列化,直接写入新的文件。
~~~
public class SequenceInputStreamDemo {
public static void main(String[] args) throws IOException {
String[] fileNames = { "a", "b", "c" };
ArrayList al = new ArrayList();
for (int x = 0; x < 3; x++) {
al.add(new FileInputStream(fileNames[x] + ".txt"));
}
Enumeration en = Collections.enumeration(al);
SequenceInputStream sis = new SequenceInputStream(en);
FileOutputStream fos = new FileOutputStream("d.txt");
byte[] buf = new byte[1024];
int len = 0;
while ((len = sis.read(buf)) != -1) {
fos.write(buf, 0, len);
}
fos.close();
sis.close();
}
}
~~~
对于序列流的使用,需要注意的也就是,其接受的需要进行序列化的流对象的容器类型必须是:Enumeration
### 文件的切割与合并
很多网站在上传文件的时候,因为文件过大会影响传输,所以都会选择对文件做切割。
也就是说将一个大的文件,分割为多个较小的文件。在Java中,就可以通过序列流来实现这样的功能:
~~~
public class FileCuttingUtil {
private static final int SIZE = 1024 * 1024;
/**
* @param args
* @throws Exception
*/
public static void main(String[] args) throws Exception {
File file = new File("F:\\Audio\\KuGou\\All in My Head.mp3");
File parts = new File("E:\\PartFiles");
splitFile(file);
mergeFile(parts);
}
private static void splitFile(File file) throws IOException {
// 用读取流关联源文件。
FileInputStream fis = new FileInputStream(file);
// 定义一个1M的缓冲区。
byte[] buf = new byte[SIZE];
// 创建目的。
FileOutputStream fos = null;
int len = 0;
int count = 1;
/*
* 切割文件时,必须记录住被切割文件的名称,以及切割出来碎片文件的个数。 以方便于合并。
* 这个信息为了进行描述,使用键值对的方式。用到了properties对象
*/
Properties prop = new Properties();
File dir = new File("E:\\partfiles");
if (!dir.exists())
dir.mkdirs();
while ((len = fis.read(buf)) != -1) {
fos = new FileOutputStream(new File(dir, (count++) + ".part"));
fos.write(buf, 0, len);
fos.close();
}
// 将被切割文件的信息保存到prop集合中。
prop.setProperty("partcount", count + "");
prop.setProperty("filename", file.getName());
fos = new FileOutputStream(new File(dir, count + ".properties"));
// 将prop集合中的数据存储到文件中。
prop.store(fos, "save file info");
fos.close();
fis.close();
}
public static void mergeFile(File dir) throws IOException {
/*
* 获取指定目录下的配置文件对象。
*/
File[] files = dir.listFiles(new SuffixFilter(".properties"));
if (files.length != 1)
throw new RuntimeException(dir + ",该目录下没有properties扩展名的文件或者不唯一");
// 记录配置文件对象。
File confile = files[0];
// 获取该文件中的信息================================================。
Properties prop = new Properties();
FileInputStream fis = new FileInputStream(confile);
prop.load(fis);
String filename = prop.getProperty("filename");
int count = Integer.parseInt(prop.getProperty("partcount"));
// 获取该目录下的所有碎片文件。 ==============================================
File[] partFiles = dir.listFiles(new SuffixFilter(".part"));
if (partFiles.length != (count - 1)) {
throw new RuntimeException(" 碎片文件不符合要求,个数不对!应该" + count + "个");
}
// 将碎片文件和流对象关联 并存储到集合中。
ArrayList al = new ArrayList();
for (int x = 0; x < partFiles.length; x++) {
al.add(new FileInputStream(partFiles[x]));
}
// 将多个流合并成一个序列流。
Enumeration en = Collections.enumeration(al);
SequenceInputStream sis = new SequenceInputStream(en);
FileOutputStream fos = new FileOutputStream(new File(dir,filename));
byte[] buf = new byte[1024];
int len = 0;
while ((len = sis.read(buf)) != -1) {
fos.write(buf, 0, len);
}
fos.close();
sis.close();
}
}
class SuffixFilter implements FilenameFilter {
private String suffix;
public SuffixFilter(String suffix) {
super();
this.suffix = suffix;
}
@Override
public boolean accept(File dir, String name) {
return name.endsWith(suffix);
}
}
~~~
### 利用IO流实现对象的持久化
举例来说,我们一个程序中通常都会涉及到很多实体类(bean),大多时候我们都需要对这些实体类对象存放的数据进行数据持久化。
持久化的方式有很多,实际开发中最常用的可能就是类似通过xml文件或者数据库的方式了。
那么,当我们想通过IO流对象,将一个实体对象的数据保存到一个外部文件当中时。应当选择什么样的流对象使用呢?
Java提供的两个类分别是:ObjectInputStream与ObjectOutputStream。
其中ObjectOutputStream流对象用于:将对象序列化后写入。
而ObjectInputStream则用于对那些经ObjectOutputStream序列化的数据进行反序列化。
你可能在最初学习Java时,就知道了一个关键字“transient”,但却很少使用。
该关键字的使用都是关联到对象序列化的,也就是说:一个对象中被transient修饰的字段,不会参与到对象的序列化工作。
该字段是瞬态的,它的值不会被持久化到外部文件当中。
举例来说:你的某个实体类含有“密码”字段,那么你当然不希望如此隐私的信息暴露给外部。就可以通过transient修饰该字段。
注:使用该流对象进行序列化工作的类必须实现Serializable接口。
~~~
package com.tsr.j2seoverstudy.io;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
public class ObjectStreamDemo {
/**
* @param args
* @throws IOException
* @throws ClassNotFoundException
*/
public static void main(String[] args) throws IOException,
ClassNotFoundException {
/*
* 输出结果为:小强:0。
* 可以看到因为age被设为瞬态,所以该属性不会参与对象的序列化工作。
* 当进行对象的反序列化时,则会取到一个该数据类型的默认初始化值
*/
writeObj();
readObj();
}
public static void readObj() throws IOException, ClassNotFoundException {
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(
"obj.object"));
// 对象的反序列化。
Person p = (Person) ois.readObject();
System.out.println(p.getName() + ":" + p.getAge());
ois.close();
}
public static void writeObj() throws IOException, IOException {
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(
"obj.object"));
// 对象序列化。 被序列化的对象必须实现Serializable接口。
oos.writeObject(new Person("小强", 30));
oos.close();
}
}
class Person implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private transient int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
~~~
### 管道流
管道流与其它IO流的最大不同之处在于:管道流是结合多线程使用的。
简单的说,其特点就是:输出管道流与输入管道流存在于不同的线程之间,但它们之间能够互相将管道连接起来。
也就是说,当我们在线程A中定义一个输入管道流,用于接收在线程B当中的输出管道流所书写的数据信息。
~~~
package com.tsr.j2seoverstudy.io;
import java.io.IOException;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
public class PipedStreamDemo {
/**
* @param args
* @throws IOException
*/
public static void main(String[] args) throws IOException {
PipedInputStream input = new PipedInputStream();
PipedOutputStream output = new PipedOutputStream();
input.connect(output);
new Thread(new Input(input)).start();
new Thread(new Output(output)).start();
}
}
class Input implements Runnable {
private PipedInputStream in;
Input(PipedInputStream in) {
this.in = in;
}
public void run() {
try {
byte[] buf = new byte[1024];
int len = in.read(buf);
String s = new String(buf, 0, len);
System.out.println("s=" + s);
in.close();
} catch (Exception e) {
// TODO: handle exception
}
}
}
class Output implements Runnable {
private PipedOutputStream out;
Output(PipedOutputStream out) {
this.out = out;
}
public void run() {
try {
Thread.sleep(5000);
out.write("我是线程B当中的输出管道流,给你输出信息了,线程A当中的兄弟!".getBytes());
} catch (Exception e) {
// TODO: handle exception
}
}
}
~~~
### DataStream-操作基本数据类型的流
简单的说,该流对象最大的特点就是对基本的输入输出流进行了一定的“装饰”。
专门针对于Java当中的基本数据类型的操作方法进行了封装,所以当操作的数据是基本数据类型时
就应当选择该中流对象使用,因为效率更高。
~~~
public class DataStreamDemo {
private static void write() {
try {
DataOutputStream dos = new DataOutputStream(new FileOutputStream(
"dataStream.txt"));
dos.writeByte(0);
dos.writeChar(0);
dos.writeInt(0);
dos.writeLong(0);
dos.writeDouble(0);
dos.writeFloat(0);
dos.writeUTF("hello");
dos.close();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
private static void read() {
try {
DataInputStream dis = new DataInputStream(new FileInputStream(
"dataStream.txt"));
dis.readByte();
dis.readChar();
dis.readInt();
dis.readLong();
dis.readFloat();
dis.readDouble();
dis.readUTF();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
~~~
### 操作内存数组的流
就是指:ByteArrayInputStream;ByteArrayOutputStream;CharArrayReader;CharArrayWriter;
与其它的输入输出流不同的是:该种流对象用于,当数据源和数据目的都位于内存当中的情况。
并且,这种类型的流对象调用close方法后,此类当中的方法还是可以被调用到。
~~~
public class ByteArrayStreamDemo {
/**
* @param args
* @throws IOException
*/
public static void main(String[] args) {
ByteArrayInputStream bis = new ByteArrayInputStream("abcedf".getBytes());
ByteArrayOutputStream bos = new ByteArrayOutputStream();
int ch = 0;
while((ch=bis.read())!=-1){
bos.write(ch);
}
System.out.println(bos.toString());
}
}
~~~
以上就是对Java IO体系当中常用的流对象和一些提供特殊功能的对象的常用操作做了一个总结。
我们之前已经说过了,无论IO体系下存在这么多不同的具体子类实现,其根本无非就是根据数据传输的不同特性将流抽象封装成不同的类。
所以,对于IO的掌握,重点还是了解不同的流对象的功能特点,从而根据实际需求选择最合适的流对象。从而使代码变得更简单,使读写效率更高。
';
牛刀小试 – Java泛型程序设计
最后更新于:2022-04-01 20:08:52
# 序言
一般的类和方法,只能使用具体的类型:要么是基本数据类型,要么是自定义的类。
如果要编写可以应用于多种类型的代码,这种刻板的限制对代码的束缚就会很大。
—— 《Think in Java》
# 泛型程序设计带来的好处是什么
不难想象,“可以应用于多种类型的代码”这种需求当然是最初就存在的,因为代码的复用一直是编码的一个重点。
然而值得注意的是:泛型技术是是在Java 1.5版本之后才出现的。那么在此之前,对于上述的需求,Java是怎么样来满足的呢?
其实答案不难想象,当然是通过继承的多态特性来实现的:超类类型声明的对象引用能够指向任何其子类对象。
而同时被众所周知的还有:Java当中定义的任何类,都有一个默认的共同的超类:Object类。
那么自然的,如果想要一段代码能够接收任一类型的数据,那么该数据的类型自然就应当被定义为“Object”类型。
Java集合框架当中的容器类就基于泛型技术而实现,所以它们能够保证你所定义的容器对象能够接受任何指定类型的数据进行存储。
而你已经知道集合框架于JDK 1.2之后就诞生了。那么在Java 1.2 - 1.5版本期间,它们是如何保证“能够存储任一对象类型”这一特征的呢?
答案并不让人惊讶,当然同样是基于继承来实现的。正如当时的ArrayList容器类,内部只是维护一个Object类型引用的可变数组。
由此已经不难想象:正如Java中,原始数组已经可以完成对同一数据类型的多个数据进行存储的工作。而在之后的升级中,却衍生了集合框架的道理一样。
既然通过继承的多态特性,能够完成同一段代码应用于多种类型的目的。Java还是在升级中制定出了泛型技术,那么自然是因为原本的设计方式存在缺陷。
我们可以这样考虑:假设想要编写一个方法,方法能够接受操作任一对象类型的参数。那么,在Java 1.5之前,其代码自然是这样的:
~~~
public class Demo {
void anyObject(Object o){
//some code..
}
}
~~~
我们不难看出使用这样的方式,可能会造成的困扰。
首先,因为Java中的“动态绑定机制”。所以通过这样的方式传递参数,在方法中只能调用到Object类自身的方法。
所以如果你想在方法内,调用你传入的特定对象类型其自身额外的方法时,就必须涉及到强制类型转换 - 完成“向下转型”。
从而导致了蝴蝶效应:因为一旦涉及到类型的强制转换,就可能会导致类型转换异常:"ClassCastException"。
而泛型程序设计技术的出现,正是为了弥补上述实现方式的缺陷。所以说泛型的最大好处正是:
- 减少了代码的复杂性:避免了在代码中使用强制类型转换的麻烦。
- 增强了代码的安全性:将运行时异常“ClassCastException”转到了编译时检测异常。
# 泛型的实际使用
泛型的使用规范,并不复杂,可以简单归纳为:
- Java中用符号"<>"用以声明使用泛型。括号内用以包含一个或多个泛型参数,多个参数之间用逗号隔开。
- 通常推荐使用简练的名字来作为泛型参数的命名。最好避免小写字母,这能很好的用以其和其他普通形式参数的区分。
- 如果一个泛型类里还包含有泛型方法,那么最好避免对方法的泛型类型参数与类的泛型参数使用同样的标示符,避免混淆。
### 泛型类的定义
一个泛型类就是具有一个或多个类型变量的类。其定义格式通常为:class ClassName 。举例来说:
~~~
package com.tsr.j2seoverstudy.generic;
public class GenericClassDemo {
private T t;
GenericClassDemo(T t) {
this.t = t;
}
public T getT() {
return t;
}
public void setT(T t) {
this.t = t;
}
/*
* 输出结果为:
* generic_class
* new value
*/
public static void main(String[] args) {
String genericVar = new String("generic_class");
GenericClassDemo g = new GenericClassDemo(genericVar);
System.out.println(g.getT());
g.setT("new value");
System.out.println(g.getT());
}
}
~~~
对于泛型类的使用,可以看到其特点就是:
在泛型类的内部,可以使用类声明当中的类型参数,作为该类的成员变量的数据类型。
正如我们在上面的代码中所定义的:“private T t;”。
而该类型参数,可以在该类进行构造工作时,被具体指定为具体的任意对象数据类型。
上面的例子中,我们为其制定的具体类型,是字符串类型“String”。这个过程就是“泛型的实例化”。
同时,泛型类当中定义的方法,可以接受和操作泛型类上声明的类型参数。
但同时有一点特别需要注意的是:泛型的实例化工作是在对泛型类进行构造,生成对象的时候完成的。
这也解释了为什么类中的静态方法如果要使用泛型,泛型就必须被定义在该方法上,而不允许被定义在类上的原因。
正是因为一个类上声明的泛型,本身就是依赖于该类的对象来明确的,但静态方法却不依赖于对象。
于是,接下来就让我们来看一看泛型方法的定义和使用。
### 泛型方法的定义
泛型方法的定义格式为:public 返回类型 方法名(T t)
关于泛型方法的定义,需要注意的就是:方法上的泛型应该放在方法修饰符和方法返回类型之间。
~~~
package com.tsr.j2seoverstudy.generic;
public class GenericMethodDemo {
/*
* 输出结果为:
* 位于数组中间的数是:5
*/
public static void main(String[] args) {
Integer[] nums = { 1, 3, 5, 7, 9 };
System.out.println("位于数组中间的数是:" + getMiddle(nums));
}
static T getMiddle(T[] t) {
return t[t.length / 2];
}
}
~~~
Java中的泛型载体有:泛型方法,泛型类,泛型接口。也就是说泛型除类与方法之外,还可以应用在接口上。
不过对于泛型接口的使用,在了解了泛型在类和方法的使用方式之后,你也应该差不多了解了。
### 泛型变量范围的限定
有的时候,根据需求需要对类型变量加以约束。这时就涉及到了泛型变量的范围限定的使用。
我们都知道实现compareable下的compareTo方法,可以用于对两个对象的比较和排序。
那么假设我们定义了如下的泛型方法,想要举出一个数组中compareTo方法比较下最大的对象结果:
~~~
package com.tsr.j2seoverstudy.generic;
public class GenericMethodDemo {
/*
* 输出结果为:
* 最大的字符串元素是:zsda
*/
public static void main(String[] args) {
String [] strs = {"asdas","xzvzh","zsda","qwe"};
System.out.println("最大的字符串元素是:"+getMaxElement(strs));
}
static > T getMaxElement(T[] t) {
if (t == null || t.length == 0)
return null;
T max = t[0];
for (int i = 1; i < t.length; i++) {
if (t[i].compareTo(max) > 0) {
max = t[i];
}
}
return max;
}
}
~~~
我们知道:最基本的泛型参数,可以用于接受任一对象数据类型。
但在上面的例子中,我们希望通过compareTo方法用于对象的比较。
什么样的对象才具备compareTo方法,就是实现了Comparable接口的类的对象。
所以这个时候我们还必须对声明的泛型做一个约束:指定的数据类型必须实现了Comparable接口。
这就是所谓的:泛型参数的范围限定。
我们在上面的代码中:“>”就是一种用于限定泛型范围的使用方式。
你可能注意到在这里我们使用了Java中原本用于表明类的继承关系的关键字extends,用于指定泛型必须实现Comparable接口。
没错,在泛型当中,用于表示声明的类型参数继承自一个类或者实现自一个接口,都用extends表示。
这种限定的方式也常常被称为:泛型的上限。其表现形式也就是:<泛型类型参数 extends 上限>。
注:如果声明的泛型类型实现了多个接口,则其上限中的多个接口以符号”&“隔开。
如果声明的类型参数继承自某个类,并同时实现了多个接口,那么其继承的类必须被声明在上限中的第一个。
既然有泛型的上限,就不难想象肯定存在与之对应的:泛型的下限。其限定方式为:<泛型类型参数 super 下限>。
下限的限定代表,该泛型参数除开至少可以被声明的下限类型实例化之外,还可以被该下限类型的所有超类类型所实例化。
举例来说,有自定义的三个类:动物类、老虎类、东北虎类。它们之间的继承关系是:东北虎继承自老虎。老虎继承自动物。
如果使用""代表的含义就是:泛型参数T即可以被实例化为老虎,还可以被实例化为动物,但不能被实例化为东北虎。
# 泛型擦除
所谓的泛型擦除是指:当程序度过编译器,进入到运行期后,在JVM中会将泛型去掉。这个过程就被称为泛型的擦除。
之所以要进行泛型的擦除工作,实际上是因为:泛型实际上仅仅只是被Java编译器所支持的的一项技术。而虚拟机是并不认识泛型的。
所以当一个Java程序使用到泛型技术的时候,首先会在编译期经过编译器的检查:确定是否存在类型转换的问题。
而当编译通过,程序转向运行期之后。在负责Java程序运行的虚拟机中,则会将泛型类型擦除。
所谓的泛型擦除就是指:将定义的泛型类型还原为其对应的原始类型(raw type)。这个还原的过程是根据泛型的限定类型确定的:
- 如果没有声明限定类型的类型参数则将被还原为:Object。
以上面我们说泛型类的定义时,所用到的例子来说,其还原形式就如同:
~~~
//泛型擦除前
public class GenericClassDemo {
private T t;
GenericClassDemo(T t) {
this.t = t;
}
}
//泛型擦除后
public class GenericClassDemo {
private Object o;
GenericClassDemo(Object o) {
this.o = o;
}
}
~~~
- 如果泛型本身存在限定,则原始类型用限定的第一个类型变量代替。
例如说:
~~~
//泛型擦除之前
public class GerericDemo &Serializable>{
private T t;
GerericDemo(T t){
this.t = t;
}
}
//泛型擦除之后
public class GerericDemo implements Serializable{
private Comparable t;
GerericDemo(Comparable t){
this.t = t;
}
}
~~~
所以实际上可以看到:所谓的泛型技术在使用时,最终还是会涉及到类型的强制转换。但不同的是:使用泛型后的强制转换是安全的!
之所以这么说,是因为我们在编译时期已经确保了数据类型的一致性。如果你使用了指定泛型类型之外的数据类型,程序是不能编译通过的。
那么既然话至于此,也正好通过一个例子,来看一看我们上面所谈到的:“泛型将运行时异常“ClassCastException”转到了编译时期到底是指什么?
以容器类ArrayList为例,如果不通过泛型技术,那么假设使用以下这样的方式:
~~~
package com.tsr.j2seoverstudy.generic;
import java.util.ArrayList;
public class GerericDemo{
public static void main(String[] args) {
ArrayList list = new ArrayList();
list.add(new String());
list.add(new Integer(5));
for (int i = 0; i < list.size(); i++) {
String value = (String)list.get(0);
}
}
}
~~~
上面的代码编译不会出现任何问题,但一旦运行就会报告运行时异常:java.lang.Integer cannot be cast to java.lang.String
这正是因为,如果没有泛型的限定。ArrayList自身接受任何继承自Object类的对象类型数据。
所以因为继承的特性,我们要将定义的两个分别为String与Integer类型的对象存放进该list容器是没有问题的,因为它们自身都会完成一次“向上转型”。
但也正是因为当完成“向上转型”的工作后,实际上存放进list容器之后,它们的类型已经是Object类型了。
所以当我们再想要以特定类型将其取出时,就必须进行“向下转型”,也就是强制类型转换工作。
但Integer类型自身是不能够强制转换为Strring类型的,从而也就导致了类型转换异常的出现。
反之,当容器类加入泛型技术之后,代码则变为了:
~~~
public class GerericDemo{
public static void main(String[] args) {
ArrayList list = new ArrayList();
list.add(new String());
list.add(new Integer(5));//因为泛型的出现,编译出错!
for (int i = 0; i < list.size(); i++) {
//不再需要强转
String value = list.get(i);
}
}
}
~~~
因为泛型类在构造时,必须为类型参数指定具体的类型,也就是完成泛型参数的实例化工作。
所以当我们将类型指定为之后,再想要向容器内加入Integer类型的对象就会编译出错。
而同时因为编译器已经知道该容器内存放的数据类型时String,所以在取出数据时,也就不再需要进行强转了。
# 泛型通配符
Java当中的泛型通配符的符号是“?”。顾名思义,也就是指任何对象数据类型都可以通通匹配。
其主要的使用方式我们可以通过一个例子来看一下,假设我们定义了如下几个类:
~~~
package com.tsr.j2seoverstudy.generic;
public class Animal {
private T t;
Animal(T t) {
this.t = t;
}
public T getT() {
return t;
}
}
class Tiger {
@Override
public String toString() {
return "老虎";
}
}
class Bird {
@Override
public String toString() {
return "鸟";
}
}
~~~
假设我们想要定义一个工具类,用于输出动物信息,如果没有通配符的时候,实际上用起来是很不爽的:
~~~
package com.tsr.j2seoverstudy.generic;
public class GerericDemo {
public static void main(String[] args) {
Animal tiger = new Animal(new Tiger());
Animal bird = new Animal(new Bird());
printTiger(tiger);
printBird(bird);
}
static void printTiger(Animal animal){
System.out.println(animal.getT());
}
static void printBird(Animal animal){
System.out.println(animal.getT());
}
}
~~~
因为我们已经说过了,泛型类在构造时必须指定类型参数的具体类型。所以你只能通过这种笨重的方式来实现需求。
但通过通配符,编码的工作就变得轻松多了:
~~~
public class GerericDemo {
public static void main(String[] args) {
Animal tiger = new Animal(new Tiger());
Animal bird = new Animal(new Bird());
printAnimal(tiger);
printAnimal(bird);
}
static void printAnimal(Animal> animal){
System.out.println(animal.getT());
}
}
~~~
另外,泛型的通配符同样可以用于泛型上限,下限的限定。
最后,顺带一提吧。注意下面一种错误的使用方式:
~~~
static void printAnimal(Animal animal){
System.out.println(animal.getT());
}
static void printAnimal(Animal animal){
System.out.println(animal.getT());
}
~~~
一定要知道这样的书写方式是会导致编译失败的,而不要认为这是通过参数类型的不同对方法实现了重载。
泛型擦除!!!一定不要忘了这个工作。上面的代码经泛型擦除之后,实际就变成了两个一模一样的方法声明,自然是不能编译通过的。
# 小结
到此,关于Java中泛型程序设计的应用,基本上已经是做了一个比较全面和详细的总结了。
如果要继续深入,可能就要自己再通过一些书籍和资料去研究泛型的原理以及java虚拟机方面的知识了。
个人感觉关于泛型擦除方面的底层原理还是十分复杂的,要深入掌握还是需要花一定精力的。
';
牛刀小试 – 浅析Java集合框架的使用
最后更新于:2022-04-01 20:08:49
# 基本概述
Java中的集合框架与数组类似,都是用于存储多个同一类型数据的容器。
但是对于数组的使用,会因为数组本身的特性会导致一些使用限制,例如:
数组要求在构造时,就必须确定数组的长度。所以如果想要存放的数据个数不确定,数组就无法使用。
于是促使了集合框架的诞生,与数组相比,集合框架最大特点在于:
- 集合框架下的容器类只能存放对象类型数据;而数组支持对基本类型数据的存放。
- 任何集合框架下的容器类,其长度都是可变的。所以不必担心其长度的指定问题。
- 集合框架下的不同容器了底层采用了不同的数据结构实现,而因不同的数据结构也有自身各自的特性与优劣。
# 常用容器类关系图
网上找的一张图,基本描述了Java集合框架中最常用部分的构成关系:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-08-17_57b4319e1e401.jpg)
# 集合框架的分类
1、Collection体系:以单值的形式存储对象数据的容器体系。
1.1、List容器:允许重复元素的存储。(最常用的为ArrayList以及LinkedList;另外在JDK1.2集合框架出现之前,该类型使用的容器为Vector)
- ArrayList :线程不同步、底层的数据结构是数组结构、元素存储顺序有序、访问元素速度相对较快,存取或删除元素等操作速度较慢
- LinkedList :线程不同步、底层的数据结构是链表结构、元素存储顺序有序、访问元素速度相对较慢,存取或删除元素等操作速度快
- Vector :线程同步、 底层的数据结构是数组结构、元素存储顺序有序、访问,存取,删除元素的操作都很慢。
1.2、Set容器:不允许重复元素的存储,该容器中存储的对象都是唯一的。(最常用的为HashSet与TreeSet)
- HashSet :线程不同步、底层的数据结构为哈希表结构、无序
- TreeSet :线程不同步、底层的数据结构为二叉树结构、会按自然排序方式或指定排序方式对元素进行排序
2、Map体系:以“键值对”的形式存储数据的容器体系。即是将键映射到值的对象;一个映射不能包含重复的键;每个键最多只能映射到一个值。
- HashMap :线程不同步、底层数据结构是哈希表结构、无序、允许NUll作为键或值
- Hashtable :线程同步 、底层数据结构是哈希表结构、无序、不允许NUll作为键或值
- TreeMap :线程不同步、底层数据结构是二叉树结构、可以按照自然或指定排序方式对映射中的键进行排序
面试中常常会问到HashMap和HashTable的不同,由此就可以做出回答:
1、HashTable与Vector一样,在JDK1.2之前就存在,是基于陈旧的Dictionary类实现的。
HashMap是JDK1.2之后,经集合框架出现之后,基于Map接口实现的。
2、HashTable是线程同步的,而HashMap默认并没有实现线程同步。
也就是说,如果存在多线程并发操作同一个HashMap容器对象时,需要自己加上同步。
3、HashTable中存放的数据允许将null作为键或值,而HashMap则不支持。
# List接口的常用操作
List接口是Collection的子接口,这就意味着:除开Collection接口提供的方法列表之外,它又根据自身特性新增了一些额外的方法。
所谓数据存放的容器,所涉及的操作自然离不开“增删改查”。那就不妨来看一下关于List体系下,常用的操作方法。
### List接口的常用方法列表
1.添加操作
- void add(index,element); //在列表的指定位置插入元素
- void add(index,collection); //将指定collection中的所有元素都插入到列表中的指定位置
2.删除操作
- Object remove(index): //移除列表中指定位置的元素
3.修改操作
- Object set(index,element); //修改(替换)列表指定位置的元素的值
4.获取操作
- Object get(index); //获取列表指定位置的元素的值
- int indexOf(object); //获取对象在列表中的位置,如果不包含该对象,则返回-1
- int lastIndexOf(object); //获取对象在列表中最后一次出现的位置,如果不包含该对象,则返回-1
- List subList(from,to); //从指定起始位置from(包括)到指定结束位置to(不包括)截取列表
可以看到与其父接口Collection提供的方法列表相比,List接口新增的方法有一个显著的特性:**可以操作角标**。
### **ArrayList的基本应用**
~~~
package com.tsr.j2seoverstudy.collection;
import java.util.ArrayList;
import java.util.List;
public class ArrayListTest {
public static void main(String[] args) {
//ArrayList容器对象的创建
ArrayList arrayList = new ArrayList();
List listTemp = new ArrayList();
//添加数据的操作
//添加单个元素
arrayList.add(1);
arrayList.add(2);
listTemp.add(3);
listTemp.add(4);
//一次性添加另一个容器内的所有元素
arrayList.addAll(listTemp);
//在指定位置添加元素
arrayList.add(0, 5);
//打印一次数据
printList(arrayList);
System.out.println();
//删除数据
System.out.println("删除的元素为:"+arrayList.remove(2));
System.out.println("删除元素值为5的元素是否成功:"+arrayList.remove(new Integer(5)));
printList(arrayList);
System.out.println();
//修改数据
System.out.println("修改下标为1的元素值为6,修改前的值是:"+arrayList.set(1, 6));
printList(arrayList);
System.out.println();
//获取元素总个数
System.out.println("size:"+arrayList.size());
//元素截取
listTemp = arrayList.subList(0, 2);
System.out.print("listTemp:");
printList(listTemp);
}
static void printList(List list){
for (Integer integer : list) {
System.out.print(integer + "\t");
}
}
}
/*
输出结果为:
5 1 2 3 4
删除的元素为:2
删除元素值为5的元素是否成功:true
1 3 4
修改下标为1的元素值为6,修改前的值是:3
1 6 4
size:3
listTemp:1 6
*/
~~~
### LinkedList的基本使用
与ArrayList不同,LinkedList内部采用链表结构所实现,它每一个节点(Node)都包含两方面的内容:
节点本身的数据(data)以及下一个节点的信息(nextNode)。
之所以LinkedList对于数据的增加,删除动作的速度更快,正是源于其内部的链表结构。
由于ArrayList内部采用数组结构,所以一旦你在某个位置添加或删除一个数据,之后的所有元素都不得不跟着进行一次位移。
而对于LinkedList则只需要更改nextNode的相关信息就可以实现了,这是LinkedList的优势。
而也正是基于链表结构的特性,LinkedList还新增了一些自身特有的方法。
其中最常用的是:addFirst(E e),addLast(E e),getFirst(),getLast(),removeFirst(),removeLast()等方法。
对于LinkedList中元素的基本操作方法,与ArrayList并无多大区别。
所以这里主要看一下对于LinkedList的链表特性和新增方法的使用方式。
根据这些特性,我们可以通过封装LinkedList来实现我们自己的容器类。
并且,可以完成队列,或是栈的不同存储方式的实现。
而在面试中,也可能遇到类似问题:请分别自定义实现一个采用队列和栈存储方式的容器类。
那么,队列意味着数据先进先出,而栈则意味着数据先进后出的特性,正好可以通过LinkedList来实现:
~~~
package com.tsr.j2seoverstudy.collection;
import java.util.LinkedList;
/*
* 输出结果为:
* 队列: s1 s2 s3 s4
* 栈: s4 s3 s2 s1
*/
public class QueueAndStack {
public static void main(String[] args) {
MyQueue q = new MyQueue();
q.add("s1");
q.add("s2");
q.add("s3");
q.add("s4");
System.out.print("队列:"+"\t");
while (!q.isEmpty()) {
System.out.print(q.get()+"\t");
}
System.out.println();
System.out.print("栈:"+"\t");
MyStack s = new MyStack();
s.push("s1");
s.push("s2");
s.push("s3");
s.push("s4");
while (!s.isEmpty()) {
System.out.print(s.pop()+"\t");
}
}
}
//我的队列
class MyQueue {
private LinkedList linklist;
MyQueue() {
this.linklist = new LinkedList();
}
public void add(Object obj) {
linklist.addFirst(obj);
}
public Object get() {
return linklist.removeLast();
}
public boolean isEmpty() {
return linklist.isEmpty();
}
}
//我的栈
class MyStack {
private LinkedList linklist;
MyStack() {
this.linklist = new LinkedList();
}
public void push(Object obj) {
linklist.addFirst(obj);
}
public Object pop() {
return linklist.removeFirst();
}
public boolean isEmpty() {
return linklist.isEmpty();
}
}
~~~
简而言之,如果不涉及到底层实现的“数据结构”的具体研究,而只是针对于容器自身的使用。
那么了解了不同容器之间的特点就应放在首位。因为了解了不同容器之间的差别,基本上也就对集合框架有一个不错的掌握了。
正如ArrayList和LinkedList,它们对于操作数据提供的方法使用都是没有太大区别的。因为它们都是基于List接口而实现的。
而针对于它们的使用,则是根据实际需求,来选择更适合的容器类。例如说:可以考虑你的数据是否需要频繁的增删操作?
如果需要,则选择LinkedList。否则就可以选择ArrayList,因为其访问元素的速度更快。
# Set接口的常用操作
与同样隶属于Colleciton体系下的List接口不同的是,实现Set接口的容器类有着保证存放元素唯一性的特性。
Set接口的实现子类当中,最为常用的容器类分别是HashSet和TreeSet。
### HashSet的基本使用
顾名思义,HashSet内部采用的是哈希表结构。而该容器也正是基于此特性来保证元素唯一的。
HashSet容器会通过存放的元素的类型中的hashCode方法和equals方法判断元素是否相同。
首先经hashCode方法判断,如果通过hashCode()方法返回的哈希值不同,则证明不是重复元素,直接存储到哈希表中。
而如果判断得到的元素的hashCode值相同,则接着调用对象的equal方法判断元素是否重复。
来看一个例子:
~~~
package com.tsr.j2seoverstudy.collection;
import java.util.HashSet;
import java.util.Set;
/*
* 输出结果为:
* name:张三..age:19
* name:张三..age:19
* name:李四..age:20
*/
public class HashSetDemo {
public static void main(String[] args) {
HashSet set = new HashSet();
set.add(new Student("张三", 19));
set.add(new Student("李四", 20));
set.add(new Student("张三", 19));
printSet(set);
}
static void printSet(Set set) {
for (Student student : set) {
System.out.println(student);
}
}
}
class Student {
private String name;
private int age;
Student(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return new StringBuilder().append("name:").append(name)
.append("..age:").append(age).toString();
}
}
~~~
从程序的输出结果中注意到Set中存放了两个姓名同为“张三”,年龄同为19岁的学生。
这可能与我们最初的设想偏离甚远,不是说Set容器类保证元素唯一性吗?为何出现重复。
这正是因为自定义的Student中并没有对hashCode于equals方法进行覆写。
所以HashSet容器在通过equals方法判断元素是否重复的时候,采用的原本Object当中“obj1==obj2”的方式。
==用于比较对象的时候,是比较内存中的地址,所以自然得到的结果是两个对象不是相同的。
所以说,在使用HashSet容器存储自定义对象的时候。请一定记得按照自己的需求,重新覆写hashCode与equals方法。
修改后的Student类如下:
~~~
class Student {
private String name;
private int age;
Student(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return new StringBuilder().append("name:").append(name)
.append("..age:").append(age).toString();
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + age;
result = prime * result + ((name == null) ? 0 : name.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
Student other = (Student) obj;
if (age != other.age)
return false;
if (name == null) {
if (other.name != null)
return false;
} else if (!name.equals(other.name))
return false;
return true;
}
}
~~~
### **TreeSet的基本使用**
与HashSet一样,从命名我们就可以猜到:TreeSet内部的结构采用的是二叉树结构。
所谓的二叉树数据结构是指:每个结点最多有两个子树的有序树,子树有左右之分,次序不能颠倒。
TreeSet容器通过底层的二叉树保证数据唯一性的原理基本可以分析为:
当向TreeSet容器内添加进第一个元素,二叉树就在最顶端有了一个结点。
当插入第二个元素时,TreeSet就会用这第二个元素与该结点的元素进行比较。
假定该次插入的元素比上一个元素的结点小,就会被放在顶端节点的左子树上。而如果较大,则放在右子树上。
以此类推,当所有的元素完成存储,所有左边子树上的元素值都较小,右边则较大。
而比较结果为相同的元素则无法进入到该数据结构中。从而保证了数据的唯一性。
那么,同样以上面的需求为例,我们这次利用TreeSet写一个测试类:
~~~
package com.tsr.j2seoverstudy.collection;
import java.util.Set;
import java.util.TreeSet;
public class TreeSetDemo {
public static void main(String[] args) {
TreeSet set = new TreeSet();
set.add(new Student("张三", 19));
set.add(new Student("李四", 20));
set.add(new Student("张三", 19));
printSet(set);
}
static void printSet(Set set) {
for (Student student : set) {
System.out.println(student);
}
}
}
~~~
当我们再次运行程序,得到的信息是这样的:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-08-17_57b4319e4299c.jpg)
没错,通过该异常打印信息我们能够读到的是:自定义的Student类不能转换为Comparable。
也就是说我们定义的该类不具备比较性。我们已经说过了,二叉树结果会针对元素进行比较。
根据结果决定其是否进入该结构,或者应当排在左边子树还是右边子树。那么,自然就应该有一个比较的方式。
而TreeSet容器对元素进行比较的方式有两种:
- 让存储的元素所属类实现Comparable接口,并覆写compareTo()方法。
- 新建比较器类,实现Comparatorj接口,并覆写compare()方法。将比较器对象作为TreeSet的构造器的参数传递给TreeSet对象
假设我们选用第一种方式对Student类进行修改:
~~~
package com.tsr.j2seoverstudy.collection;
import java.util.TreeSet;
import java.util.Set;
/*
* 输出结果为:
* name:张三..age:19
* name:张三..age:19
* name:李四..age:20
*/
public class HashSetDemo {
public static void main(String[] args) {
TreeSet set = new TreeSet();
set.add(new Student("张三", 19));
set.add(new Student("李四", 20));
set.add(new Student("张三", 19));
printSet(set);
}
static void printSet(Set set) {
for (Student student : set) {
System.out.println(student);
}
}
}
class Student implements Comparable{
private String name;
private int age;
Student(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return new StringBuilder().append("name:").append(name)
.append("..age:").append(age).toString();
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + age;
result = prime * result + ((name == null) ? 0 : name.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
Student other = (Student) obj;
if (age != other.age)
return false;
if (name == null) {
if (other.name != null)
return false;
} else if (!name.equals(other.name))
return false;
return true;
}
@Override
public int compareTo(Student s) {
int temp = this.age - s.age;
return temp == 0 ? this.name.compareTo(s.name) : temp;
}
}
~~~
我们实现的compareTo方法的含义在于:先通过年龄进行比较,如果两个对象的年龄相同,则再通过姓名进行比较。
注:compareTo方法返回的是一个int型的值,该值代表的含义是:负数代表小于,0代表对象相等,正数代表大于。
而此时运行main方法得到的输出结果为:
name:张三..age:19
name:李四..age:20
可以发现相对于HashSet来说,TreeSet除了保持了元素的唯一性之外。
还可以按照我们定义的比较方法,来保证元素的顺序。
例如上面例子中实现的compareTo方法正是:让元素按照年龄从小到大的顺序排序;
如果年龄相同,则按照姓名字母从小到大的顺序排序。
# Map接口的常用操作
我们已经说过,Map体系与Collection体系最大的不同之处就在于:不再采用单值,而是采用键值对的方式存储元素。
也就是说,除开存储对象本身之外,还给每个对象关联了一张“身份证”,这张“身份证”就是所谓的“键”。
那么,对应的其对于元素的操作方法,自然也就有了不同。Map接口常用的方法为:
**添加:**
- V put(K key,V value); //将指定的值与此映射中的指定键关联,如果此映射以前包含一个该键的映射关系,则用该指定值覆盖旧值。返回前一个和key关联的值,如果没有返则回null.
- void putAll(Map extends K,? extend V> m)//从指定映射中将所有映射关系复制到此映射中.
**删除:**
- V remove(Object key); //如果存在一个键的映射关系,则将其从此映射中移除
- void clear(); //从此映射中移除所有映射关系
**判断:**
- boolean containsKey(Object key); //判断是否存在对应的键的映射关系。如果包含,则返回 true
- boolean containsValue(Object value); //判断是否该map内是否有一个或多个键映射到该指定值,如果有,则返回true.
- boolean isEmpty(); //如果此映射未包含任何键-值映射关系,则返回 true
**获取:**
- V get(Obejct key);//获取该指定键所映射的值,如果此映射不包含该键的映射关系,则返回`null`
- Set keySet(); //获取该map内包含的所有键的set视图
- Set> entrySet(); //获取该map内包含的所有映射关系的set视图
- Collection values(); //获取该map内包含的所有值的collection视图。
- int size(); //获取该map内所包含的映射关系的总数,如果该映射包含的元素大于 Integer.MAX_VALUE,则返回 Integer.MAX_VALUE
可以通过一个例子,来简单的看一下对于Map容器的常用操作:
~~~
public class MapDemo {
public static void main(String[] args) {
Map map = new HashMap();
//存储
map.put("张三", 19);
map.put("李四", 18);
System.out.println(map);
//获取
System.out.println("size:"+map.size());
Set nameSet = map.keySet();
for (Iterator it = nameSet.iterator(); it.hasNext(); ) {
System.out.println(map.get(it.next()));
}
Set> set = map.entrySet();
for (Iterator> it = set.iterator(); it.hasNext(); ) {
Entry entry = it.next();
String name = entry.getKey();
Integer age = entry.getValue();
System.out.println("name:"+name+"..age:"+age);
}
Collection values = map.values();
for (Iterator it = values.iterator(); it.hasNext(); ) {
System.out.println(it.next());
}
//删除
System.out.println("..."+map.remove("张三"));
}
}
~~~
关于Map容器的使用,可能值得注意的就是:
1.取出数据时,是根据键获取该键映射的值。
2.Map容器本身无法进行迭代,但可以:
通过keySet()方法获取该容器内所有键的Set视图进行迭代;
通过entrySet()方法获取该容器内所有映射关系的Set视图进行迭代;
通过values()方法获取该容器内所有值的Collection视图进行迭代;
3.通过命名,就可以猜想到。与HashSet与TreeSet的使用相同。
在通过该HashMap与TreeMap的时候,请不要忘记覆写hashcode、equals方法和添加实现比较器接口.
# 总结
所谓的集合框架,究其根本其实都是对于对象数据的一个存储容器。
之所分离出如此之多的不同容器,原因也是因为根据实际需求提供不同特性的容器。
而之所各个容器类之间具备不同的特性,也正是因为它们底层采用了不同的数据结构实现。
正如前面谈到过的一样,如果你暂时不想或者没有经理去更深一步的了解不同数据结构的具体实现。
那么针对于集合框架中不同容器类的使用,应该掌握的重点就是,它们各自不同的特性。
因为只有掌握了其各自的优劣点,就可以根据实际的需求选择最合适的容器来使用。
前面对于最常用的各种容器的特点都做了一个说明,而对于容器类的选择技巧,其实简单归纳就是:
1、思考你是只需要保存单个的值,还是希望以键值对(字典)的形式保存数据。
如果存储单值,则选用Collection;如果存储字典,则选用Map。
2、选用Collection体系时,思考你存储的数据是否需要保证唯一性?
如果需要,则选用Set。如果不需要,则选用List。
2.1、如果选用的是List,则考虑元素是否涉及到频繁的增删操作。
如果是,则选用LinkedList;如果不是,则选用ArrayList。
2.2、如果选用的是Set,则考虑是否需要指定存储顺序。
如果是,则选用TreeSet;如果不是,则选用HashSet。
另外一种情况,如果不需要自定义顺序,而希望让元素按存储的先后顺序排序,则可以直接选用LinkedHashSet。
3、当选用Map体系时,与Set的选择一样。
如果需要按指定存储顺序,则选用TreeMap。如果不需要则选用HashMap。
如果想让元素按照存储的先后顺序进行排列,则选用LinkedHashMap。
';
牛刀小试 – 详解Java多线程
最后更新于:2022-04-01 20:08:47
# **线程与多线程的概念**
关于线程与多线程的较详细的理解可以参考:[线程的解释](http://baike.baidu.com/link?url=EUXhNs__U5gPEKvPq82eddOisibw8RaLiiNXy7VXxeRFE5Xq1MVgXdMunLvjJ4hsgFHyWpC0nlDo1B9KAm76Da) 和[多线程的解释](http://baike.baidu.com/link?url=NWpAVlbQwePoTOsMzJsWOfk4Fz_-67HKGxTekmW2S1vSv7pGsqx70kWXfzJaPIlA)。
而我们要做的是,对其进行“精炼"。我们每天都在和电脑、手机打交道,每天都在使用各种各样的应用软件。
打开上电脑的任务管理器,就可以看到有一项名为"进程"的栏目,点击到里面可能就会发现一系列熟悉的名称:QQ,360等等。
所以首先知道了,QQ、360之类的应用软件在计算机上被称为一个进程。
而一个应用程序都会有自己的功能,用以执行这些进程当中的个别功能的程序执行流就是所谓的线程。
所以,线程有时候也被称为轻量级进程,是程序执行流当中的最小单元。
线程的划分尺度小于进程,其不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。
进程在执行过程中拥有独立的内存单元,而多个线程共享内存,所以能极大地提高了程序的运行效率。
所以简而言之的概括的话,就是:一个程序至少有一个进程,一个进程至少有一个线程。
以360杀毒来说,里面的一项功能任务“电脑体检”就是该应用程序进程中的一个线程任务。
而除此任务之外,我们还可以同时进行多项操作。例如:“木马查杀”、“电脑清理”等。
那么,以上同时进行的多项任务就是所谓的存活在360应用程序进程中的多线程并发。
# 多线程的利与弊
多线程的有利之处,显而易见。在传统的程序设计语言中,同一时刻只能执行单任务操作,效率非常低。
假设在某个任务执行的过程中发生堵塞,那么下一个任务就只能一直等待,直至该任务执行完成后,才能接着执行。
而得益于多线程能够实现的并发操作,即使执行过程中某个线程因某种原因发生阻塞,也不会影响到其它线程的执行。
也就是说,多线程并发技术带来的最大好处就是:很大程度上提高了程序的运行效率。
似乎百里而无一害的多线程并发技术,还有弊端吗?从某种程度上来说,也是存在的:会导致任务执行效率的降低。
之所以这样讲,是因为所谓的“并发”并不是真正意义上的并发,而是CPU在多个线程之间做着快速切换的操作。
但CPU的运算速度肯定是远远高于人类的思维速度,所以就带来了一种“并发”的错觉。
那就不难想象了:假设某一进程中,线程A与线程B并发执行,CPU要做的工作就是:
不断快速且随机的在两个线程之间做着切换,分别处理对应线程上的线程任务,直到两个线程上的任务都被处理完成。
那么,也就可以考虑这样的情况:CPU执行完原本线程A的线程任务只需要5秒;但如今因为另一个线程B的并发加入。
CPU则不得不分出一部分时间切换到线程B上进行运算处理。于是可能CPU完成该线程任务A的时间反而延长到了7秒。
所以所谓的效率降低,就是指针对于某单个任务的执行效率而言的。
也就是说,如果在多线程并发操作时,如果有某个线程的任务你认为优先级很高。那么则可以:
通过设置线程优先级或者通过代码控制等手段,来保证该线程享有足够的“特权”。
注:Java中设置线程优先级,实际上也只是设置的优先级越大,该线程被CPU随机访问到的概率会相对高一些。
这个过程可以替换成一些实际生活中的情形来进行思考。快过年了,以家庭团聚为例。
假设你除了准备炒一桌子美味的菜肴之外,过年自然还要有一顿热腾腾的饺子。那么:
传统单任务的操作过程可以被理解为:先把准备的菜肴都做好;菜都端上桌后便开始煮饺子。
这样做的坏处就是:如果在炒菜的中途发生一些意外情况,那么随着炒菜动作的暂停。煮饺子的动作也将被无限期延后。
而对应于多线程并发的操作就是:一边炒菜,一边煮饺子。这时你就是CPU,你要做的动作可能是这样的:
炒菜的中途你能会抽空去看看锅里的饺子煮好没有;发现没有煮好,又回来继续炒菜。炒好一道菜后,再去看看饺子能出锅了没。
由此你发现,你做的工作与CPU处理多线程并发的工作是一样的:不断的在“煮饺子”与“炒菜”两个任务之间做着切换。
# 线程的周期及状态
Java中线程的整个生命周期基本可以划分为如下4种状态:
- **new**- 创建状态:顾明思议,Java通过new创建了一个线程对象过后,该线程就处于该状态。
- **runnable**- 可执行状态:也就是指在线程对象调用start()方法后进入的状态。但需要注意的是该状态是“可执行状态”而不是“执行状态”。也就是说,当一个线程对象调用start方法后,只是意味着它获取到了CPU的执行资格,并不代表马上就会被运行(CPU此时当然可能恰好切换在其它线程上做处理),只有具备了CPU当前执行权的线程才会被执行。
- **non Runnable**- 不可执行/阻塞状态:也就是通过一些方法的控制,使该线程暂时释放掉了CPU的执行资格的状态。但此时该线程仍然是存在于内存中的。
- **done**-退出状态:简单的说也就是当线程进入到退出状态,就意味着它消亡了,不存在了。Java里通过stop方法可以强制线程退出,但该方法因为可能引起死锁,所以是不建议使用的。另外一种进入该状态的方式,是线程的自然消亡,也就当一个线程的任务被执行完毕之后,就会自然的进入到退出状态。
以下是Java中一些用于改变线程状态的方法列表:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-08-17_57b4319d6a74b.jpg)
# Java中创建线程的方式
Java里面创建的线程的方式主要分为:
- 继承Thread类,并覆写run方法。
~~~
public class Demo extends Thread{
@Override
public void run() {
//...
}
}
~~~
- 实现Runnable接口,并定义run方法:
~~~
public class Demo implements Runnable{
@Override
public void run() {
//...
}
}
~~~
- 还有一种情况,如果你认为没有将线程单独封装出来的时候,可以通过匿名内部类来实现。
开发中通常选择通过实现Runnbale接口的方式创建线程,好处在于:
1.Java中不支持多继承,所以使用Runnable接口可以避免此问题。
2.实现Runnable接口的创建方式,等于是将线程要执行的任务单独分离了出来,更符合OO要求的封装性。
# 多线程的安全隐患
春运将至了,还是先通过一个老话题来看一个多线程并发的例子,来看看多线程可能存在的安全隐患。
~~~
package com.tsr.j2seoverstudy.thread;
public class TicketDemo {
public static void main(String[] args) {
Runnable sale = new TicketOffice();
Thread t1 = new Thread(sale, "1号售票窗口");
Thread t2 = new Thread(sale, "2号售票窗口");
t1.start();
t2.start();
}
}
class TicketOffice implements Runnable {
// 某车次的车票存量
private int ticket_num = 10;
@Override
public void run() {
while (true) {
if (ticket_num > 0) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
}
String output = Thread.currentThread().getName() + "售出了"
+ ticket_num-- + "号票";
System.out.println(output);
} else {
break;
}
}
}
}
/*
可能出现如下的输出结果:
2号售票窗口售出了10号票
1号售票窗口售出了9号票
1号售票窗口售出了8号票
2号售票窗口售出了7号票
1号售票窗口售出了6号票
2号售票窗口售出了5号票
1号售票窗口售出了4号票
2号售票窗口售出了3号票
1号售票窗口售出了2号票
2号售票窗口售出了1号票
1号售票窗口售出了0号票
*/
~~~
按我们的理想的想法是:两个售票处共同完成某车次列车的10张车票:座位号为1号到10号的车票的售票工作。
而根据程序的输出结果,我们发现的安全隐患是:有座位号为0号的车票被售出了,买到这张车票的顾客该找谁说理去呢?
我们来分析一下为什么会出现这样的错误情况,其形成的原因可能是这样的:
当线程1执行完“1号售票窗口售出了2号票”之后,根据while循环的规则,再一次开始售票工作。
首先判断while为true,进入到while循环体;接着判断if语句,此时余票数为1张(也就是只剩下座位号为1的车票了)。
1大于0,满足判断条件,进入到if语句块当中。此时执行到"Thread.sleep(10)"语句。
OK,当前线程进入到堵塞状态,暂时失去了Cpu的执行资格。于是Cpu重新切换,开始执行线程2。
于是线程2开始执行线程任务,又是老样子:while判断 - if判断,由于上次线程1判断后还没执行售票工作,就被阻塞了。
所以这次if判断仍然为"1>0",满足判断条件,继续执行,又执行到线程休眠语句,于是线程2也进入阻塞状态。
此时两个线程暂时都不具备执行资格,但我们指定线程休眠的时间为10毫秒,于是10毫秒后,可能两个线程都苏醒了,恢复了Cpu的执行资格。
面对两个都处于可执行状态的线程,Cpu又只好随机选择一个先执行了。于是Cpu选择了线程2,线程2恢复执行。
线程2开始做自己上次没做完的事,于是执行表达式和输出语句,于是得到输出信息"2号售票窗口售出了1号票"。
线程2继续执行while判断,没问题。再执行if判断"0>0",不满足判断条件,于是执行到了break语句。
线程2到此退出循环,完成了所有线程任务,于是自然消亡进入done状态。
于是现在Cpu的执行权自然就属于线程1了,线程1也如同线程2一样,从美梦中醒来,开始上次没做完的事。
问题就在这里出现了,虽然这个时候,堆内存中存放的对象成员变量“ticket_num”的值实际上已经是0了。
但是!因为上一次线程1已经经过了if判断进入到了if语句块之内。所以它将直接开始执行表达式,并输出。
就形成了我们看到的错误信息:“1号售票窗口售出了0号票”。并且这个时候实际上余票数的值已经是“-1”了。
所以,实际上之所以我们在处理卖票的代码之前加上让线程休眠10毫秒的代码,目的也就是为了模拟线程安全隐患的问题。
而根据这个例子我们能够得到的信息就是:之所以多线程并发存在着安全隐患,正是CPU的实际处理方式是在不同线程之间做着随机的快速切换。
这意味着它并不会保证当处理一个线程的任务时,一定会执行完该次线程的所有代码才做切换。而是可能做到一半就切换了。
所以,我们可以归纳线程安全隐患之所以会出现的原因就是因为:
- 多个并发线程操作同一个共享数据。
- 操作该共享数据的代码不止一行,存在多行。
# 解决线程安全隐患的方法 - 同步锁
既然已经了解了线程安全隐患之所以产生,就是因为线程在操作共享数据的途中,其它线程被参与了进来。
那么我们想要解决这一类的安全隐患,自然就是保证在某个线程在执行线程任务的时候,不能让其余线程来捣乱。
在样的做法,在Java当中被称为同步锁,也就是说给封装在同步当中的代码加上一把锁。
每次只能由一个线程能够获取到这把锁,只有当前持有锁的线程才能执行同步当中的代码,其它线程将被拒之门外。
Java中对于同步的使用方式通常分为两种,即:同步代码块和同步函数。关键字synchronized用以声明同步。其格式分别为:
~~~
//同步代码块
synchronized (对象锁) {
//同步代码
}
//同步函数
synchronized void method(){
//同步代码
}
~~~
通过同步我们就可以解决上面所说的“春节卖票”问题的安全隐患:
~~~
package com.tsr.j2seoverstudy.thread;
public class TicketDemo {
public static void main(String[] args) {
Runnable sale = new TicketOffice();
Thread t1 = new Thread(sale, "1号售票窗口");
Thread t2 = new Thread(sale, "2号售票窗口");
t1.start();
t2.start();
}
}
class TicketOffice implements Runnable {
// 某车次的车票存量
private int ticket_num = 10;
Object objLock = new Object();
@Override
public void run() {
while (true) {
synchronized (objLock) {
if (ticket_num > 0) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
}
String output = Thread.currentThread().getName() + "售出了"
+ ticket_num-- + "号票";
System.out.println(output);
} else {
break;
}
}
}
}
}
~~~
再次运行该代码,就不会再出现之前的安全隐患。
这正是因为我们通过同步代码块,将希望每次只有有一个线程执行的代码封装了起来,为它们加上了一把同步锁(对象)。
同步最需要注意的地方,就是要保证锁的一致性。这是因为我们说过了:
同步的原理就是锁,每次当有线程想要访问同步当中的代码的时候,只有获取到该锁才能执行。
所以如果锁不能保证是同一把的话,自然也就实现不了所谓的同步了。
可以试着将定义在TicketOffice的成员变量objLock移动定义到run方法当中,就会发现线程安全问题又出现了。
这正是因为,将对象类型变量objLock定义为成员变量,它会随着该类的对象存储在堆内存当中,该变量在内存中独此一份。
而移动到run方法内,则会存储在栈内存当中,而每一个线程都会在栈内存中,单独开辟一条方法栈。
这样就等于每个线程都有一把独自的锁,自然也就不是所谓的同步了。
而同步函数的原理实际上与同步代码块是相同的,不同的只是将原本包含在同步代码块当中的代码单独封装到一个函数中:
~~~
private synchronized void saleTicket() {
while (true) {
if (ticket_num > 0) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
}
String output = Thread.currentThread().getName() + "售出了"
+ ticket_num-- + "号票";
System.out.println(output);
} else {
break;
}
}
}
~~~
而另外一点值得说明的是,就是关于不同方式使用的锁的差别:
同步代码块:可以使用任一对象锁。
同步函数:使用this作为锁。
静态同步函数:使用该函数所在类的字节码文件对象作为锁。
# 死锁现象
提到同步,就不得不提到与之相关的一个概念:死锁。
死锁是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。
此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。同理,线程也会出现死锁现象。
~~~
package com.tsr.j2seoverstudy.thread;
public class DeadLockDemo {
public static void main(String[] args) {
Queue q1 = new Queue(true);
Queue q2 = new Queue(false);
Thread t1 = new Thread(q1, "线程1");
Thread t2 = new Thread(q2, "线程2");
t1.start();
t2.start();
}
}
class MyLocks {
public static final Object LOCK_A = new Object();
public static final Object LOCK_B = new Object();
}
class Queue implements Runnable {
boolean flag;
Queue(boolean flag) {
this.flag = flag;
}
@Override
public void run() {
String threadName = Thread.currentThread().getName();
while (true) {
if (flag) {
synchronized (MyLocks.LOCK_A) {
System.out.println(threadName + "获取了锁A");
synchronized (MyLocks.LOCK_B) {
System.out.println(threadName + "获取了锁B");
}
}
} else {
synchronized (MyLocks.LOCK_B) {
System.out.println(threadName + "获取了锁B");
synchronized (MyLocks.LOCK_A) {
System.out.println(threadName + "获取了锁A");
}
}
}
}
}
}
~~~
上面的程序就演示了一个死锁的现象:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-08-17_57b4319da6882.jpg)
线程1开启执行后,判断标记为true,于是先获取了锁A,并输出信息。
此时CPU做切换,线程2开启执行,判断标记为false,首先获取锁B,并输出相关信息。
但这时候无论CPU再怎么样切换,程序都已经无法继续推进了。
因为线程1想要继续推进必须获取的资源锁B现在被线程2持有,反之线程2需要的锁A被线程1持有。
这正是因为两个线程因为互相争夺资源而造成的死锁现象。
死锁还是很蛋疼的,一旦出现,程序的调试和查错修改工作都会变得很麻烦
# 线程通信 - 生产者与消费者的例子
关于多线程编程,类似于车站卖票的例子是一种常见的使用途径。
这种应用途径通常为:多个线程操作共享数据,并且执行的是同一个动作(线程任务)。
车站售票:多个线程都是操作同一组车票,并且都是执行同一个动作:出售车票。
那么在多线程当中的另一个经典例子:生产者与消费者,就描述的是另一种常见的应用途径。
多个线程操作共享数据,但是不同的线程之间执行的是不同的动作(线程任务),这就是线程通信的使用。
不同线程间的通信应当怎么样来完成,其手段是通过Object类当中提供的几个相关方法:
- wait():在其他线程调用此对象的`notify()`方法或notifyAll()方法前,导致当前线程等待。
- notify():唤醒在此对象监视器上等待的单个(任一一个)线程。
- notifyAll():唤醒在此对象监视器上等待的所有线程。
首先,我们可能会思考的一点就是:既然是针对于线程之间相互通信的方法,为什么没有被定义在线程类,反而被定义在了Object类当中。
因为这些方法事实上我们可以视作是线程监视器的方法,监视器其实就是锁。
我们知道同步中的锁,可以是任意的对象,那么既然是任一对象调用的方法,自然一定被定义在Object类中。
可以将所有使用同一个同步的线程视作被存储在同一个线程池当中,而该同步的锁就是该线程池的监视器。
由该监视器来调度对应线程池内的各个线程,从而达到线程通信的目的。
接下来就来看生产者与消费者的例子:
1.生产者生产商品;
2.消费者购买商品。
3.可能会同时存在多个生产者与多个消费者。
4.多个生产者中某个生产者生产一件商品,就暂停生产,并在多个消费者中通知一个消费者进行消费;
消费者消费掉商品后,停止消费,再通知任一一个生产者进行新的生产工作。
~~~
package com.tsr.j2seoverstudy.thread;
public class ThreadCommunication {
public static void main(String[] args) {
Queue q = new Queue();
Customer c = new Customer(q);
Producer p = new Producer(q);
Thread t1 = new Thread(c, "消费者1-");
Thread t2 = new Thread(c, "消费者2-");
Thread t3 = new Thread(p, "生产者1-");
Thread t4 = new Thread(p, "生产者2-");
t1.start();
t2.start();
t3.start();
t4.start();
}
}
class Queue {
//当前商品数量是否为0
private boolean isEmpty = true;
//生产
public synchronized void put() {
String threadName = Thread.currentThread().getName();
//如果生产者线程进入,而现在还有剩余商品
while (!isEmpty) {
try {
wait();//则该生产者暂时等待,不进行生产
} catch (InterruptedException e) {
}
}
//否则则生产一件商品
isEmpty = false;
System.out.println(threadName + "生产了一件商品");
//唤醒阻塞的线程,通知消费者消费
this.notifyAll();
}
//消费
public synchronized void take() {
String threadName = Thread.currentThread().getName();
//消费者前来消费,如果此时没有剩余商品
while (isEmpty) {
try {
wait();//则让消费者先行等待
} catch (InterruptedException e) {
}
}
//否则则消费掉商品
isEmpty = true;
System.out.println(threadName + "消费了一件商品");
//通知生产者没有商品了,起来继续生产
this.notifyAll();
}
}
class Customer implements Runnable {
Queue q;
Customer(Queue q) {
this.q = q;
}
@Override
public void run() {
for (int i = 0; i < 5; i++) {
q.take();
}
}
}
class Producer implements Runnable {
Queue q;
Producer(Queue q) {
this.q = q;
}
@Override
public void run() {
for (int i = 0; i < 5; i++) {
q.put();
}
}
}
~~~
这就是对线程通信一个简单的应用。而需要记住的是:关于线程的停止与唤醒都必须定义在同步中。
因为我们说过了,关于所谓的线程通信工作。实际上是通过监视器对象(也就是锁),来完成对线程的停止或唤醒的操作的。
既然使用的是锁,那么自然必须被定义在同步中。并且,必须确保互相通信的线程使用的是同一个锁。
这是十分重要的,试想一下,如果试图用线程池A的监视器锁A去唤醒另一个线程池B内的某一个线程,这自然是办不到的。
简单解释下,你可能已经注意到在上面的例子中,我是直接采用"wait()"和"notifyAll()"的方式来唤醒和阻塞线程的。
那么你应该明白这其实对应于隐式的"this.wait()"与"this.notifyAll()",而同时我们已经说过了:
在同步方法中,使用的锁正是this。也就是说,在线程通信中,你可以将同步锁this看做是一个线程池的对象监视器。
当某个线程执行到this.wait(),就代表它在该线程池内阻塞了。而通过this.notify()则可以唤醒阻塞在这个线程池上的线程。
而到了这里,另一值得一提的一点就是:
Thread类的sleep()方法和Object类的wait()方法都可以使当前线程挂起,而它们的不同之处在于:
1:sleep方法必须线程挂起的时间,超过指定时间,线程将自动从挂起中恢复。而wait方法可以指定时间,也可以不指定。
2:线程调用sleep方法会释放Cpu的执行资格(也就是进入到non Runnable状态),但不会释放锁;
而通过调用wait方法,线程即会释放cpu的执行资格,同时也会释放掉锁。
# 线程通信的安全隐患
与之前说过的卖票用例一样,对于线程通信的通信也应当小心谨慎,否则也可能会引发相关的错误。常见的问题例如:
一、使用notify而不是notifyAll唤醒线程可能会出现的问题
我在最初接触多线程的时候,容易这样考虑,既然想要达到的目的是:
生产者线程生产一件商品,则唤醒一个消费者线程。消费者进行消费,则唤醒一个生产者线程。
既然notify()方法用于唤醒单个线程,而notifyAll()用于唤醒所有线程,那使用notifyAll不是浪费效率吗?
后来明白,很可惜的是,我们要做的是唤醒单个对方线程。而notify没有这么强大。
它只是随机的唤醒一个处于阻塞状态下的线程,所以如果使用notify(),可能会看到如下的错误情况:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-08-17_57b4319dc1d07.jpg)
没错,操蛋,又出现了坑爹的死锁。为什么出现这样的情况呢?我们来分析一下:
- 我们创建的4个线程经调用start方法之后,都进入了可执行状态,具备CPU执行资格。
- CPU随机切换,首先赋予“生产者1”执行权,生产者1开始执行。
- 生产者1判断isEmpty为true,执行一次生产任务。当执行notify方法时,当前还没有任何可以唤醒的阻塞线程。
- 生产者1继续while循环,判断isEmpty为flase。执行wait,于是生产者1进入阻塞状态。
执行到此,当前处于可执行状态的线程为:生产者2、消费者1、消费者2
- CPU在剩下的3个可执行状态中随机切换到了生产者2,于是生产者2开始执行。
- 生产者2判断isEmpty为false,执行wait方法,于是生产者2也进入到临时阻塞状态。
于是,当前处于可执行状态的线程变为了:消费者1、消费者2
- CPU继续随机切换,此次切换到消费者1开始执行。
- 消费者1判断isEmpty为false,于是执行一次消费,修改isEmpty为true。
- 执行到notify()方法,唤醒任一阻塞状态的线程,于是唤醒了生产者2。
- 消费者1继续while循环,判断isEmpty为true,于是执行wait,进入阻塞。
到此,当前处于可执行状态的线程变为了:生产者2、消费者2
- 同样的,CPU这次切换到消费者2执行。
- 消费者2判断isEmpty为true,于是执行wait,进入阻塞。
好了,处于可执行状态的线程只剩下:生产者2。
- 那么,自然现在只能是轮到生产者2执行了。
- 判断isEmpty为true,执行一次生产。修改isEmpty为false。
- 通过notify()方法随机唤醒了生产者1线程。
- 再次执行while循环,判断isEmpty为false后,进入阻塞。
至此,唯一处于可执行状态的线程变为了:生产者1
- 生产者1线程开始执行。
- 判断isEmpty为false,执行wait进入阻塞。
这下好了,4个线程都进入了阻塞状态,而不是消亡状态。自然的,死锁了。
二、使用if而不是使用while判断isEmpty可能出现的问题
如果使用if而不是while对isEmpty进行判断,可能会出现的错误为:
1、不同的生产者连续生产了多件商品,但消费者只消费掉其中一件。
2、一个生产者生产了一件商品之后,有多个消费者进行连续消费。
出现这样的安全问题是因为if的判断机制造成的:通过if来判断标记,只会执行一次判断。
所以可能会导致不该运行的线程运行了,从而出现数据错误的情况。
这种问题的出现也就是与我们上面说的“售票处售出0号票”的错误类似。
# JDK1.5之后的新特性
我们前面已经说到了,关于生产者与消费者的问题中。
我们的目的是,每当一个线程执行完毕一次任务后,只唤醒单一的对方线程。
而在JDK1.5之前,为了避免死锁的发生,我们不得不使用notifyAll()来唤醒线程。
而这样做有一个缺点就在于:每次都要唤醒所有处于阻塞的线程,自然就会导致效率降低。
在JDK1.5之后,,Java提供了新的工具用于解决此类问题,就是:Lock和Condition接口。
简答的说,就是对将原本的同步锁synchronized与对象监视器进行了封装,分别对应于于Lock及Condition。
并且,重要的是相对于1.5之前,新的工具拥有更灵活及更广泛的操作。
一、Lock的使用及注意事项
1、通过Lock lock = new ReentrantLock();获取一个Lock对象。
2、通过成员方法lock(),用于对代码进行同步管理。
3、通过成员方法unlock(),用于同步代码执行完毕后,释放锁对象。
4、由于不管在同步代码的执行过程中是否出现异常,最后都必须释放该锁,否则可能会导致死锁现象的产生。所以通常在使用lock时,都会遵循如下格式:
lock.lock();
try{
{
// 同步代码....
}finally{
lock.unlock();
}
}
二、对象监视器Condition的使用及注意事项
1、可以通过Lock对象使用成员方法newCondition()来获取一个新的监视器对象。
2、Condition分别使用await();signal();signalAll()来替代原本Object类当中的wait();notify();及notifyAll()方法。
3、同一个Lock对象可以拥有多个不同的Condition对象。
请注意一个很关键的特性:同一个Lock对象可以拥有多个不同的Condition对象!
也就是说:通过此特性,我们可以获取多个Condition对象,将操作不同线程任务的线程分别存放在不同的Condition对象当中。
例如在前面所说的生产者消费者例子当中,我们就可以生成两组监视器,一组监视生产者线程,一组监视消费者线程。
从而达到我们想要的每次只唤醒对方线程而不唤醒本方线程的目的,修改后的例子代码如下:
~~~
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ThreadCommunication {
public static void main(String[] args) {
Queue q = new Queue();
Customer c = new Customer(q);
Producer p = new Producer(q);
Thread t1 = new Thread(c,"消费者1-");
Thread t2 = new Thread(c,"消费者2-");
Thread t3 = new Thread(p,"生产者1-");
Thread t4 = new Thread(p,"生产者2-");
t1.start();
t2.start();
t3.start();
t4.start();
}
}
class Queue {
private int goodsTotal;
private boolean isEmpty = true;
final Lock lock = new ReentrantLock();
final Condition notFull = lock.newCondition();
final Condition notEmpty = lock.newCondition();
public void put() {
String threadName = Thread.currentThread().getName();
lock.lock();
try{
while (!isEmpty) {
try {
notFull.await();
} catch (InterruptedException e) {
}
}
goodsTotal ++;
System.out.println(threadName + "生产了一件商品");
isEmpty = false;
notEmpty.signal();
}finally{
lock.unlock();
}
}
public synchronized void take() {
String threadName = Thread.currentThread().getName();
lock.lock();
try{
while (isEmpty) {
try {
notEmpty.await();
} catch (InterruptedException e) {
}
}
goodsTotal --;
System.out.println(threadName + "消费了一件商品");
isEmpty = true;
notFull.signal();
}finally{
lock.unlock();
}
}
}
class Customer implements Runnable {
Queue q;
Customer(Queue q) {
this.q = q;
}
@Override
public void run() {
while (true) {
q.take();
}
}
}
class Producer implements Runnable {
Queue q;
Producer(Queue q) {
this.q = q;
}
@Override
public void run() {
while (true) {
q.put();
}
}
}
~~~
# 线程的常用方法
最后,看一下一些关于线程的常用方法。
一、线程的中断工作
1、通常使用自然中断的做法,也就是当某个线程的线程任务执行结束之后,该线程就会自然终结。
2、通过标记控制。如果线程任务中存在循环(通常都有),那么,可以在循环中使用标记,通过标记来控制线程的中断。
二`、interrupt()方法:中断线程`
`我们知道sleep及wait等方法都可以使线程进入阻塞状态。所以可能你在程序通过使用标记的方式来控制线程的中断,但由于过程中线程陷入了`冻结(挂起/阻塞)状态,这时通过标记将无法正常的控制线程中断。这时,就可以通过interrupt方法来中断线程的冻结状态,强制恢复到运行状态中来,让线程具备cpu的执行资格。但是因为此方法具有强制性,所以会引发InterruptedException,所以要记得处理异常。
三、setDaemon()方法:将该线程标记为守护线程或用户线程。
所谓守护线程,可以理解为后台线程。对应的,我们在程序中开辟的线程都可以视为前台线程,在Java中,当所有的前台线程都执行结束之后,后台线程也将随之结束。
例如:你在某个程序中开辟两个线程,一个用于接收输入,一个用于控制输出。因为只有当有输入存在时,才会存在输出。这时就可以通过setDaemon将输出线程设置为守护线程。这样当输入线程中断结束时,输出线程就会随之自动中断,而不必再人为控制中断。
四、控制线程优先级
所谓控制线程优先级,是指我们可以通过设置线程的优先级来控制线程被CPU运行到的几率,线程的优先级越高,被CPU运行的概率越大。
通过setPriority()与getPriority()方法可以分别设置和获取某个线程的优先级。Java中线程的优先级取值范围为:1-10
Thread类中使用MAX_PRIORITY(10),NORM_PRIORITY(5),MIN_PRIORITY(1)三个常量代表最常用的线程优先级值。
五、join()方法
线程使用join方法,意味着该线程申请加入执行,所以通常如果要临时加入一个线程,可以使用join()方法。并且,当执行到join方法之后,其余线程将等待使用该方法的线程执行完线程任务之后,再继续执行。
六、yiled()方法
暂停正在执行的线程对象,并执行其他线程。
';
牛刀小试 – 趣谈Java中的异常处理
最后更新于:2022-04-01 20:08:45
# **概述**
顾名思义,通俗来讲异常就是指,那些发生在我们原本考虑和设定的计划之外的意外情况。
生活中总是会存在各种突发情况,如果没有做好准备,就让人措手不及。
你和朋友约好了明天一起去登山,半道上忽然乌云蔽日,下起了磅礴大雨。这就是所谓的异常情况。
你一下子傻眼了,然后看见朋友淡定的从背包里掏出一件雨衣穿上,淫笑着看着你。这就是对异常的处理。
对于一个OO程序猿来讲,所做的工作就是:将需要处理的现实生活中的复杂问题,抽象出来编写成为程序。
既然现实生活中总是存在着各种突然的异常情况,那么对应其抽象出的代码,自然也是存在这样的风险的。
所以常常说:要编写一个完善的程序并不只是简简单单的把功能实现,还要让程序具备处理在运行中可能出现的各种意外情况的能力。
这就是所谓的异常的使用。
# **体系**
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-08-17_57b4319d24f47.jpg)
这就是Java当中异常体系的结构构成,从图中我们可以提取到的信息就是:
1、Java中定义的所有异常类都是内置类Throwable的子类。
2、Java中的异常通常被分为两大类:Error和Exception:
- 那么顾名思义,Error代表错误,Exception代表异常;
- Error用以指明与运行环境相关的错误,JVM无法从此类错误中恢复,此类异常无需我们处理;
- Exception代表着可以被我们所处理的异常情况,我们需要掌握和使用的,正是该类型。
3、Exception最常见的两种异常类型分别是:
- IOException:主要是用以处理操作数据流时可能会出现的各种异常情况。
- RuntimeException:指发生在程序运行时期的异常,如数组越界,入参不满足规范等情况引起的程序异常。
# **工欲善其事,必先利其器**
要理解Java中的异常使用,首先要明白几个关于异常处理的工具 - 异常处理关键字的使用。
1、throw:用以在方法内部抛出指定类型的异常。
~~~
void test(){
if(发生异常的条件){
thorw new Exception("抛出异常");
}
}
~~~
2、throws:用以声明 一个方法可能发生的异常(通常都是编译时检测异常)有哪些。
~~~
void test() throws 异常1,异常2{
//这个方法可能发生异常1,异常2
throw new 异常1();
throw new 异常2();
}
~~~
3、try - catch:另一种处理异常的方式,与throws不同的是,这种方式是指 "捕获并处理"的方式。
用try语句块包含可能发生异常的代码,catch用于捕获发生的异常,并在catch语句块中定义对捕获到的异常的处理方式。
~~~
try {
//可能发生异常的代码
} catch (要捕获的异常类型 e) {
//对捕获到的异常的处理方式
}
~~~
4、finally语句块:通常都是跟在try或try-catch之后进行使用,与其名字代表的一样。也就是定义最终的操作。
特点是被try语句块包含的内容中,是否真的发生了异常。程序最终都将执行finally语句块当中的内容。
通常用于对资源的释放操作,例如:通过JDBC连接数据库等情况。
~~~
try {
//获取资源
} catch (要捕获的异常类型 e) {
//对捕获到的异常的处理方式
}finally{
//释放资源
}
~~~
# **趣解异常的实际使用**
了解Java中异常类的实际应用之前,应当先了解两个概念,用以对最常用的异常做一个分类:
1、编译时被检测异常:
只要是Exception和其子类都是,除了特殊子类RuntimeException体系。
所谓的编译时被检测异常也就是指在程序的编译器就会进行检测的异常分类。
也就是说,如果一个方法抛出了一个编译时检测异常,Java则要求我们必须进行处理。
既:通过throws对异常进行声明处理 或是 通过try-catch对异常进行捕获处理。
如果程序编译时检测到该类异常没有被进行任何处理,那么编译器则会报出一个编译错误。
~~~
public class Test{
public static void main(String[] args) {
try {
Class clazz = Class.forName("Java");
System.out.println(clazz.getName());
} catch (ClassNotFoundException e) {
System.out.println("没有找到该类");
}
}
}
~~~
上面代码中的ClassNotFoundException就是一种编译时检测异常,这个异常是由Class类当中的forName方法所抛出并声明的。
如果我们在使用该方法时没有对异常进行处理:声明或捕获,那么该程序就会编译失败。
通过这个例子想要说明的是:编译时被检测异常通常都是指那些“可以被我们预见”的异常情况。
正例如:我们通过Class.forName是想要获取指定类的字节码文件对象,所以我们自然也可以预见可能会存在:
与我们传入的类名参数所对应的类字节码文件对象不存在,查找不到的情况。
既然这种意外情况是可以被预见的,那自然就应该针对其制定一些应对方案。
2、编译时不检测异常(运行时异常):
就是指Exception下的子类RuntimeException和其子类。
通常这种问题的发生,会导致程序功能无法继续、运算无法进行等情况发生;
但这类异常更多是因为调用者的原因或者引发了内部状态的改变而导致的。
所以针对于这种异常,编译器不要求我们处理,可以直接编译通过。
而在运行时,让调用者调用时的程序强制停止,从而让调用者对自身的代码进行修正。
曾经看到过一道面试题:列出你实际开发中最常见的五个运行时异常,就我自己而言,如果硬要说出五个,那可能是:
NullPointerException(空指针异常)、IndexOutOfBoundsException(角标越界异常)、ArithmeticException(异常运算条件异常)
ClassCastException(类型转换异常)、IllegalArgumentException(非法参数异常)
~~~
public class Test{
public static void main(String[] args) {
division(5, 0);
}
static int division(int a ,int b){
return a/b;
}
}
/*
Exception in thread "main" java.lang.ArithmeticException: / by zero
at com.tsr.j2seoverstudy.base.Test.division(Test.java:31)
at com.tsr.j2seoverstudy.base.Test.main(Test.java:28)
*/
~~~
上面的例子就报出了运行时异常:ArithmeticException。因为我们将非法的被除数0作为参数传递给了除法运算的函数内。
同时也可以看到,虽然“division”方法可能引发异常,但因为是运行时异常,所以即使不做任何异常处理,程序任然能够通过编译。
但当该类型的异常真的发生的时候,调用者运行的程序就会直接停止运行,并输出相关的异常信息。
通过自定义异常理解检测异常和非检测异常
前面我们说到的都是Java自身已经封装好提供给我们的一些异常类。由此我们可以看到,秉承于“万物皆对象”的思想,Java中的异常实际上也是一种对象。
所以自然的,除了Java本身提供的异常类之外,我们也可以根据自己的需求定义自己的异常类。
这里我想通过比较有趣的简单的自定义异常,结合自己的理解,总结一下Java当中检测异常和非检测异常的使用。
**1、编译时检测异常**
对于编译时异常,我的理解就是:所有你**可以预见**、**并且能够做出应对**的**意外状况**,都应该通过**编译时检测异常**的定义的方式进行处理。
举个例子来说:假定我们开了一家小餐馆,除开正常营业的流程之外。自然可能发生一些意外状况,例如:
菜里不小心出现了虫子,出现了头发;或者是餐馆突然停电之类的状况。这些状况是每个经营餐馆的人事先都应该考虑到的情况。
既然我们已经考虑到了这些意外情况发生的可能性,那么自然就应该针对于这些状况做出应对的方案。所以代码可能是这样的:
1、首先,定义两个编译时检测异常类,菜品异常和停电异常:
~~~
package com.tsr.j2seoverstudy.exception_demo;
/*
* 菜品异常
*/
public class DishesException extends Exception{
public DishesException() {
super("菜品有问题..");
}
}
package com.tsr.j2seoverstudy.exception_demo;
/*
* 停电异常
*/
public class PowerCutException extends Exception{
PowerCutException(){
super("停电异常..");
}
}
~~~
2、然后在餐厅类当中,对异常作出处理:
~~~
package com.tsr.j2seoverstudy.exception_demo;
public class MyRestaurant {
private static String sicuation;
static void doBusiness() throws DishesException, PowerCutException{
if(sicuation.equals("菜里有虫") ||sicuation.equals("菜里有头发")){
throw new DishesException();
}
else if(sicuation.equals("停电")){
throw new PowerCutException();
}
}
public static void main(String[] args) {
try {
doBusiness();
} catch (DishesException e) {
//换一盘菜或退款
} catch (PowerCutException e) {
//启动自备发电机
}
}
}
~~~
1、我们已经说过了菜品出现问题和停电之类的意外情况都是我们可以预见的,所以我们首先定义了两个编译时检测异常类用以代表这两种意外情况。
2、然后我们在餐厅类当中的营业方法当中做出了声明,如果出现“菜里有虫”或“菜里有头发的问题”,我们就用thorw抛出一个菜品异常;如果“停电”,就抛出停电异常。
3、但是,由于我们抛出这一类异常是因为想告知餐厅的相关人员,在餐厅营业后,可能会出现这些意外情况。所以还应当通过throws告诉他们:营业可能会出现这些意外情况。
4、餐厅相关人员接到了声明。于是制定了方案,当餐厅开始营业后。如果出现了菜品异常,请为客人换一盘菜或退款;如果出现停电异常,请启动店里自备的发电机。
**2、运行时异常**
对于运行时异常的使用,我个人觉得最常用的情况有两种:
第一、编译时检测异常用于定义那些我们可以提供“友好的解决方案”的情况。那么针对于另外一些状况,可能是我们无法很好的进行解决的。
遇到这种情况,我们可能希望采取一些“强制手段”,那就是直接让你的程序停止运行。这时,就可以使用运行时异常。
第二、如果对异常处理后,又引发一连串的错误的“连锁反应”的时候。
我们先来看一下第一种使用使用情况是怎么样的。例如说:
我们在上面的餐厅的例子中,餐厅即使出现菜品异常或停电异常这一类意外情况。
但针对于这一类的意外情况,我们是能够提供较为妥善的解决方案的。
而通过我们提供的针对于这些异常情况的解决方案进行处理之后,餐厅照常营业,顾客接着用餐(程序依旧能够正常运行)。
但还有一种情况,可能无论我们怎么样友好的尝试进行解决,都难以让顾客满意。这种顾客就是传说中被称为“**砸场子**”的顾客。
针对于这种情况,我们可能就要采取更为“强硬的措施”了。例如直接报警把他带走(不让程序继续运行了),这就是所谓的运行时异常:
~~~
package com.tsr.j2seoverstudy.exception_demo;
//砸场子异常
public class HitException extends RuntimeException {
HitException() {
super("草,砸场子,把你带走! ");
}
}
~~~
这时,餐馆类被修改为:
~~~
package com.tsr.j2seoverstudy.exception_demo;
public class MyRestaurant {
private static String sicuation;
static void doBusiness() throws DishesException, PowerCutException {
if (sicuation.equals("菜里有虫") || sicuation.equals("菜里有头发")) {
throw new DishesException();
} else if (sicuation.equals("停电")) {
throw new PowerCutException();
} else if (sicuation.equals("砸场子")) {
throw new HitException();
}
}
public static void main(String[] args) {
try {
sicuation = "砸场子";
doBusiness();
} catch (DishesException e) {
// 换一盘菜或退款
} catch (PowerCutException e) {
// 启动自备发电机
}
}
}
~~~
于是运行该程序,就会出现:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-08-17_57b4319d4e83e.jpg)
可以看到出现该运行时异常,程序将直接被终止运行,砸场子的人直接被警察带走了。
那么接下来,我们就可以来看看第二种使用情况了,什么是所谓的“引发连锁效应的错误”。
举个例子来说,以我们上面用到的“被除数为0”的异常情况。你可能会思考:传入的被除数为0,这样的情况我们是可以考虑到的。
并且我们也可以针对这样的错误给出对应的措施。那Java为什么不将这样的异常定义为编译时检测异常呢?
那么我不妨假设ArithmeticException就是编译时检测异常,所以我们必须对其作出处理,那么可能出现这样的代码:
~~~
public class Test {
public static void main(String[] args) {
System.out.println("5除以0的结果为:" + division(5, 0));
}
static int division(int a, int b) {
int num = 0;
try{
num = a/b;
}catch (ArithmeticException e) {
num = -1;
}
return num;
}
}
}
~~~
我们提供了一个进行除法运算的方法,针对于传入的被除数为0的异常情况,我们也给出了自己的解决方案:
如果传入的被除数为0,就返回负数“-1”,“-1”就代表这个运算出错了。
于是这时有一个调用者,刚好调用了我们的除法运算方法计算“5除以0的结果”,理所当然的,他得到的结果为:
“5除以0的结果为-1”。好了,这下叼了,这哥们立马拿着这个运算结果,去向他的朋友炫耀:
你们都是2B吧,算不出5除以0等于多少是吧?告诉你们,等于-1。于是,在朋友的眼中,他成2B了。
';
牛刀小试 – 详解Java中的接口与内部类的使用
最后更新于:2022-04-01 20:08:42
**一、接口**
- 接口的理解
Java接口是一系列方法的声明,是一些方法特征的集合,一个接口只有方法的特征没有方法的实现;
也就是说,接口自身自提供方法的基本声明,而不提供方法体;接口中声明的方法只能被实现该接口的子类所具体实现。
接口是Java中另一种非常重要的结构。因为Java不支持多继承,某种程度来说这也造成了一定的局限性。
所以接口允许多实现的特点弥补了类不能多继承的缺点。通常通过继承和接口的双重设计,可以既保持类的数据安全也变相实现了多继承。
- 接口的特点
1. 使用关键字"interface"声明一个接口;使用关键字"implements"声明一个类实现一个接口。
1. 与类的权限限制相同,接口的也只能被声明为“public”或默认修饰符。
1. 接口当中声明的变量被自动的设置为public、static、final,也就是说在接口声明的变量实际都会被隐式的提升为"公有的静态常量"
1. 接口中声明的方法都是抽象的,并且都被自动的设置为public。
1. 接口自身不能被构造实例化,但可以通过实现该接口的类进行实例化。
1. 实现接口的类如果不是抽象类,那么该类就必须对接口中的方法进行实现。
1. 接口与接口之间也可以实现继承关系。子接口除拥有父接口的所有方法声明外,还可以定义新的方法声明。
~~~
package com.tsr.j2seoverstudy.interface_demo;
//访问修饰符只能为public或默认访问修饰符
public interface InterfaceDemo {
// 会被隐式的提升为:public static final int VAR = 50;
int VAR = 50;
// public void methodDemo();
void methodDemo();//方法必须是抽象的
}
class Test implements InterfaceDemo{
public static void main(String[] args) {
InterfaceDemo in = new InterfaceDemo();//compile exception
InterfaceDemo in = new Test();//但可以声明接口类型的变量,并通过实现该接口的类来进行实例化
}
@Override
public void methodDemo() {
}
}
~~~
根据接口的特点,实际上我们可以看到:接口实际上更像是在声明一种规范,相当于实现定义了程序的一种框架。例如,作为一种遥控汽车的设计者。你可能需要提供一些规范给这些遥控汽车的生产厂商们,让他们按照你的设计规范来生产遥控汽车。
~~~
package com.tsr.j2seoverstudy.interface_demo;
public interface Moveable {
void turnLeft();
void turnRight();
void stop();
}
~~~
这是你提供的让玩具汽车具备可移动性(moveable)的接口,生产厂商必须按照该接口的规范进行实现,才能然小汽车成功的move起来。
- 接口与抽象类的区别
1. 一个类可以实现多个接口,但只能继承一个抽象类。
1. 抽象类中可以存在非抽象方法,但接口中声明的方法必须是抽象的。
1. 抽象类中的方法可以是任何的访问权限,但接口中的方法都是public权限的。
1. 抽象类中可以存在自己定义的任何类型的实例域(变量等),但接口中的域都是公有、静态、最终的。
而抽象类和接口最大的相同之处,可能就在于:都是对其体系中的对象,不断的进行向上抽取而来的共有特性。它们都可以用于多态的实现。
**二、内部类**
1、什么是内部类?
顾名思义,内部类就指定义在另一个类的内部当中的类。我们知道一个Java所编写的基本体现形式就是一个类,而一个类的结构通常是由域(静态域、实例域)和方法构成的。而有时候一个类中还有另一种构成部分,就是内部类。
2、为什么使用内部类?
关于这点,《Java2学习指南》中这样说:你是一个OO程序猿,因此知道为了代码的重用性和灵活性(可扩展性),需要将类保持足够的专用性。也就是说,一个类只应该具有该类对象需要执行的代码;而任何其它操作,都应该放在更适合这些工作的其它的类当中。但是!有时候会出现当前类当中需要的某个操作,应当放在另一个单独的特殊类中更为合适,因为要保持类足够的专用性;但不巧的是,这些操作又与当前的类有着密切联系(例如会使用到当前类当中的成员(包括私有成员)等等)。正是这一类的情况,促使了内部类的诞生。
而更具体的来说,之所以使用内部类的原因,通常主要在于:
- 在内部类当中可以访问该类定义所在的作用域当中的任何数据,包括私有数据。(这是因为内部类会隐式的持有所在外部类的对象引用:“外部类名.this”)
~~~
package com.tsr.j2seoverstudy.base;
public class Outer {
private int num = 5;
private class Inner {
void printOuterNum() {
/*
* 1.验证了内部类可以访问其定义所在的作用域当中的任何属性,包括被声明为私有的属性。
* 2.之所以内部类能访问外部类的实例属性,是因为其隐式的持有了外部类的对象:外部类类名.this
*/
System.out.println(num);
System.out.println(Outer.this.num);
}
}
}
~~~
可以看到上面例子中虽然外部类“Outer”中的变量“num”被声明为私有的,但定义在“Outer”当中的内部类仍然可以访问到该成员变量。
- 内部类能够针对于存在同一个包下的其他类,将自身隐藏起来。简单的来说,该好处就是带来更完善的类的封装性。
~~~
package com.tsr.j2seoverstudy.base;
class Outer {
private int num = 5;
// 暴露给别人的方法接口
public void exposeMethod() {
Inner in = new Inner();
System.out.println(in.doSomeThingSecret());
}
private class Inner {
int doSomeThingSecret() {
// 封装一些你不想暴露给其它人任何细节的方法
System.out.println("隐蔽的方法,叼!");
return num;
}
}
}
/*
* 程序输出结果为:
* 隐蔽的方法,叼!
* 5
*/
public class Test{
public static void main(String[] args) {
Outer out = new Outer();
out.exposeMethod();
}
}
~~~
通过该例子我们可以看到通过内部类实现带来的严谨的封装性。我们通过内部类“Inner”的方法“doSomeThingSecret”完成了一系列“秘密的操作”。
但我们提供给它人使用时,暴露给使用者的细节只有外部类当中的一个方法“exposeMethod”。这就很好的达到了我们的目的,隐藏不想让别人知道的操作。
我们知道通常来讲,类的访问权限只能被修饰为public 或 默认的。这就意味着即使我们选择相对较小的访问权限:默认的包访问权限。
那么我们定义的该类当中的实现细节,也会暴露给位于同一个包中的其它类。
而内部类允许被声明为pirvate。这意味着:其它类甚至连我们定义了这样的一个类都不知道,就更不用提该类当中的实现细节了。
- 通过匿名内部类能够更为便捷的定义回调函数。以Java中的多线程机制为例:
如果不使用内部类,那么我们的实现方式就应该如同:
~~~
package com.tsr.j2seoverstudy.base;
public class InnerDemo {
public static void main(String[] args) {
Assignment assignment = new Assignment();
Thread t = new Thread(assignment);
t.start();
}
}
class Assignment implements Runnable{
@Override
public void run() {
System.out.println("线程任务");
}
}
~~~
而通过匿名内部类,我们可以将实现简化为:
~~~
package com.tsr.j2seoverstudy.base;
public class InnerDemo {
public static void main(String[] args) {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("线程任务");
}
});
t.start();
}
}
~~~
与我在之前的回顾中说到的一样,“匿名”其实很好理解,直接的理解就是没有名字。Java中的标示符就是名字。
所以,在第一种实现方式里,定义线程任务类的类名标示符“Assignment ”就是该线程任务类的类名。
而当我们将线程的任务通过第二种方式实现时,我们发现该其直接被作为参数传递给Thread类的构造函数当中。
没有相关的类名标示符,那么这个线程任务类就是没有名字的,所以被称为“匿名”。
注:第三种使用方式应该是实际开发中最常用的。前两种情况我个人在工作里很少用到,但很多知名的书中都介绍了,所以不妨也作为一种了解,总会有用得上的时候。
**创建内部类对象的方式**
在上面我们说过了,内部类当中是隐式的持有一个其所属外部类的对象引用的。
由此就不能想象,一个内部类的对象创建肯定是依赖于其所属的外部类的。
换句话说,要像创建使用一个内部类的对象,前提是必须先获取到其所属外部类的对象。
内部类的创建方式大概也就是两种情况:
- 在其所属外部类当中,创建该内部类对象:这种情况与平常创建对象的方式没有任何不同,也就是ClassName clazz = new ClassName();这样的方式。
- 在其所属外部类之外的类中创建内部类对象:因为我们说过了内部类对象的创建依赖于其所属外部类,所以这是的创建方式为:Outer.Inner in = new Outer().new Inner()。
我们还是通过一道以前看见过的面试题,来更直观的了解内部类的对象创建:
~~~
/*
* 题目:
* 1. public class Outer{
* 2. public void someOuterMethod() {
* 3. // Line 3
* 4. }
* 5. public class Inner{}
* 6. public static void main( String[] args ) {
* 7. Outer o = new Outer();
* 8. // Line 8
* 9. }
* 10. }
*
* Which instantiates an instance of Inner?
* A. new Inner(); // At line 3
* B. new Inner(); // At line 8
* C. new o.Inner(); // At line 8
* D. new Outer.Inner(); // At line 8
*/
~~~
其实很简单,只要牢记我们上面说的两种创建情况就OK了。归纳来讲:在创建内部类对象之前,必须先构造其外部类的对象。
所以当在外部类中创建内部类对象,因为外部类自身持有对象引用:this。所以可以直接创建内部类。
而在外部类之外创建内部类对象,则就需要先new Outer()创建得到外部类对象,再创建内部类对象。
由此我们来分别看该题目当中的4个答案:
- A答案放在程序的第三行。是在其所属外部类当中的实例方法中创建内部类对象,因为实例方法持有外部类对象引用this,所以可以直接创建。则A答案合法。
- B答案放在程序的第八行。虽然是在内部类本身创建内部类对象,但因为代码是位于静态方法当中,所以并不持有其外部类对象。所以B是非法的。
- C答案放在程序的第八行。代码虽然位于静态方法中,但因为之前已经创建了外部类对象“o”,所以再通过“o”创建内部类对象的方式是行得通的的。但要注意的是这种方式的正确使用形式应当是“o.Inner()”而非“new o.Inner()”。所以C也是非法的。
- D答案放在程序的第八行。乍看之下,十分完美。但要注意的是其使用的是“new Outer.”,而非通过new关键字调用类构造器创建对象的正确方式“new Outer().”。所以D自然也是非法的。
由此可以得出结论,合法的内部类实例声明方式只有:A。
**局部内部类**
局部内部类是内部类之中一种稍显特殊的使用方式。顾名思义,与“局部变量”相同,也就是被定义在方法或代码块当中的内部类。
关于局部内部类的使用,我觉得需要掌握的主要只有三点:
第一、与其它的局部成员一样,局部类的有效范围被限定在包含的代码块中,一旦超出该范围,该局部内部类就不能被访问了。
第二、局部内部类不能被访问修饰符修饰,也就是说不能使用private、protected、public任一修饰符。因为作用域已经被限定在了当前所属的局部块中。
第三、这通常也是使用局部内部类的最常见原因。你可能也注意到了,普通的内部类虽然能访问任何其所属外部类的成员;但其所属外部类定义的方法当中的局部变量是访问不到的,使用局部内部类就可以解决这一问题。但必须谨记的是:被局部内部类访问的变量必须被修饰为final。
~~~
public class PartInnerDemo {
int num_1 = 10;
public void method(){
final int num_2 = 5;
class PartInner{
private void InnerMethod(){
System.out.println(num_1+num_2);
}
}
}
}
~~~
**
**
**静态内部类(嵌套类)**
静态内部类可以说是内部类当中的一朵奇葩。开个玩笑,之所以这样说是因为静态内部类是比较特殊的一种内部类。
它的特性更像是一种嵌套类,而非内部类。因为我们前面说过了一般内部类当中,都会隐式的持有一个其所属外部类的对象引用。而静态内部类则不会。
除此之外,在任何非静态内部类当中,都不能存在静态数据。所以,如果想在内部类中声明静态数据,那么这个内部类也必须被声明为静态的。
当然,静态类中除了静态数据,也可以声明实例数据。不同之处在于:
如果要在之外使用静态内部类当中的静态数据,那么可以直接通过该内部类的:类名.静态成员名的方式。
而如果要只用该静态内部类当中的实例成员,那么就必须如同其他非静态内部类一样,先创建该内部类的对象。
但同时需要注意,静态内部类的对象创建与一般的内部类又有所不同,因为我们知道静态内部类自身是不持有外部类的对象引用的,所以它不依赖于外部类的对象。简单的说,我们可以认为静态内部类自身也就是所属外部类的一个静态成员,所以其对象创建的方式为:Outer.Inner in = new Outer.Inner();
~~~
public class StaticInner {
void test() {
int num_1 = Inner.num_1;
//
Inner in = new Inner();
int num = in.num;
}
private static class Inner {
int num = 5;
static int num_1 = 10;
}
public static void main(String[] args) {
StaticInner.Inner in = new StaticInner.Inner();
}
}
~~~
说到这里,想起另外一个问题。这个问题在初学Java时一直没能想清楚原因:
~~~
public class Test {
public static void main(String[] args) {
class Inner{
String name;
Inner(String s){
name = s;
}
}
Inner o = new Inner("test");
}
}
~~~
我们在前面说过,所有非静态的内部类的实例化工作,都依赖于其外部类的对象实例化。
但在这段代码中,我们对于定义的局部内部类“Inner”的对象创建却没有依赖于其所在外部类。
这应当是因为:因为该局部内部类被定义在一个静态方法中,静态方法中不会持有其所属的类的对象引用this。
也就是说,定义在静态方法当中的局部内部类遭受与静态方法同样的限制:不能访问任何其所属外部类的非静态成员。
而内部类之所以持有其外部类对象引用的目的在于:可以访问其所属外部类的所有实例成员。
那么既然现在我已经被限制成不能访问实例成员了,自然也就不必依赖于外部类的对象了。
**匿名内部类**
关于匿名内部类,其实在上面说为什么使用内部类的三种原因时,已经说过了。
匿名内部类的定义格式通常为:
~~~
new SuperType(constuction parameters){
//inner class method and data
}
~~~
对于匿名内部类,简单来说就是一种内部类的简写形式。
而必须注意的是,对于匿名内部类的使用,其前提是:该内部类必须是继承于一个外部类或者实现一个外部接口。
正如我们在上面说到的关于Java多线程。之所以能使用匿名内部类,是因为我们定义的匿名内部类实现了Runnable接口。
';
牛刀小试 – 浅析Java的继承与动态绑定
最后更新于:2022-04-01 20:08:40
**什么是继承?**
继承也是面向对象的重要特性之一。顾名思义,继承就是指从已有的类中派生出新类的动作。新的类能吸收已有类的数据属性和行为,并能扩展新的能力。
而通俗一点的来说,就是指Java中可以通过继承的方式,从现有的类派生出新的类。该现有类被称为超类(父类),而派生出的新类就被称为子类(派生类)。
首先,子类能够访问继承超类当中的所有非私有的方法和成员变量;其次,还可以在父类原有的成员的基础上添加一些新的方法和域,或者对父类的方法进行覆写(override)。
所有通常也这样讲:父类是子类的一般化表现形式;而子类是父类的特有化表现形式。
Java中使用关键字“extends”用于声明一个类继承自另一个类。
**继承的体现**
首先,假定我们自定义了一个雇员类“Employee”:
~~~
package com.tsr.j2seoverstudy.extends_demo;
public class Employee {
private String name; // 姓名
private int salary; // 收入
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getSalary() {
return salary;
}
public void setSalary(int salary) {
this.salary = salary;
}
}
~~~
一个部门中,雇员通常分为经理和普通雇员。二者之间大部分的行为相似,但可能在收入上略有不同。
假设普通雇员的收入来源为工资,而经理的收入结构则是由工资 + 绩效奖金构的。于是这个时候,原有的雇员类就不足以描述经理了。
于是,使用继承从原有的雇员类中派生出一个新的经理类“Manager”:
~~~
package com.tsr.j2seoverstudy.extends_demo;
public class Manager extends Employee {
// 子类新的特有实例域:绩效奖金
private int bonus;
public int getBonus() {
return bonus;
}
public void setBonus(int bonus) {
this.bonus = bonus;
}
}
~~~
**
**
**继承的特性**
**1、子类会继承超类当中的方法以和实例域**
~~~
package com.tsr.j2seoverstudy.extends_demo;
public class JavaExtendsDemo {
public static void main(String[] args) {
Manager m = new Manager();
m.setName("张经理");
System.out.println(m.getName());
m.setBonus(20000);
System.out.println(m.getBonus());
}
}
~~~
在这里可以看到的是,我们在派生类“Manager”当中并没有定义成员变量“name”,也没有定义其相关的set/get方法。
但是我们仍然可以通过“Manager”类对这些成员进行访问,正是因为继承的机制带来的。
“Manager”继承自“Employee”类,所以“Emoloyee”类当中的所有非私有化的成员都被隐式的继承到了子类“Manager”当中。
**2、方法覆写(override)**
前面我们已经说过了,经理的收入结构为:工资 + 奖金。所以“Employee”类当中获取雇员收入的方法“getSalary”就不适用于描述经理的收入了。
对于“Manager”类中,“getSalary”方法返回的值应当是:salary + bonus。这时候就涉及到继承中一个重要的知识点:方法覆写(override)
覆写是指:除方法体以外,方法的所有声明都当与父类中相同,而方法的访问修饰符只能比父类更加宽松。
一定要牢记覆写的规则,才不会再使用时出错。下面通过一道网上看见的华为的面试题,来更形象的理解一下覆写的概念:
~~~
package com.tsr.j2seoverstudy.extends_demo;
/*
* QUESTION NO: 3
*1. class A {
*2. protected int method1(int a, int b) { return 0; }
*3. }
*
* Which two are valid in a class that extends class A? (Choose two)
* A. public int method1(int a, int b) { return 0; }
* B. private int method1(int a, int b) { return 0; }
* C. private int method1(int a, long b) { return 0; }
* D. public short method1(int a, int b) { return 0; }
* E. static protected int method1(int a, int b) { return 0; }
*/
public class Test {
}
class A {
protected int method1(int a, int b) {
return 0;
}
}
class B extends A {
// 合法,通过提高访问权限(protected → public)的方式覆写父类方法。
public int method1(int a, int b) {
return 0;
}
// 不合法,注意覆写的规则:方法的访问修饰符只能比父类更加宽松。
private int method1(int a, int b) {
return 0;
}
// 合法,但并不是方法覆写。而仅仅是在类B中,针对继承的方法method1进行了重载;
private int method1(int a, long b) {
return 0;
}
// 不合法,首先方法返回类型不能作为方法重载的标示。那么就只能是覆写的情况,但覆写要求:除方法体外,方法所有声明都与父类中相同。
public short method1(int a, int b) {
return 0;
}
// 不合法,同样还是因为方法声明与父类不同。
static protected int method1(int a, int b) {
return 0;
}
}
~~~
**
**
**3、引用父类关键字“super”**
我们在第2点特性中说到,对于“Manager”类中,“getSalary”方法返回的值应当是:salary + bonus。也就是说“Manager”类中,“getSalary”的实现应当为:
~~~
public int getSalary(){
return bonus + salary;
}
~~~
但实际上,这样做肯定是行不通的,因为salary在父类中被声明为私有的。所以在子类中是无法直接进行访问的。
所以,我们只能通过父类中,针对于该成员变量提供的访问方法“getSalary”获取它的值。所以可能正确的实现应该是:
~~~
@Override
public int getSalary() {
return getSalary() + bonus;
}
~~~
但因为我们本身已经根据新的需求对该方法进行了覆写,所有这样做也是行不通的。
这是因为通过方法的覆写,“Employee”类当中已经有了自己特有的“getSalary”方法,所以上面的做法实际上是在让“getSalary”不断的调用自身,直到程序崩溃。
想要调用到父类当中的成员,就可以使用Java的关键字之一:super。
super是Java提供的一个用于只是编译器调用超类成员的特殊关键字。所以修改后的“getSalary”方法应当为:
~~~
@Override
public int getSalary() {
return super.getSalary() + bonus;
}
~~~
**4、多态以及动态绑定**
**多态**是指:同一事物根据上下文环境不同使用不同定义的能力。例如我们熟悉的重载、覆写都是多态的一种体现。多态是面向对象的又一重要特性。
而多态在Java里继承中的体现形式,主要分别为:
- 对象的多态:例如我们定义了一个超类动物类"Animal",其中老虎类"Tiger"和鱼类"Fish"都继承自"Animal"。这就是一种类的对象的多态体现。因为一个动物(Animal)构造出的对象既可以是一头老虎"Tiger",也可以是一条鱼“Fish”。
- 方法的多态:假设动物类“Animal”提供了一个动物呼吸的方法“Breath”。但因为老虎和鱼的呼吸方式是不同的,老虎用肺呼吸,而鱼通过腮呼吸。所以我们需要分别在对应的子类中覆写“Breath”方法。这也方法的多态的一种体现形式。
**动态绑定**是指:程序在运行期(而不是编译时期)根据对象的具体类型进行绑定,所以又被称为运行时绑定。动态绑定的执行过程大概为:
1、首先,编译器首先查看并获取到对象的声明类型和方法名;
然后在其声明类型对应的相应类及其超类的方法表进行查找;
最终搜索出所有方法声明为“pulbic”的对应方法名的方法,得到所有可能被执行的候选方法。
注:方法表也就是指,当一个类第一次运行,被类装载器(classloader)进行装载工作时,其自身及其超类的所有方法信息都会被加载到内存中的方法区内。
2、编译器将查看调用方法时传入的参数的类型。
如果在执行第一步工作中所获得的所有候选方法中,存在一个与提供的参数类型完全符合,则会决定绑定调用该方法。
这个过程被称为重载解析。也就可以明白:用重载表现的多态,其动态绑定的解析工作就是这样完成的。
另外,由于Java允许类型转换,所以这一步过程可能会很复杂。
如果没有找到与参数类型匹配的方法,或者经过类型转换过后有多个匹配的方法,则会报告编译错误。
注:方法名+参数列表 = 方法签名。所以前面我们谈到的方法的覆写规则也可以理解为:方法签名必须与父类相同,访问修饰符只能更宽松。
但值得注意的是,方法返回类型不是方法签名的一部分。在JDK1.5之前,要求覆写时,返回类型必须相同。
而在这之后的版本中,覆写允许将父类返回类型的子类作为返回类型,这被称为**协变返回类型**。
3、与之对应的:如果是被声明为private、static、final的方法或构造器,编译器将可以直接的准确知道应当调用的方法。这种方式又被称为静态绑定。
4、如果采用的是动态绑定的方式。当程序运行时,一定会选择:对象引用所指向的实际对象所属类型中,最合适的方法。
这也是为什么在继承中,子类覆写超类中的方法后。如果使用超类的类型进行声明,而实际引用子类的对象的对象引用调用方法,会准确的调用到子类中覆写后的方法的原因。
~~~
package com.tsr.j2seoverstudy.extends_demo;
public class Test {
public static void main(String[] args) {
Animal [] animals = new Animal[3];
animals[0] = new Animal();
animals[1] = new Tiger();
animals[2] = new Fish();
for (Animal animal : animals) {
animal.breath();
}
}
}
class Animal{
void breath(){
System.out.println("动物呼吸");
}
}
class Tiger extends Animal{
@Override
void breath() {
System.out.println("老虎用肺呼吸");
}
}
class Fish extends Animal{
@Override
void breath() {
System.out.println("鱼用腮呼吸");
}
}
/*
运行结果为:
动物呼吸
老虎用肺呼吸
鱼用腮呼吸
*/
~~~
**5、继承结构:单继承和多重继承**
Java中不支持多继承。其继承结构分为:单继承和多重继承。
顾名思义,单继承也就是指:一个类只能继承自一个超类。
而多重继承则是指:C类继承自B类,而B类继承自A类这样的继承层次结构。
多重继承的应用是很常见的。还是以动物为例,老虎继承自动物。而老虎可能又分为东北虎之类的很多品种,那么新定义的东北虎类就应该继承老虎类。
这就是多重继承的一种常见体现形式。
6、继承体系中子类的构造过程
~~~
package com.tsr.j2seoverstudy.extends_demo;
public class JavaExtendsDemo {
public static void main(String[] args) {
Son s = new Son();
}
}
class Far{
Far(){
System.out.println("父类构造初始化..");
}
}
class Son extends Far{
Son() {
//这里有一个隐式的构造语句:super(),调用父类的构造初始化工作..
System.out.println("子类构造初始化..");
}
}
/*
运行结果为:
父类构造初始化..
子类构造初始化..
*/
~~~
也就是说,子类的构造过程是依赖于父类的,当开始子类的构造工作之前,会先完成其所属超类的对象构造工作。
这是理所应当的,因为我们已经知道子类的很多属性与方法是依赖于父类的,如果在此之前不先完成父类的构造工作,对于子类的使用就很容易引起错误。
**
**
**什么时候使用继承?**
要了解什么使用继承,首先我们应当知道使用继承可以带来的好处是什么:
- 通过继承构成体系,能让程序的条理性更加清晰。
- 若出现特殊需求,子类可以较方便的改动父类的实现。
- 最大的好处在于方便实现代码的复用,减少了编码工作。
所以,最长在什么时候使用继承呢?
1、声名远扬的“IS-A”关系
例如上面说到的“经理是一个雇员”,“老虎是一种动物”都是归属于这种关系。
2、想要通过继承实现多态
以一段代码更好理解,假如我们的程序提供了一个接口用于获取动物的奔跑速度:
~~~
int getSpeed(Animal animal){
return animal.getRunSpeed();
}
~~~
通过继承,通过这样简单的代码。只要传入的参数对象的类型位于该Animal的继承体系当中,就可以通过动态绑定的机制正确获取到其奔跑速度。
这实际上也是”策略设计模式“的一种体现方式。而如果假设不使用继承的话,就需要提供对应数量的方法来分别获取不同动物的奔跑速度。
但与此同时,继承也有一定的弊端:
- 类继承是在编译时刻静态定义的,所以无法在运行时改变继承类的实现。
- 通过继承的复用方式被称为“白箱复用",因为父类的实现细节对子类可见。
- 父类通常都至少定义了子类的部分行为,所以父类的改变都有可能影响到子类的使用。
所以,正如这个世界上任何事物都有两面性一样,对于继承的使用还是应该结合实际情况作出最适合的决定。
';
换一个视角看事务 – 用"Java语言"写"作文"
最后更新于:2022-04-01 20:08:38
前段时间在抽工作之空余,更加系统和深入的重新学习Java的一些技术知识。
最近也试着申请了一个专栏,对前段时间的一些收获和知识点做一个系统性的归纳回顾和总结。
昨天也是刚刚写完了关于Java中的各种基础语言要素的总结,总觉得少了点什么。
对基础语言要素的理解和使用,实际上是很重要的。
俗话说,一切伟大的行动和思想,都源于一个微不足道的开始。
而对于一门语言来说,熟练的掌握对其基础语言要素的理解和使用,就是这个“微不足道的开始”
可以这样说,一门语言的基础语言要素,就等同于是一门武功的内功。
将自己的“内功”练到炉火纯青,剩下的,就只是招式(外功)而已了。
我在网上看到别人这样解释内功与外功的区别,觉得用于表达我想说的话,十分契合:
内功就像子弹,外功就像枪。
每个人都会有子弹和枪,但问题是多少和优劣。
内功越强,子弹越多;外功越强,枪就越好。
其两者之间的关系就是,内功是基础,决定外功建筑。
对应于Java当中来说则是:
基础语言要素是根基;而我们所做的所谓的“编程”工作,实际就是通过根基创造使用招式而已。
回想最初学习Java时,一直没能对这些基础语言要素有足够的重视,
也没能对其有一个很系统性的分类归纳的理解,导致总觉得思路有点杂乱。
时常在想,怎么样可以对这些语言要素的概念和使用方式,有一个更形象的理解。
后来在渐渐深入的过程中,发现:
“编程”工作与我们在学生时代都经历的“作文”工作实际上是很相似的。
举例来说:
~~~
请以《站在……的门口》为题写一篇文章。
要求:1.立意自定。
2.文体不限。可以记叙经历,抒发感情,发表议论,展开想象,等等。
3.不少于800字
~~~
这是什么?这是一个作文题目。
而:
~~~
有一个100万长度的数组,其中有两个数是重复的,
请使用Java语言写一个最快的算法,查找到重复的数。
~~~
这又是什么?这是一道程序题目。
所以我们不难发现,所谓的“作文题目”与“程序题目”。
归纳来讲:实际上也就是一种“实现需求”罢了。
对于二种不同的需求,我们所做的工作实际上都是“编写文章”!不同的是,
- 对于“作文题目”,我们使用的语言是:中文;对于“程序题目”,我们使用的语言是:Java;
- 对于用中文书写的文章,我们通常称为“一篇作文”;对于用Java语言书写的文章,我们通常称为“一个Java类”。
一篇作文的构成可能通常包括:
- 标题:用于对整篇作文所讲的内容做一个最精短的概括。例如每个人小时候可能都用过的作文题目《我的父亲》,目的就是让阅读的人看见就题目就知道这篇作文是描写关于你父亲的事迹的。
- 人、物、地点:一篇文章自然会涉及到相关的人,物和事情发生的地点。例如《我的父亲》一文里,通常都有一个经典情节:记得小时候,夜里发高烧下着大雨,也打不到车了。父亲毅然决然的冒着大雨背着我跑到了医院..云云。那么这里涉及到的人就是:“我”和“父亲”;涉及到的物可能包括:雨伞,雨衣等;涉及到的地点可能包括:“家”,医院等.
- 故事情节(段落):故事情节可能是一篇作文所占比重最大的部分。而同样以《我的父亲》为言,通常我们会根据多个事例来表现父亲对自己的好,而每个事例就会分别对应于一个故事情节。而我们就会通过分段来分别描述这些故事情节,而故事情节里涉及到的就是所谓的“人、物、地点”。
同理的,一个Java类的构成通常包括:
- 类的声明:Java类的类名与作文的“标题”作用实际是一样的。就是用于描述你封装的该类提供的功能。
- 变量/常量:变量与常量实际上就是作文中的“人,物和地点”。
- 方法(函数):方法用于描述我们要提供的程序功能,就如同段落用于描述故事情节一样。而段落涉及“人、物、地点”,方法里使用定义的变量/常量。
我们将上面举例说到的《我的父亲》转换为一个Java类的体现形式,可能能让我们更形象的进行理解:
~~~
/*
* 作文《我的父亲》用Java类体现
*/
public class MyDad /* 类声明::作文标题 */{
// 变量(常量)用于记录作文中的人、物、地点
// 人物:
private static final String ME = "我";
private static final String MY_DAD = "我的父亲";
// 物:
private static final String RAINCOAT = "雨衣";
// 地点:
private static final String MY_HOME = "我家";
private static final String HOSPITAL = "医院";
//函数 ::段落
private static void see_a_patient(){
StringBuilder sb = new StringBuilder();
sb.append("记得小时候,有一次");
sb.append(ME);//使用变量
sb.append("发高烧!窗外下着大雨,也打不到车。万分紧急之下,");
sb.append(MY_DAD);
sb.append("赶紧拿出一件");
sb.append(RAINCOAT);
sb.append("为我披上,背上我,冒着大雨把我从");
sb.append(MY_HOME);
sb.append("送到了");
sb.append(HOSPITAL);
System.out.println(sb.toString());
}
public static void main(String[] args) {
see_a_patient();
}
}
~~~
该程序运行的输出信息为:
记得小时候,有一次我发高烧!窗外下着大雨,也打不到车。万分紧急之下,我的父亲赶紧拿出一件雨衣为我披上,背上我,冒着大雨把我从我家送到了医院
请看了,我们不正是用Java语言写了一篇“作文”吗?
到此,我们通过以熟悉的“作文”为切入点,了解了一个Java类的构成结构。
由此我们知道了:一个Java类的构成实际并不复杂,它的结构通常就是类声明、变量(常量)以及方法(函数)。
而所谓的一个“复杂的类”就如同“一篇几万字的文章”的原理一样,无非就是:
1、涉及到的人、物更多 = 定义的变量\常量更多
2、段落更多/描述的故事情节更多 = 定义的方法(函数更多)
而对应的,以长篇小说为例。如果一篇文章的故事的字数已经达到了一定范围,那么可能就会影响阅读性。这个时候,我们就可以进一步的对其进行“分解”。
例如一本自传体的长篇小说,一个人一生中可以描述的情节有很多,那么它可能被搭建成如下结构:
有十篇用于描述其青年时期事迹的故事,我们将其提取出来放在一起,形成小说的第一篇:《我的青年时期》
有十篇用于描述其中年时期事迹的故事,我们也将其提取出来,作为小说的第二篇:《我的中年时期》..
从而以此类推。最终这个结构的总和被我们称作“小说”。
Java中,也是一样的。如果多个类综合完成同一方面的功能实现,
那么我们也可以将这些类提取出来,形成一个“篇章”,Java中的**包结构**就是这个“篇章”。
而最终由多个包结构组合起来的整个程序,就是所谓的一个完整的Java项目。
再壮观的高楼大厦,究其根本也不过就是由众多数量的钢精水泥搭建起来的。
而建筑师能做的就是通过设计手段(招式)让大楼的结构更加稳固,美观和实用。
我们谈到,之所以对一篇长篇小说进行这样的结构搭建。是因为当文章字数达到一定量的时候,就会影响阅读性,其主题也会模糊。
所以在Java中也是同样的道理,这一定程度上也是“代码重构”的初衷之一。类应该具有足够的功能特性化。
前面我们说到的是**程序的结构**,但同时我更想重点谈到的是Java的**基础语言要素**的使用。
但是在了解了程序的结构之后,我们相对就更容易了解基础语言要素的意义了。
同样以作文为例,了解作文结构是必要的。但我们还应该做的,就是以结构为切入点,
继续深入看一篇作文的基础要素与基础成分,也就是是:标点符号,词语,语句之类的东西。
因为即使你对于作文结构的研究再深,如果不会使用文字,不会使用标点,不会书写语句,那一切都是空谈。
同理,假设我们要搭建一所房屋。需要了解房屋的结构构成要有卧室,厨房,浴室,客厅。
但仅仅是了解这样的结构,也许足够成为一名在纸上画房屋结构设计图的设计师;但还不足以让你真的搭建出一所房子。
要成功的搭建出房子,还需要了解房屋的构建成分也就是材料,如同水泥,钢筋,一些建材等等。
那么对应到Java中来说,其实也是一样的道理。
而Java为我们提供的材料(基础语言要素),包括:
标示符、关键字、注释、常量(变量)、运算符、表达式以及程序语句。
到了这里我们再对前面说到的“类声明、变量、方法”的Java类的结构构成,进行成分分析:
类声明 = 访问修饰符关键字 + 类声明关键字 + 类名标示符。
变量(常量) 的声明 = 访问修饰符关键字 + 数据类型(基本数据类型或对象数据类型) + 赋值运算符 + 变量或常量的值
方法的声明 = 访问修饰符关键字 + 类/变量/方法修饰符关键字 + 返回类型关键字 + 方法名标示符 + 方法参数列表(局部变量)
而方法的内容通常就是通过对:运算符、表达式、程序语句(简单语句和复合语句)组成“招式”,完成对数据(变量等)的操作。
~~~
//类声明
public/* 访问修饰符关键字 */class /* 类声明关键字 */JavaArticlescrap /* 类名标示符 */{
private/* 访问修饰符关键字 */int /* 数据类型 */num_1 /* 变量标示符 */= /* 赋值运算符 */10;
private int num_2 = 20;
public/* 访问修饰符关键字 */static/* 静态修饰符关键字 */int /* 方法返回类型 */getSum /* 方法名标示符 */(
int a, int b)/* 参数列表(局部变量) */{
int sum = a + b; // 运算符、表达式、语句等等方法内容
return sum;
}
}
~~~
由此我们简单的总结一下,一个Java程序的编写工作,实际通常就是:
根据“需求”,通过对Java基础语言要素(示符、关键字、注释、常量(变量)、运算符、表达式以及程序语句)的使用,组成“招式”。
写一篇结构为:题目(类声明) + 人、物、地点(变量/常量声明) + 段落(方法/函数)的“作文”。
';
磨刀不误砍材工 – Java的基础语言要素(数组)
最后更新于:2022-04-01 20:08:35
在日常生活中,可乐有罐装的,有瓶装的。这里的“罐”和“瓶”就是可乐的容器。
Java当中也一样,当同一类型的数据数量较多时,我们也可以通过容器将其装在一起,更加方便使用。
数组是Java中的对象,用以存储多个相同数据类型的变量。
数组能够保存基本数据类型也能保存对象引用,但数组自身总是堆中的对象。
**一、数组的创建**
**1.1、声明数组:**
通过说明数组要保存的元素类型来声明数组,元素类型可以是基本数据类型或对象,后跟的方括号可以位于标示符的左边或右边。
也就是说,数组的声明方式可以分为以下两种:
- ArrayType ArrayName [];
- ArrayType [] ArrayName;
符号“[]”代表声明的是一个数组,这两种声明方式就达到的效果而言,没有任何区别。
但第二种方式可以一次性声明多个数组,如:int [] intArr1,intArr2;而且第二种方式的阅读性更强。
所以通常来说,都选择使用第二种方式来声明一个数组对象。
**1.2、构造数组:**
当我们使用上面说到的方式声明了一个数组后,实际上只是声明了一个对应数组类型的对象引用,而内存中还没有真正的数组对象存在。
这时候,就需要完成数组对象的构造工作。所以说,我们所谓的构造数组,实际上也就是是指在堆内存中真正的创建出数组对象。
而换句话说,所谓的构造数组的工作。就是指,在数组类型上执行一次new,从而完成数组的对象实例化工作。
为了创建数组对象,JVM需要了解在堆内存上需要分配多少空间,因此必须在构造时指定数组长度。数组长度是该数组将要保存的元素的数量。
构造数组最直观的方法是使用关键字new,后跟数据类型,并带方括号“[]”指出该数组要保存的元素的数量,也就是数组长度。
例如:int [] intArray = new int[10];。该条语句代表声明并构造了一个长度为10的保存int类型数据的数组。
再次提醒,构造数组时必须要求声明数组的长度,因为我们已经说过了JVM需要得到这个长度,才能为该数组对象在堆中分配合适的内存空间。
所以,不要使用int [] intArray = new int [];这样的语句。这将会引起编译错误。
现在我们通过一段代码来看一看,数组在内存中的构造初始化特点:
~~~
private static void demo_1() {
byte[] byteArr = new byte[5];
short[] shortArr = new short[5];
int[] intArr = new int[5];
long[] longArr = new long[5];
char[] charArr = new char[5];
float[] floatArr = new float[5];
double[] doubleArr = new double[5];
String[] strArr = new String[5];
System.out.println("byteArr:" + byteArr[0]);
System.out.println("shortArr:" + shortArr[0]);
System.out.println("intArr:" + intArr[0]);
System.out.println("longArr:" + longArr[0]);
System.out.println("charArr:" + charArr[0]);
System.out.println("floatArr:" + floatArr[0]);
System.out.println("doubleArr:" + doubleArr[0]);
System.out.println("strArr:" + strArr[0]);
}
~~~
这段程序运行的输出结果为:
byteArr:0
shortArr:0
intArr:0
longArr:0
charArr:
floatArr:0.0
doubleArr:0.0
strArr:null
观察输出结果我们发现,对于数组构造,JVM在内存中还会根据该数组的数据类型对其中的元素进行一次默认初始化的赋值。其实这也正是源自于堆内存自身的特点。
对于在堆内存中存储的变量,如果我们没有在声明变量时对其进行赋值工作。那么堆也会对这些变量根据其自身数据类型进行一次默认初始化的赋值工作。
这也正是我初学Java时,一直不明白为什么一个类的成员变量可以不做手动的初始化赋值工作,仍然能够在以后的程序中正常调用;
而如果一个局部变量如果不进行手动的初始化赋值,如果在之后的代码对其发生调用,就会编译出错的原因所在。
因为成员变量存储在堆当中,即使我们没有对其做手动的初始化赋值工作,其也会有一个默认的初始化值。而存储在栈内存当中的成员变量则不会被进行默认初始化赋值工作,所以如果说我们没有人为的为其指定一个初始化值的话,在之后对其调用时,该变量自身是没有值的,自然无法调用。所以也就不难理解为什么会编译出错了。
**多维数组**
像我们前面说到的格式为:int [] intArray = new int [5];这样声明的数组,被称为一维数组。那么对应的,自然也就存在多维数组。
要明白的是:多维数组其实也是数组,只不过一维数组用于保存基本数据类型或对象引用,而多维数组用于保存数组(其实也是保存对象引用,数组的对象引用)。
所以,假设我们声明一个二维数组:
int [ ] [ ] twoD = new int [5] [5];
对于这样的多维数组,我们应该这样理解:
一个int型的二维数组,实际上就是一个int型的一维数组(int [])的对象,而它保存的是一维数组的数组对象引用。
也就是说我们使用一个int型的二维数组,实际上存储的元素就是:int [] intArray = new int [5];这样的数组对象的引用。
所以就int [ ] [ ] twoD = new int [5] [5];而言,
实际上就是说,构造了一个int型的二维数组,该二维数组存储5个“int [] intArray = new int [5]”这样的一维数组。
如果觉得这样的说法还是过于抽象,不易理解的话。我们不妨结合一些现实生活中的事物来看待:
举个例子,我们在感冒或者中暑之类的时候,可能都喝过一样东西叫:藿香正气液。
就以藿香正气液来说,我们知道它的最小包装单位是“一小瓶”。我们可以将“一小瓶藿香正气液”,视为我们要存储的数据元素。那么:
假设一盒藿香正气液里面包装有10小瓶,就正是所谓的一维数组:
藿香正气液 [ ] 一盒 = new 藿香正气液 [10];
假设一箱藿香正气液里面包装有50盒,这种情况就是所谓的二维数组:
藿香正气液 [ ] 一箱 = new 藿香正气液 [50] [10];
同样的原理,更多维的数组也可以以此类推。所以需要明白的就是:所谓的多维数组,根本来讲还是数组。
**1.3、数组元素的赋值与取值**
既然知道了数组是作为存放统一数据类型的容器存在的,那么所谓容器,自然就涉及到向容器中存放元素或从中取出元素的操作的。
Java中,对于数组元素的访问方式很简单,数组中的各个元素都能通过索引(也就是数组下标)进行访问,格式为:arrayName[下标]。
而需要注意的就是,Java中数组元素的索引是从0而不是从1开始的,也就是说第一个被存储进行数组的元素的索引是0而不是1;
相对的,数组中最后一个元素的索引就是声明的数组的长度减去1,而不是声明的数组长度。如果通过无效的索引访问数组,则会触发数组越界异常。
具体的使用,通过一段简单的代码进行简单的演示:
~~~
private static void demo_2() {
int[] array = new int[5];
// 可以通过length获取数组的长度
System.out.println("数组的长度为:" + array.length);
// 遍历数组中的元素,并为其赋值
for (int i = 0; i < array.length; i++) {
array[i] = i + 1;
}
// 遍历获取数组中的元素的值
for (int i = 0; i < array.length; i++) {
System.out.println("array[" + i + "] = " + array[i]);
}
}
~~~
话到这里,我们已经知道了在Java当中:
1、通过arrayType [] arrayName;可以声明一个数组。
2、通过arraryName = new arrayType[length];可以对声明的数组在内存中进行构造工作,并完成元素的一次默认初始化。
3、通过arrayName[index]对数组中存放的元素进行赋值或访问。
那么,顺带一提的就是,Java种还提供另外一种声明方式。这种声明方式将数组的声明、构造以及赋值(显式初始化)工作都集合到一条语句当中。
这种声明方式就是:arrayType [ ] arrayName = {value1,value2,value3};。
举例来说,如果我们想声明一个int型的数组对象,其数组长度为4,我们想要存放的4个值分别为1,3,6,9。那么就可以定义为:nt [] intArray = {1,3,6,9};
使用这种方式最大的特点就在于:可以在声明数组的同时,就完成数组中的元素的赋值工作。
除此之外,还有另外一种数组的声明方式,被称为:匿名数组创建。
举例来说,我们这样定义:int [ ] intArray = new int [ ]{1,2,3}; 。这里的"new int [ ]{1,2,3}"就被称为匿名数组的创建。
你可能在想,使用这样的创建方式相对于其它方式而言,好处是什么?
好处就是:可以实时的创建出一个新的数组,而不需要将其赋给任何变量,就直接作为参数传递给接受对应数组类型的方法。
我们通过一段代码更形象的来理解关于“匿名数组”的这一特点:
~~~
//分别使用三种创建方式完成相同效果的数组创建工作
private void array_demo(){
//第一种方式
int [] array_1 = new int[3];
array_1[0] = 1;
array_1[1] = 2;
array_1[2] = 3;
//第二种方式
int [] array_2 = {1,2,3};
//第三种方式
int [] array_3 = new int[]{1,2,3};
/*
* 第三种方式最大的好处就在于:
* 直接实时创建匿名数组对象作为方法参数进行传递
* 而不需要像第一和第二种方式必须通过变量(对象引用)进行传递。
*/
this.acceptUnnamedArray(new int[]{1,2,3});
}
private void acceptUnnamedArray(int [] arr){
//...
}
~~~
我想顺带说明一下的就是,之所以被称为“匿名”数组,就是因为“new int [ ]{1,2,3}”直接作为方参数进行传递,
而没有将自身赋给任何对象引用,也就是说该数组对象自身是没有标示符的,我们知道在Java中,标示符就是一个变量的名字。
既然它没有对应的“名字”,自然就被称为“匿名”了。而同理的,Java中的匿名对象也是如此。
与此同时数组本身也属于对象,所以匿名数组其实也可以被视作是匿名对象的一种。
**数组排序**
很多时候,我们会需要对数组中的元素按照一定的顺序(如按从小到大顺序等等)进行重新排列。
所谓的排序,也就是指将元素按照指定顺序进行位置的置换。所以,在正式的排序工作之前,我们应该首先了解数组中元素的置换工作怎样完成。
数组中元素的置换过程与我们传统的思想可能有些不同:
以学生相互换座位之间的问题为例,座位号为10的a学生与座位号为25的b学生进行座位的互换,其过程被分解一下,其实就是:
首先,让两位学生都起身离开座位。然后让a学生先坐到25号座位,b学生坐到10号座位,就完成了座位的置换。
而如果我们以相同的思想,对数组中的元素进行置换工作的话,对应代码的体现形式就是:
~~~
package com.tsr.j2seoverstudy.base;
public class ArrayDemo {
public static void main(String[] args) {
int [] arr = {1,2};
System.out.println("置换之前:");
printArr(arr);
//进行元素置换
swap(arr, 0, 1);
System.out.println("置换之后:");
printArr(arr);
}
/**
* 数组元素置换
* @param arr 数组
* @param a 要置换的第一个元素的索引(下标)
* @param b 要置换的第二个元素的索引
*/
private static void swap(int [] arr,int a,int b){
arr[a] = arr[b];
arr[b] = arr[a];
}
private static void printArr(int [] arr){
for (int i = 0; i < arr.length; i++) {
System.out.println("arr["+i+"] = " +arr[i]);
}
}
}
~~~
但是我们运行该程序,发现其输出结果为:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-08-17_57b4319ce64f2.jpg)
也就是说,置换后两个元素的值都变为了2。出现这样的错误,原因并不难理解:
首先arr[a] = arr[b],实际上执行的就是arr[0] = 2;于是这个时候内存中该数组内索引为0和为1的两个元素的值实际上都变为了2.
所以当再执行arr[b] = arr [a]的时候实际上就对应于arr[1] = 2;然后通过分析,我们发现:
之所以出现这样的输出结果,是因为原本的索引0的元素的值"1"在置换过程中丢失了。
那么我们应该做的措施就是让记录下这个值,不让其丢失。所以,就可以通过新建一个临时变量专门记录该值的方式,避免丢失的发生。
于是上面的swap方法,应该修改为:
~~~
private static void swap(int [] arr,int a,int b){
//定义一个临时变量记录索引a的元素的值
int temp = arr [a];
arr[a] = arr[b];
arr[b] = temp;
}
~~~
再次运行程序,查看其输出结果:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-08-17_57b4319d06eee.jpg)
了解了数组元素的置换工作,就可以试着完成对数组的排序了。这里我们对两种原理较简单但又很常用的排序方式进行了解。
第一种:选择排序
原理:每一趟从待排序的数组中选出最小(或最大)的一个元素,顺序放在已排好序的数列之后的一个位置,直到全部待排序的数据元素排完。
以我们日常生活中,按从矮到高的顺序站队列来说,假设现在有十个高矮不一的人需要排列。选择排序的方式就是:
首先我们假定现在站在最左边的第一个人就是最矮的,然后让他分别与后面的九个人依次进行比较。
在一次比较过程完成后,记录下这次比较中最矮的人和他站的位置。然后让这个人改变位置站到最左边去,而最初站在最左边的人则站到这个人的位置上去。
然后因为已经选出了最矮的人站在了最左边了,这次就直接将最左边第二个人选出,分别与剩下的8个人进行比较,然后以此类推。体现代码中的表现形式就是:
~~~
public static void main(String[] args) {
int[] arr = { 76, 81, 19, 24, 35 };
selectSort(arr);
for (int i = 0; i < arr.length; i++) {
System.out.print(arr[i]);
if (i < arr.length - 1)
System.out.print(",");
}
}
private static void selectSort(int[] arr) {
int temp; //缓存
int min; //最小值
int index;//最小值的索引
for (int i = 0; i < arr.length; i++) {
min = arr[i];
index = i;
//依次比较
for (int j = i + 1; j < arr.length; j++) {
if (arr[j] < min) {
min = arr[j];
index = j;
}
}
//位置置换
temp = arr[i];
arr[i] = min;
arr[index] = temp;
}
}
~~~
查看其输出结果为:19,24,35,76,81
第二种:冒泡排序
原理:冒泡排序也是一种交换排序算法。其过程是数组中较小(或较大)的元素看做是“较轻的气泡”,对其进行上浮操作。从底部开始,反复的对其进行上浮操作。
而对应于我们刚才谈到的队列问题,冒泡排序的方式就是:依次让相邻的两个人之间进行比较,如果左边的人高于右边的人,则让他们交换位置。对应的代码体现则是:
~~~
public static void main(String[] args) {
int[] arr = { 76, 81, 19, 24, 35 };
bubbleSort(arr);
for (int i = 0; i < arr.length; i++) {
System.out.print(arr[i]);
if (i < arr.length - 1)
System.out.print(",");
}
}
private static void bubbleSort(int [] arr){
int temp;
for (int i = 0; i < arr.length - 1; i++) {
/*
* 数组长度-1是为了保证不发生数组越界
* 而减-i则是因为每完成一次排序过程,该次比较中最大的数就会下沉到相对最后的位置,
* 那么就不需要进行重复而多余的比较工作了,-i就是为了避免这些多余的工作。
*/
for (int j = 0; j < arr.length - 1 - i; j++) {
if(arr[j]>arr[j+1]){
temp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = temp;
}
}
}
}
~~~
对于数组的理解和应用,到此就基本结束了。更多的使用方式还是应该根据实际的需求,自己加以利用。
';
磨刀不误砍材工 – Java的基础语言要素(语句-深入理解)
最后更新于:2022-04-01 20:08:33
语句同样是Java重要的基础语言要素之一,那么在Java中语句是以什么形式体现的呢?通常分为:
- 简单语句:就如同语文中以句号“。”结尾的一个句子就是一句语句一样,Java中以分号“;”结尾的一段代码就是最基本的一条Java语句。
- 块(复合)语句:指以一对花括号"{ }"包含起来的一系列程序语句的集合,所以又被称为复合语句。
提到块语句,我们就不得不提及与之紧密相关的一个名词:代码块。
代码块实际上也可以理解为作用域,之所以这样讲,是因为我们已经说过了代码块是以花括号“{ }”包含起来的一系列语句。
而块定义了变量的使用范围,各个块之间可以进行嵌套,而在块中声明的变量,只在当前块当中有效,在块以外将无法使用。
所以说,在使用代码块的时候,需要十分注意的两点就是:
- 注意变量的作用范围,不要在无效范围中使用该变量,否则程序将编译失败。
- 不要在嵌套的两个块中,声明使用相同标示符的变量,否则也将导致程序编译失败。
那么,首先我们来看第一个注意点:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-08-17_57b4316083bae.jpg)
在这段代码中,值得我们注意的是:
- **一个完整的Java程序(类)实际上正是由一个个嵌套的代码块组合起来的**。就像在类声明后用花括号包含起来的代码块被称为类代码块,方法声明后包含的代码块被称为方法代码块,嵌套在方法内的代码块被称为局部代码块等等一样。
- **嵌套在更内部层次的代码块可以使用嵌套外部的代码块中的内容,但位于嵌套更外部层次的代码块不能使用更内部层次的代码块中的内容。**所以在上面的例子中我们看到,方法代码块中可以使用类代码块中声明的变量;局部代码块中,在类代码块和方法代码快中声明的变量都能够被访问;但最后想要在方法代码块中访问局部代码块中声明的变量,程序就编译失败了。**这一切现象出现的原因,正是因为:在块中声明的变量只在当前块中有效。**
- **合理的使用代码块,可以在一定程度上节约内存开销。**这是因为之所以说块中声明的变量只在当前块中有效,深入的讲,实际就是因为代码块限定了其生命周期,也就是说当虚拟机执行到该代码块,当中声明的变量才会被加载到内存之中,而随着该代码块的代码都执行完毕,当中的变量就会在内存中被释放,所以自然在块以外就无法再访问到了。****
接着,我们来看第二个注意点:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-08-17_57b43160a6323.jpg)
注意以上代码截图中,用红色方框标记的两行代码。
我们写这段代码想要验证的是代码块的特性之一:不要在嵌套的两个代码块中声明相同命名的变量。
那么,第二个红框标注的代码恰恰印证了这一点,在方法代码块中声明了名为“method_block_var”的变量之后,如果再在其嵌套的局部代码块中声明,就会导致编译出错。
但让人在意的是,我们在类代码块中声明了一个“class_block_var”的变量,为何之后我们在其嵌套的方法代码块中,仍然可以声明相同命名的变量呢?
这实际上涉及到Java的内存机制,首先我们需要知道的就是:Java中声明在类代码块中的变量被称为该类的成员变量,而声明在方法或局部代码块中的变量被称为局部变量。
之所以造成这样的现象,究其根本是因为虚拟机内部的内存结构对于成员变量和局部变量的存储位置是不同的:
类的成员变量会随着类的对象一起,被存储在内存当中的堆内存当中;而局部变量则会随着方法的加载,而被存储到栈内存当中(方法的压栈)。
到了这里就不难理解了:
1.不同的两个班级:一班(堆内存)和二班(栈内存)中,都有一个名为“ 小明”的同学(变量)。这样的情况是没有任何问题的,因为你在调用时,可以通过“一班的小明”和“二班的小明”来正确的调用到目标学生。Java中也是这样的,在对成员变量进行调用时,实际上是隐式的调用了当前类对象关键字this。也就是说对成员变量的调用实际上是以:this.var的形式进行调用的,这就很好的与局部变量调用区分开了。
2.但同一个班级中(都位于栈内存)有两个相同名字的学生 ,那么再想要正确的调用目标学生就很难了,这会产生“调用的不确定性”。所以自然的,Java作为一门严谨的具有高度健壮性的语言,自然不会允许这样的“危险因素”存在。
**
**
**流程控制语句的使用**
话到这里,就来到了一个重要的部分:Java的程序流程控制语句的使用。
之所以使用流程控制语句,是因为一个程序的运行都是有序的,通常都是按照代码的书写顺序从上到下,从左到右进行执行。这一过程被称为顺序执行。
但实际开发中,我们通常都要根据实际需求,对程序的运行方式做一些目的性的改变,例如加一些条件判断等等。
于是,这个时候就必须用到流程控制语句,来进行程序流程的控制工作了。
Java中,主要的流程控制语句分为三种:选择/条件语句,循环语句,跳转语句。
**
**
**一、选择/条件语句**
顾名思义,就是指一段程序根据条件判断之后,根据判断结果再选择不同的处理方式的语句结构。
而必须铭记的是:条件语句的判断条件必须满足是boolean类型的数据。即:
条件只可以是一个boolean类型的值;是一个boolean类型的变量;或是一个返回boolean类型值的表达式。
**1.1、if条件语句.**
最简单的条件语句,作为条件分支语句,可以控制程序选择不同的处理方式执行。有三种表现形式:
第一种表现形式:if。
通常用于只有当满足某个条件,才具备程序继续执行的资格的情况。例如:判断一个人的年龄,只有判断结果为成年,才能继续执行相关代码:
~~~
private void if_Demo_1(int age) {
if (age >= 18) {
System.out.println("已成年,可以执行相关代码..");
//上网..
//饮酒..
}
}
~~~
第二种表现形式:if - else。
通常用于条件判断可能存在两种结果的情况.例如,判断一个人的性别,性别非男即女:
~~~
private void if_Demo_2(String gender) {
if (gender.equals("male")) {
System.out.println("男性");
} else {
System.out.println("女性");
}
}
~~~
第三种表现形式:if - else if - else。
通常用于当条件判断可能存在两种以上结果的情况。例如,判断两个数a与b的大小,则可能存在a大于b,b大于a或二者相等的三种情况:
~~~
private void if_Demo_2(int a, int b) {
if (a > b) {
System.out.println(a + "大于" + b);
} else if (a < b) {
System.out.println(b + "大于" + a);
} else {
System.out.println(a + "等于" + b);
}
}
~~~
最后顺带一提的就是,某些情况下会存在:仅仅通过一次条件判断,还无法选择正确的处理方式的情况。这时就可以通过对if语句嵌套,完成多次判断:
~~~
/*
* 一家俱乐部,只针对年满18岁的女性营业
*/
private void if_Demo_4(String gender, int age) {
if (gender.equals("male")) {
if (age >= 18) {
System.out.println("欢迎观临.");
} else {
System.out.println("对不起,您未满18岁!");
}
} else {
System.out.println("对不起,我们只针对女性营业!");
}
}
~~~
**1.2、switch条件/选择语句**
我们注意到在if语句的使用中,如果当条件判断存在多种结果的时候,则必须使用“if - else if - else”的方式来处理。
那自然我们可以想象如果当一个条件存在大量的可能结果时,我们可能就必须书写大量的else if语句,这样做可能会比较麻烦。
针对于这样的情况,Java提供了另一种相对简单一些的形式,就是switch条件语句。对于switch语句,值得注意的是:
其接受的条件只能是一个整型数据或枚举常量,只是在JDK1.7之后又新支持了String类型。
而同时我们知道Java中支持数据类型转换,而byte,short,char都可以被隐式的自动转换为int。
所以通常来说:switch语句所能接受的条件只能是byte,short,int,char或枚举类型的数据。
正是因为如此,所以switch语句相对于if语句而言,本身存在一定的局限性。
通常来说,一个switch语句的定义格式为:
~~~
switch (key) {
case value:
break;
default:
break;
}
~~~
举例而言,假设我们通过用户输入的一个整数,来查询对应的星期数:
~~~
private void switch_demo(int num) {
switch (num) {
case 1:
System.out.println("星期一");
break;
case 2:
System.out.println("星期二");
break;
case 3:
System.out.println("星期三");
break;
case 4:
System.out.println("星期四");
break;
case 5:
System.out.println("星期五");
break;
case 6:
System.out.println("星期六");
break;
case 7:
System.out.println("星期日");
break;
default:
System.out.println("没有对应的星期数");
break;
}
}
~~~
swtich语句的执行过程是这样的,首先计算条件表达式的值,然后根据值分别对每个case进行匹配工作。
假如找到对应的匹配,则执行该case值下的程序语句;如果没有匹配的case值,则执行default下的程序语句。
在执行完case的语句块后,应当使用跳转语句break语句跳出该switch语句。
因为如果没有添加break语句,程序在执行完匹配的case值下的程序语句后,
并不会停止,而是将会连续执行下一个case值下的代码,直到碰到break语句为止。
**多重if和switch的区别**
我们已经知道在某些情况下,多重if和switch是可以完成相同的目的的。而它们最大的区别就在于:
switch语句局限性更大,这是因为我们说过了:switch语句只能对类型为byte,short,int,long或枚举的数据的具体值进行判断。
但if既可以对具体的值进行判断;也可以进行区间判断;同时还可以对返回值类型为boolean类型的表达式进行判断,如:
~~~
public void ifTest(int a, char c,String s) {
//对具体的值进行判断
if(a == 5);
if(c == 'a');
if(s.equals("s"));
//对区间进行判断
if(a>5&&a<=10);
//对返回值为boolean类型的表达式进行判断
if(a>=c);
}
~~~
而网上很多人说,switch语句的效率相对高于if语句。这是因为:switch语句会将所有可能情况的代码语句一次性都加载进内存,所以在执行时效率相对较高。
但因为其本身的局限性,所以在实际开发中,还是因该根据实际需求选择最合适的做法。
**二、循环语句**
顾名思义,循环语句也就是指用于控制同一段程序反复多次运行的语句。Java中循环语句有三种,分别为:while、do-while以及for循环。
可以说,三种循环语句之间的区别实际不大,但同时也可以说都有本质的区别。下面我们分别来看一下三种循环的原理和使用。
**2.1、while循环语句**
~~~
/*
* while循环语句的定义格式为:
* while(条件){
* //循环内容..
* }
*/
private void while_demo(int a){
while(a < 20){
System.out.println(++a);
}
}
~~~
与if条件语句相同,while语句也会接受一个boolean类型的值作为条件。
当该条件判断为true时,则会循环执行循环体的内容;当执行到条件判断结果为false时,就会结束循环。
**2.2、do-while循环语句**
~~~
/*
* do-while循环语句的定义格式为:
* do {
//循环体
} while (condition);
*/
private void do_while_demo(int a){
do {
System.out.println("执行一次循环:"+(++a));
} while (a<20);
}
~~~
do-while循环与while循环最大的不同就是:无论循环条件的判断结果是否为true,都会至少执行一次循环体中的内容。
之所以Java会单独提供do-while这种循环方式,也正是因为:当使用while循环的时候,如果首次判断循环条件的结果就为假的话,那么该循环就会直接被跳过,根本不会执行。而事实上,我们很多时候会希望循环体至少执行一次。
**2.3、for循环语句**
~~~
/*
* for循环的定义格式为:
* for(初始化表达式;循环条件表达式;迭代运算)
* {
* 执行语句;(循环体)
* }
*/
private void for_demo(){
for (int i = 0; i < 20; i++) {
System.out.println(++i);
}
}
~~~
for循环的执行过程为:
1.执行循环的初始化,通过它设置循环的控制变量值
2.判断循环条件:如果为真,则执行循环体内容;如果为假,则退出循环;
3.执行迭代运算。迭代运算通常是一个表达式,用于改变循环控制变量的值。
4.再次执行循环条件判断,然后反复第2-3步的步骤,直到循环条件判断为假时,则退出循环。
需要注意的是:for循环的初始化表达式,只会在循环第一次开始时执行一次;
而迭代运算表达式则会在每一次循环体内容执行完毕后,紧接着执行一次。
可以通过一道网上流传的华为的Java面试题,来更形象的了解for循环的执行特点:
~~~
/*
What is the result?
A. ABDCBDCB
B. ABCDABCD
C. Compilation fails.
D. An exception is thrown at runtime.
*/
public class Demo {
static boolean foo(char c) {
System.out.print(c);
return true;
}
public static void main(String[] args) {
int i = 0;
//ABDCBDCB
for (foo('A'); foo('B') && (i < 2); foo('C')) {
i++;
foo('D');
}
}
}
~~~
最终执行的结果为:ABDCBDCB,我们来逐步分解该输出结果形成的原因:
1.for循环第一次开始执行,首先执行循环初始化表达式,于是foo('A')被执行,输出A。此时输出结果为:A
2.紧接着开始执行循环条件判断,于是foo('B')被执行,输出B;接着判断i(0)<2;循环条件判断结果为真。此时输出结果为:AB
3.由于该次判断结果为真,于是开始执行循环体。于是,i自增运算,值变为1;foo('D')被执行,输出D。此时输出结果为:ABD
4.一次循环体内容执行完毕,紧接着开始执行迭代运算表达式:foo('C'),于是输出C。此时输出结果为:ABDC
5.再次开始新一次的循环条件判断,于是foo('B')被执行,输出B;接着判断i(1)<2;循环条件判断结果为真。此时输出结果为:ABDCB
6.再次开始执行循环体。于是i继续自增运算,值变为2;foo('D')被执行,输出D。此时输出结果为:ABDCBD
7.循环体内容又一次执行完毕,同样紧接着开始执行迭代运算表达式:foo('C'),于是再次输出C。此时输出结果为:ABDCBDC
8.进行新一轮的循环条件判断。于是foo('B')被执行,又一次输出B;接着判断i(2)<2,此次循环条件判断结果为假,于是循环到此退出。
所以,最终该程序的运行结果为:ABDCBDCB
**while循环与for循环的异同**
while循环与for循环之间实际上是可以相互替换的。以常见的数的累加的问题来说,以while循环和for循环分别的实现形式为:
~~~
/*
* 求1到100之间的数累加的和?
*/
public class Demo {
private static int for_demo() {
int sum = 0;
for (int i = 1; i <= 100; i++) {
sum += i;
}
return sum;
}
private static int while_demo() {
int sum = 0;
int i = 0;
while (i <= 100) {
sum = sum + (i++);
}
return sum;
}
public static void main(String[] args) {
System.out.println("通过for循环完成累加的值为:" + for_demo());
System.out.println("通过while循环完成累加的值为:" + while_demo());
}
}
~~~
而while和for之间唯一的小差就在于:循环控制变量的作用域。这实际上正是涉及到在这篇博客中提到的代码块的相关知识。从上面的用例代码中,我们看到:
方法“for_demo”中,用于控制for循环的循环控制变量“i”,被声明在for循环语句内。所以这里的变量“i”实际上,是被声明在for循环语句块当中的局部变量,所以随着for循环语句块的运行结束,该变量就会从内存中释放,走到生命周期的尽头。
反观方法“while_demo”中,用于控制while循环的循环控制变量"i"则只能被声明在属于"while_demo"的方法代码块中,而不属于循环本身。也就是说,就算当while循环运行结束,该循环控制变量依然有效,仍然可以被访问,因为它实际是属于“while_demo”所声明的代码块中。
这也正是我在这个专栏系列里,第一篇文章[《第一个专栏《重走J2SE之路》,你是否和我有一样的困扰? 》](http://blog.csdn.net/ghost_programmer/article/details/42491083)中提到的:
为什么查看一些Java的源码时,发现老外很多时候选择使用for循环的原因,也正是因为for循环相对于while循环,可以在很小程度上减少内存的开销,
**三、跳转语句**
跳转语句是指打破程序的正常运行,跳转到其他部分的语句。Java提供了三种跳转语句,分别为:break、continue以及return。
**3.1、break语句**
break语句的使用方式主要三种:跳出switch条件语句、跳出循环以及通过代码块的标示符跳出代码块。我们通过一段代码分别看一下它们具体的应用:
~~~
void break_demo(int a) {
// 跳出switch条件语句
switch (a) {
case 1:
System.out.println();
break;
default:
break;
}
// 跳出循环
while (true) {
if (++a > 50) {
break;
}
}
// 跳出代码块
my_block_1: {
my_block_2: {
a++;
if (a > 5) {
break my_block_1;
}
}
}
}
~~~
值得注意的是:在使用break跳出循环时,只会跳出其所在的循环,而其外部的循环并不会跳出,还会继续运行。
**3.2、continue语句**
break语句可以用于跳出其所在循环。但是有时我们需要跳出一次循环剩余的循环部分,但同时还要继续下一次循环,这时就用到了continue语句。
~~~
void continue_demo(){
for (int i = 1; i <= 30; i++) {
System.out.print(i+"\t");
if(i%5!=0)
continue;
System.out.println("*");
}
}
~~~
这段代码运行的输出结果为:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-08-17_57b43160bd5b8.jpg)
这正是continue语句起到的作用,每执行一次循环体的内容。
首先会输出“i”当前的值及一个制表符,然后会判断当前“i”是否是5的倍数。
如果是不是5的倍数,则会通过continue语句结束该次剩余的部分,所以在其之后的输出语句便不会再被执行。
而如果是5的倍数的话,才会执行之后的输出语句,输出一个“*”号,并换行。
由此,最终才出现了上面我们看到的输出效果。
所以,总的来说break与continue的区别就在于:break语句用于退出整个循环;而continue语句则是用于结束该次循环的剩余循环部分,但继续新一次的循环。
**
**
**3.3、return语句**
简单的来说,如果说break用于跳出循环的话,而return语句则是用于结束整个方法并返回。
return语句可以说是实际开发中最常用的跳转语句了,因为任何方法都需要return语句来控制方法的结束并返回对应类型的值。
~~~
void return_demo_1() {
/*
* 实际上void返回类型的值,也使用了return。 只不过在以void作为返回类型的方法中,return是隐式的存在的。
*/
}
int return_demo_2() {
/*
* 通过return
*/
for (int i = 0; i < 5; i++) {
i++;
for (int j = 0; j < 20; j++) {
if (j == 3) {
/*
* 这里如果使用break,虽然内部循环会被退出,但外部循环仍然会继续执行
* 而使用return则意味结束整个方法,并返回值“5”,
* 这样做代表这之后的代表将永远没有机会再运行。
*/
return 5;
}
}
// 该语句永远不会被执行到。
System.out.println("外部循环执行一次..");
}
return 0;
}
~~~
到了这里,Java中程序语句的使用,我们已经有了一个不错的了解了。最后,就通过一个实际应用的小例子,输出九九乘法表作为结束吧:
~~~
package com.tsr.j2seoverstudy.base;
public class Print99 {
public static void main(String[] args) {
System.out.println("*****************九九乘法表****************");
for (int i = 1; i <= 9; i++) {
System.out.print("\t"+i);
}
System.out.println();
for (int i = 1; i <= 9; i++) {
System.out.print(i+"\t");
for (int j = 1; j <= i; j++) {
System.out.print(i*j+"\t");
}
System.out.println();
}
}
}
~~~
运行程序,查看其输出结果:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-08-17_57b4319cd04fb.jpg)
';
磨刀不误砍材工 – Java的基础语言要素(运算符和表达式的应用)
最后更新于:2022-04-01 20:08:31
如同前面我们已经总结过的标示符、关键字、注释一样,**运算符和表达式**也是Java的基础语言要素和一个Java程序的重要组成部分**。**
这是因为任何程序通常都会涉及到对数据的运算,因为所谓的编程工作,实际也就是将现实生活中的一系列复杂问题,抽象出来编写成为程序,方便更加容易的处理的过程。所以正如同我们在日常生活中也会涉及到一系列类似加减乘除的运算一样,一个程序也离不开“运算”。
**运算符指明对操作数的运算方式**,就如同数学中用“+”完成对操作数的加法运算一样,Java也与其类似,不同的是Java中运算的方式要比数学中多。
了解了运算符的基本概念之后,就不得不提及到“表达式”。简单来说,**表达式可以认为是数据和运算符的结合**。
二者实际可以认为是相辅相成的,因为就如同在数学运算中一样,如果我们对两个数进行加法运算,实际我们的目的是想要得到这两个数相加后的值。
这个时候我们自然就需要一个方式来表达这个值,而这个“表达方式”正是所谓的表达式。
举个简单的例子来说,在数学中一个一元方程式求两个数的和,被描述为:x = 3+5;
在这个一元方程式中,"+"就是执行数学加法运算的运算符,而整个方程“x = 3+5”则是运算的表达式。
这个原理在Java中是一样的,而x是我们定义的一个int型的变量。
废话少说,我们已经了解了Java中提供了很多不同功用的运算符。
那么,接下来就让我们通过这些运算符的功能对其进行分类,从而分别了解它们的功能及使用。
总的来讲,Java中的运算符可以被分为如下几个大类:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-08-17_57b43160669d1.jpg)
除此之外,还有一个最最常用和特别的运算符“=”,这个运算符被称为赋值运算符。我们知道了表达式是数据和运算符的结合,所以,赋值运算符就是将它们结合起来的桥梁。
接着,就分别来看一看这些不同的运算符的功能及使用。
**一、算术运算符的使用:**(算术运算符实际是最易理解的,其原理都和数学中算数运算是相同的)
~~~
//算数运算符的应用
private void arithmeticOperatorDemo(int a,int b){
int result = 0;
//加法运算符:+。
result = a + b;
//减法运算符: -。
result = a - b;
//乘法运算符: *。
result = a * b;
//除法运算符:/。
result = a / b;
//求余运算符:%。
result = a % b;
}
~~~
需要注意的是:使用除法运算符对两个整数做除法运算时,运算的结果同样会被提升为整数,也就是说小数点后的数字会被忽略。
例如,我们对两个整数5和2进行除法运算,得到其运算结果。那么:
表达式:int a = 5/2;的运算结果为2。
表达式:double a = 5/2;的运算结果为2.0。
如果想要得到完整的运算结果2.5,那么被用作除法运算的数也必须使用浮点数的形式:double a = 5.0/2.0。
**二、自增自减运算符的使用:**
首先,顾名思义,自增自减运算,也就是指一个操作数对自身的值进行增加或减少的运算。简单的来说例如我们有一个整形变量“a”,
那么,实际上自增运算:a++的运算效果就等同于a = a + 1;
这样的应用实际上是很简单的,而需要我们注意的是:自增自减运算符有作为“前缀”和“后缀”的两种不同的使用方式,也就是说可以使用“a++”或者“++a”两种方式对一个数进行自增自减运算。那么,这两种方式的区别在于什么?看一段代码:
~~~
private static void selfArithmeticOperatorDemo() {
int a = 0;
int b = 0;
int sum_1 = 5 + (a++);
int sum_2 = 5 + (++b);
System.out.println("a="+a+",b="+b);
System.out.println("sum_1="+sum_1+",sum_2="+sum_2);
}
~~~
这段代码运行的打印结果为:
a=1,b=1
sum_1=5,sum_2=6
让我们分析一下上面的代码:
首先,我们定义了两个值同样为0的整形变量“a”与“b”;
之后,我们分别对“a”与“b”进行了自增运算,并让其结果与同一个数5进行加法运算。
不同的是,变量“a”我们使用“后缀”自增运算方式,变量“b”我们使用“前缀”的方式。
同时,我们定义了另外两个整型变量sum_1与sum_2分别存放两次运算的结果。
通过程序的输出结果我们发现:
1.“a”与“b”经过自增运算之后,值都由初始值0增长为了1。
2.然而变量a执行自增并与5进行加法运算后得到的结果为5,而变量b执行自增并与5进行加法运算后得到的结果为6.
这也正是自增自减运算符作为“前缀”和“后缀”两种方式使用的不同效果。
我们简单总结来说,自增自减运算符的使用可以分为两种情况:
- 如果我们仅仅是对一个变量自身进行单纯的自增自减运算,使用“前缀”和“后缀”前两种方式达到的效果实际上是相同的。
- 但是如果在将自增自减运算和表达式结合使用的话,二者就有了一定的不同。通常来说,我们会这样归纳其不同之处:当自增自减运算符作为前缀结合表达式使用时,会先执行变量的自增自减运算,再执行表达式;而如果作为后缀,则将先执行表达式,然后再进行自增自减运算。
我们当然可以这样理解“前缀”和“后缀”的不同。但实际上这两种使用方式在内存中的运算过程究竟是怎么样的呢?
随着我们的深入学习,就应当明白:并不是说,自增自减运算符作为前缀,则先执行自增自减运算;而作为后缀,则先执行表达式运算。
例如现有一个值等于1的整形变量num。那么,以表达式int a = num++;和int a = ++num为例,其在内存中的运算过程实际上是:
- 当使用“num++”的方式时:在整个运算过程最初,JVM会将num的初始值“1”取到,然后在内存中开辟一块区域作为“预存区”,并将“1”存储到该区域内。而紧接着,就会对num进行自增运算得到新的值“2”。所以,在这个时候num在内存中的值实际上已经由“1”变成了“2”。当完成这个运算过程后,JVM则会在“预存区”中将事先存放的num初始值“1”取出,参与整个表达式的运算,将“1”赋值给变量a,所以这时得到的“a”的值为1。
- 而当使用“++num”的方式时,JVM则会直接将num的初始值“1”取到,进行自增运算。然后将自增运算后得到的值“2”,参与到整个表达式运算当中,将该值赋值给变量“a”。所以,通过这种方式,变量“a”的值为2。
**三、关系运算符的使用:**
所谓“关系运算符”,自然是指:用于判断两个变量之间的关系的运算符。而在Java中,所有的关系运算符的比较结果,都返回为一个boolean类型的数据。也就是说比较结果非真即假。同样通过一段简单的代码来看一下Java中各个关系运算符的使用:
~~~
private static void relationshipOperatorDemo(int a,int b) {
boolean result = false;
// == ,比较两个变量是否相同,相等返回true,不等返回 false
result = a == b;
// != ,比较两个变量是否不同
result = a != b;
// > ,比较一个变量是否大于另一个变量
result = a > b;
// < ,比较一个变量是否小于另一个变量
result = a < b;
// >=,比较一个变量是否大于或等于另一个变量
result = a >= b;
// <=,比较一个变量是否小于或等于另一个变量
result = a <= b;
}
~~~
**四、位运算符的使用:**
在计算机中,所有的整数都是通过二进制进行保存的,也就是一串由“0”和“1”组成的数字,每一个数字占一个比特位,8个比特位就被称为一个字节。
那么顾名思义,位运算符就是指对一个数进行按比特位的运算。而按位运算最大的好处就在于:
位运算是直接被cpu所支持的,所以其运算速度与效率上就远远高于其它运算方式。
Java中提供了以下4种位运算符:
- 按位与运算符 & :如果对应位的值都为1,则运算结果为1,否则则0.
- 按位或运算符 | :如果对应位的值都为0,则运算结果为0,否则为1.
- 按位异或运算符 ^:如果对应位的值相同 ,则运算结果为0,否则为1.
- 按位取反运算符 ~:将操作数的每一位按位取反,也就是:0变1,1变0.
~~~
private static void bitOperatorDemo(){
System.out.println("5与8进行按位与运算的结果是:"+(5&8));
System.out.println("5与8进行按位或运算的结果是:"+(5|8));
System.out.println("5与8进行按位异或运算的结果是:"+(5^8));
System.out.println("5进行按位取反运算的结果是:"+ (~5));
}
~~~
这一段测试代码的输出结果为:
5与8进行按位与运算的结果是:0
5与8进行按位或运算的结果是:13
5与8进行按位异或运算的结果是:13
5进行按位取反运算的结果是:-6
为了验证其输出结果,首先我们将两个整型数5和8还原为二进制形式,值分别为:0101和1000。接着,就让我们根据各个位运算符的运算原理进行一次验证:
1.与运算:0101和1000按照与运算的运算原理,得到的运算结果为:0000,也就是十进制当中的0
附:与运算的特性:两个数相与,只要有一个数为0,那么结果就为0。
2.或运算:0101和1000按照或运算的运算原理,得到的运算结果为:1101,转换为十进制的值也就是:13
3.异或运算:0101和1000按照异或运算的运算原理,得到的运算结果为:1101,同样也就是十进制的13.
附:对于异或运算的应用,值得一提的是,异或运算有一个特性:一个数异或运算同一个数两次,得到的结果还是这个数。
就以0101和1000为例,0101异或1000运算一次的结果是1101,1101再与1000进行异或运算,得到的结果为0101.也就是说0101^1000^1000 = 5^8^8 =5。
这实际上也是一种最基础的加密解密的应用方式,例如你的数据为5,与8进行异或后,得到的结果是13,13再与8进行异或运算后,得到的结果为5。
这个过程中,5是原始数据,13是加密后的数据,而8则是密匙。
4.取反运算:这是值得一提的运算方式,0101按位取反得到1010,你可能会想,这不就是十进制当中的10吗?会什么取反运算后变成了-6呢。
这是因为在Java中的,int型的数据在内存中实际长度32位,也就是说5在Java内存中的完整表现形式为:
0000 0000 - 0000 0000 - 0000 0000 - 0000 0101,所以在取反运算后,其值变为了:
1111 1111 - 1111 1111 - 1111 1111 - 1111 1010。而该二进制数正对应于十进制当中的-6.
值得一提的是,在计算机中:
0000 0000 - 0000 0000 - 0000 0000 - 0000 0101这样一个数的绝对值转换为的二进制数,被称为原码。
1111 1111 - 1111 1111 - 1111 1111 - 1111 1010这样一个对原码进行按位取反运算得到的二进制数,则被称为反码。
而对一个数的反码加1,运算后得到的二进制数则被称为补码。
所以,通过位运算符“~”对一个数进行取反运算,实际上正是在获取这个数的反码。
到此,我们观察发现:我们对5进行取反运算后,获取的反码对应的正是十进制当中的-6.
如果我们对-6加1,得到的结果正是:-5.而-5则正是5的负数形式。
这也正是为什么说,在计算机中,一个负数的二进制表现形式是由其补码表示的。
**五、移位运算符的使用:**
与位运算符类似,移位运算符则是指对一个数的二进制表现形式按指定位数进行移位的运算。
Java中提供了三种移位运算符:
- 左移运算符“<<”:将操作数的比特位向左移动指定位数,移位后右边空缺的位用0填补。
- 右移运算符“>>”:将操作数的比特位向右移动指定位数,移动后用于表示符号的最高位按原有的符号位填补,也就是说如果原本符号位为0,则移动后填补0,为1则补1
- 无符号右移运算符">>>":与">>"的移位运算规则相同,不同之处在于:无论原有最高位为1还是0,填补时都补0(也就是说无论正数还是负数,移位后都将变为正数)
那么,假如我们就以“-6”(1111 1111 - 1111 1111 - 1111 1111 - 1111 1010)为例:
如果对其进行左移3位运算得到的结果为:1111 1111 - 1111 1111 - 1111 1111 - 1101 0000,对应于十进制中的-48.
如果对其进行右移3位运算得到的结果为:1111 1111 - 1111 1111 - 1111 1111 - 1111 1111,对应于十进制中的-1
如果对其进行无符号右移3位运算得到的结果为:0001 1111 - 1111 1111 - 1111 1111 - 1111 1111,对应于十进制中的536870911
然后,我们用一段代码对其加以验证:
~~~
private static void bitMoveOperatorDemo(){
System.out.println((-6)<<3);
System.out.println((-6)>>3);
System.out.println((-6)>>>3);
}
~~~
运行发现其输出结果正是:
-48
-1
536870911
而提到移位运算,想起曾经看到过类似这样的一道面试题:用最有效率的方法算出2乘以8的结果。
其实解题思路很简单,我们注意到两个关键字:效率和运算。当涉及到操作数的运算,且要求保证最快的效率时,首先应该想到的,就是位运算。
而我们在二进制数中,可以发现这样一个规律:一个数向左移n位,就相当于乘了2的n次方;同理,向右移n位,则相当于除以2的n次方。
然后,我们再看这道面试题发现,8正好是2的3次方,也就是说,我们将2进行左移运算3位,则等于乘以2的3次方8.
所以这道题的答案就是:int result = 2<<3;
之所以这样做最有效率,我们已经说过,是因为位运算是直接被cpu支持的,所以不需要做任何额外操作。
**六、逻辑运算符的使用:**
通俗的说,逻辑运算符用于对多个表达式进行条件判断,与关系运算符相同,其返回结果也为boolean。我们分别来看一下其判断规则:
&:非短路逻辑与运算符,当判断的两个条件都为真时,则判断结果为真;否则只要有一个条件为假,则返回假。
|:非短路逻辑或运算符,用于判断的条件中只要有一个为真,则为真;当判断条件结果全为假,则返回假。
!:对条件判断结果取反,例如:boolean b = !(5>2) 的返回结果为false。
&&:短路与运算符,与&的判断规则大致相同,不同的是:&&只要判断到有一个条件的判断结果为假,则会立即“短路”,即刻返回判断结果为假。而&无论结果,都会一次将所有条件判断完后,才会返回最终的判断结果。
||:短路或运算符,与|的判断规则大致相同,不同的是仍是:||只要判断到有任一一个条件为真,则会立即“短路”,返回判断结果为真。
我们仍然通过一个简单的例子来看一下非短路逻辑运算符合短路运算符之间的区别:
~~~
private static void logicOperatorDemo() {
int a = 5;
int b = 5;
if ((++a) > 5 | (++b) > 5) {
System.out.println("非短路逻辑或运算:");
}
System.out.println("a:" + a);
System.out.println("b:" + b);
a = 5;
b = 5;
if ((++a) > 5 || (++b) > 5) {
System.out.println("短路逻辑或运算");
}
System.out.println("a:" + a);
System.out.println("b:" + b);
}
~~~
这段程序的输出结果为:
非短路逻辑或运算:
a:6
b:6
短路逻辑或运算
a:6
b:5
这正是体现了非短路逻辑运算符与短路逻辑运算符的区别:
当我们使用非短路逻辑或运算的时候:会依次将判断条件执行完毕后,再返回判断结果,所以(++a) > 5 | (++b) > 5会执行完6>5|6>5,返回结果true。执行后a与b的值都为6。
而当我们使用短路逻辑或运算的时候:只要判断到有任何一个条件结果为true,则会马上返回true,所以(++a) > 5 | (++b) > 5执行到(++a)=6 > 5后,就已经返回了判断结果为true。所以后面的(++b) > 5根本没有执行,自然最后得到的结果就是a=6,而b=5了。
**
**
**七、三元运算符的使用**:
简单的来说,三元运算符就是对类似于下面的一种代码的一种简化书写方式:
~~~
private static void demo(int a){
int num = 0;
if(a>0){
num =1;
}else{
num =2;
}
}
~~~
通过三元运算符,我们可以将其简化为:
~~~
private static void demo(int a) {
int num = a > 0 ? 1 : 2;
}
~~~
到这里,对于Java当中各个运算符的使用的总结就告一段落了,更多的使用方法可以自己在实际操作中加以体会、巩固和深入。
';
磨刀不误砍材工 – Java的基础语言要素(从变量/常量切入,看8种基本数据类型)
最后更新于:2022-04-01 20:08:28
变量与常量是一个Java程序组成的重要部分。
我们可以将变量与常量理解为数据的载体,而从名称上我们也可以看出二者的不同:
常量代表不能改变的数据值,而变量的值则存在可变性。
在我们回顾Java中的关键字的使用时,说道:被Java中的关键字final所修饰的变量,其值一经初始化,便不能再次进行赋值。该特性恰恰符合常量的定义。
~~~
String var = "字符串变量";
//java中,关键字final用于声明数据常量
final String CONSTANT = "字符串常量";
~~~
既然我们知道了变量与常量是作为数据的载体使用;那么,就如同我们如果使用一个杯子作为载体,那么其搭载的介质可能是水,咖啡,果汁等等一样;
我们自然要了解Java中的变量与常量作为数据载体,其搭载的数据究竟有哪些?
大体来说,Java的数据类型分为:基本数据类型,对象数据类型以及数组,但数组实际也属于对象。
所以,Java中的变量/常量就是用以作为基本数据类型和对象数据类型的载体的。
对象数据类型的回顾需要结合一个关键的概念:类。
所以在这里,我们先将变量和常量作为切入点,首先来重新系统的总结一下Java中的8种基本数据类型的特性及使用。
总的来说,Java中的8种数据类型可以分为三类:数字类型,字符类型和一种特殊的数据类型布尔型。
**一、数字数据类型**
数字数据类型共有6种,其中4种用于表示整数,2种用于表示浮点数。
首先需要明确的是:Java中的6种数字类型都是有符号数,也就是说它们都有正负之分。而具体又是如何区别表示正数与负数的呢?
我们知道所谓的"1","3","101"这样的数,是我们日常生活中习惯使用的十进制数。
但在计算机中,所有数字都是以二进制数来表示的,也就是说,是一串由“0”和"1"组成的数字。
这样的一串数字中,其最高有效位是用于表示符号的,就是所谓的符号位。
符号位为“0”,代表是一个正数;符号位为“1”,代表是一个负数。而剩余位则用于表示值。
**1.1、整数类型**
Java中,用于表示整数的4种数字类型分别为:byte(字节型)、short(短整型)、int(整型)、long(长整形)
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-08-17_57b431601660e.jpg)
我们说在计算机中,数字都是由二进制形式表示的,那么自然的,其位数越多,可能的取值范围就越大。
所以我们看到从byte到long,随着所占位数的增加,其取值范围也就越大。
计算机中,一个字节等于8个比特位。而byte长度正是8位,这也是为什么它被取名为字节型;而剩下的short,int,long分别对应2个字节,4个字节和8个字节。
**进制转换:**
我们刚刚已经说到了二进制和十进制,而在Java中,整数还有另外两种进制表现形式,分别是:八进制和十六进制。
在了解进制转换之前,我们先通过一段简单的代码了解一下Java中八进制和十六进制数的声明形式是怎么样的:
~~~
// 十进制定义形式
int num_10 = 10;
// 八进制定义形式,以“0”作为前缀,表示定义的是一个八进制整数
int num_8 = 012;
// 十六进制定义形式,以“0x”作为前缀,表示定义的是一个十六进制整数
int num_16 = 0xef;
~~~
了解了不同进制的定义形式,我们就可以看一下进制之间的相互转换了。首先我们应该知道,所谓进制,其实原理都是一样的:
所谓二进制,就是指”逢二进一“,也就是说二进制中只可能存在”0“和”1“两种可能值;而所谓十进制,就是指”逢十进一“,也就是说只可能存在0-9的可能值。
那么同样的,八进制就是指”逢八进一“,所以只可能存在0-7的可能值;同理的,十六进制就存在0-15的可能值,但传统定义数字中,”9“已经是单位的最大可能值,所以十六进制中以英文字母a - f分别代表 10 - 15。
那么,进制之间究竟是如何完成相互之间的转换工作的呢?
**1、二进制数、八进制数、十六进制数转十进制数**
有一个公式:二进制数、八进制数、十六进制数的各位数字分别乖以各自的**基数**的(N-1)次方,其和相加之和便是相应的十进制数。例如:
二进制数:0000-0110转换为十进制数:1*2的2次方+1*2的1次方+0*2的0次方=0+4+2+0=6,也就是说转换为十进制数的值为:6。
**2、十进制数转二进制数、八进制数、十六进制数**
方法是对应的的,即整数部分用除**基**取余的算法,小数部分用乘基取整的方法,然后将整数与小数部分拼接成一个数作为转换的最后结果。
**3、二进制数转换为八进制数或十六进制数**
其原理很简单:我们已经知道了八进制只有0-7的可能值,十六进制则只有0-15这的可能值。
而我们观察到这样一种情况:对于一个二进制的数,如果只取一个有效位的数,所能能表达的最大数为:1;而取两个有效位的数,所能表达的最大值则为:“11”,也就是十进制的3;取三个有效位的数,能表达的最大的数为”111“,则是十进制的7;而取四个有效位的数“1111”,则正是十进制的15.
由此我们发现:如果将二进制数每三位取出,则正好能表示一个八进制的数;而将二进制数每四位取出,则正好能表示一个十六进制数。
而事实上,二进制与八进制和十六进制的转换原理也正是这样的。举例来说:
以二进制数:0000-1010为例,转换为八进制数为:000/001/010,也就是12;而如果转换为十六进制则为:0000/1010,也就是a。
**1.2、浮点数类型**
正如数学中数字分为整数和小数一样,Java中也是一样的。但Java不称为小数,称为浮点数。
而Java中,用于表示浮点数的两种种数字类型分别为:float(单精度浮点型)和double(双精度浮点型)。
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-08-17_57b43160314db.jpg)
Java里默认的浮点数形式是双精度形式,也就是double。所以在定义一个float时,必须加上后缀F(f):float f = 2.3F。而定义double,后缀D(d)的添加则是可选的。
到了这里,我们已经了解了Java中所有的数字类型。
之所以了解它们各自不同的特性,是为了在实际编写代码的过程中,可以根据实际需求选取最合适的数据类型来定义自己的变量(常量)。举例来说:
如果想要表示全世界的人口数量,那么可能选择long型来表示更为合适;而如果要表示某个公司的职员每月的工资情况,那么选用float可能更为合适。
**三、字符类型**
Java中,另一种基本数据类型:char型,代表字符类型。
在开发中,可能经常需要存储一些字符,如‘A’,‘c’等等。char型就是用于存储单个字符的数据类型。
同时,char型数据也可以通过Unicode码表示字符;简单的来说,就是我们也可以通过在Unicode码表中有效的整数来表示一个字符。
其实很好理解,就像我们在小时候学习拼音的时候,可能都会接触到“拼音字母学习表”一样:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-08-17_57b431604c339.jpg)
Unicode码表也是类似于这样的一张字符编码表,所谓编码就是对表中的每一个字符编排一个“号码”。
这个号码就像我们每个人的身份证,是独特对应的关系,通过身份证号码就可以查出我们每个人的信息。
到了这里就不难理解了,就假设:我们以97表示一个字符,Java会根据该值到Unicode码表中进行查询,然后发现号码“97”对应的字符是"a"。
Unicode码是用'\uxxxx'表示的。x表示的是16进制数值。并且Unicode编码字符是用16位无符号整数表示的。也就是说,Unicode码表共有0-65535的编码值。
**四、布尔类型**
Java中一种特殊的数据类型,只有“true”和”false“两种可能值。通常用于关系运算或条件判断表达式返回的结果。
**数据类型的转换**
Java自身是一门强数据类型语言,所以在遇到不同数据类型同时操作,就需要进行数据类型的转换。
数据类型转换需要满足的最基本的要求,就是数据类型必须兼容。例如要将一个布尔型的数据转换为整数类型,肯定是不能成功的。
而在Java中,数据类型的转换又分为两种:自动类型转换和强制类型转换。
所谓自动转换就是指无需认为做任何额外的工作,虚拟机会自动的完成对数据类型的转换工作。
而强制转换则是指我们必须人为的进行声明后,才能完成想要的数据类型转换。
也就是说,自动数据类型转换是隐式的转换,而强制类型转换则是显式的转换。
那么首先来看一下**自动类型转换**:
第一种情况:低位数的数据类型可以自动转换为高位数的数据类型。
~~~
// 低位数的数据类型自动转换为高位数的数据类型
byte b = 1;
short s = b;
int i = s;
long l = i;
float f = 1.5f;
double d = f;
/*
* 另外,Java中整数的默认形式为int型,
* 所以下面的声明形式实际也是:
* 虚拟机自动完成了一次隐式的数据转换工作
*/
long num = 1000;
~~~
第二种情况:整数类型可以自动转为浮点数类型,但是这种转换后的值可能会出现误差。
第三种情况:字符类型可以自动转换为整型或长整形。这是因为Java中char型数据也可以通过Unicode码表示,长度为16位,所以也可以转换为长度更大的int和long型。
~~~
char c1 = 'a';
int i1= c1;
char c2 = 'b';
int i2 = c2 + 10;
char c3 = 'c';
long l = c3;
~~~
接下来,就是Java中的**强制类型转换**:强制转换的格式为:(type)value。
第一种情况:对应于自动转换,那么当高位数的数据类型转换为低位数的数据类型时,就需要做强制转换。
既然我们看到了”强制",那可能我们自然就会想到在这样的转换过程中,是不是存在一定的“风险”?
Java自身是一门严谨的编程语言,如果不存在风险,为何还需要我们作人为的"强制“性转换呢?
而事实上也正是如此。我们首先应当了解,Java中对一个高位数数据转换为低位数数据类型时,实际上是在对二进制表现形式做有效位的截取。
我们知道一个二进制数的位数越多,其取值范围也就越大,也就是说它的可能值越多。
这也就意味着,如果将一个高位数的数据类型转换为低位数的数据类型,那么便可能发生:很多高位数能够表达的可能值,低位数表达不了的情况。
这也正是其”风险“所在:转换的过程中,可能造成数据丢失!
我们举个例子来说:
假设我们有一个int型的变量,值为128。相应的,我们将其转换为二进制表现形式就是:0000 0000 - 0000 0000 - 0000 0000 - 1000 0000。
如果我们要将其转换为byte类型。那么byte类型的数据长度为8位,所以我们进行有效位的截取后,值变为了:1000 0000。
我们知道二进制数的最高有效位用以表示符号,所以这里转后的值的实际值变为了十进制当中的-128。所以128在这个转换中,值由原本的128变为了-128。
既然高位数数据类型转换低位数数据类型存在这样的风险,那么作为一门健壮的语言,Java自然是不支持这样的转换的。
所以,为我们了提供了(type)value这样的强制转换方式,我们这样做的意义就在于,告诉编译器,我了解这样做可能承担风险,但这个风险由我来承担。
最后,我们通过代码来验证我们刚刚的转换过程:
~~~
int i1 = 127;
byte b1 = (byte) i1;
System.out.println("b1="+b1);
int i2 = 128;
byte b2 = (byte) i2;
System.out.println("b2="+b2);
~~~
其运行的输出结果为:
b1=127
b2=-128
通过其结果恰恰验证了我们提到的转换过程。
因为127本身在byte的取值范围之内,所以强制转换过后,数据仍然正确。但128超出了byte的取值范围,所以在经过有效位的截取之后,值发生了变化,变为了-128.
第二种情况:浮点数类型转换为整数类型需要进行强制转换,因为小数点后的数在转换过程中会被丢弃
~~~
double d = 128.123;
//转换后的值变为了128
int i = (int) d;
System.out.println(i);
~~~
到此,我们以Java的变量(常量)为切入点,又重新回顾了Java中8种基本数据类型的特点和使用。
';
磨刀不误砍材工 – Java的基础语言要素(注释-生成你自己的API说明文档)
最后更新于:2022-04-01 20:08:26
注释是编程工作中一项重要和必不可少的东西。注释的使用并不复杂,其之所以如此重要的原因在于什么?
来看一个概念解释:注释就是对代码的解释和说明。目的是为了让别人和自己很容易看懂。为了让别人一看就知道这段代码是做什么用的。
正确的程序注释一般包括序言性注释和功能性注释。序言性注释的主要内容包括模块的接口、数据的描述和模块的功能。
模块的功能性注释的主要内容包括程序段的功能、语句的功能和数据的状态。
所及,总结来说,书写程序注释最重要的原因在于:**增强程序的阅读性**。
Java中提供了三种注释方式:
- 单行注释://这是我的单行注释
- 多行注释:/*
*这是我的多行注释
*/
- 文档注释:/**
*
*/
让我们分别通过一些简单的实际运用来了解一下其使用方式:
1.单行注释的应用(单行注释基本也是我们在实际开发中最频繁使用的注释方式)。
我们可以通过对同一个程序,不加注释与加上注释的两种方式来形象的了解注释带来的好处。
不加注释的方式:
~~~
package com.tsr.j2seoverstudy.base;
public class JavaExpDemo {
private boolean flag = true;
public void myLoop() {
int num = 0;
while (flag) {
System.out.println(++num);
if (num >= 10) {
flag = false;
}
}
}
}
~~~
加上注释的方式:
~~~
package com.tsr.j2seoverstudy.base;
public class JavaExpDemo {
private boolean flag = true; // 这是我定义的循环控制标记
public void myLoop() {
int num = 0; //这是我定义的作为循环控制的数
while (flag) {
System.out.println(++num); //循环控制数自增运算并输出自增后的值
if (num >= 10) { //当控制数自增到大于等于10时
flag = false; //改变循环控制标记,结束循环
}
}
}
}
~~~
其程序阅读性的强弱不言而喻。
二、多行注释的应用
Java中多行注释的定义格式为:"/* */"的形式。顾名思义,多行注释对应于单行注释,通常就是当我们所需说明的注释较长,这时再使用单行注释就会显的不美观,并且麻烦,因为可能你需要书写多次"//".于是就有了多行注释的用武之地。
同样以我们上面使用单行注释的相同代码来说,如果我们想要对myLoop方法的功能进行注释说明,就可以使用多行注释:
~~~
/*
* 自定义的循环控制
* 功能说明:
* 通过循环标记flag对循环进行控制,当flag值为true时,将继续循环
* 同时定义了一个初始值为0的int型变量num,每次循环num会进行自增运算
* 当num自增到大于等于0时,循环标记flag的值将被修改为false,从而控制循环结束
*/
public void myLoop() {
int num = 0; //这是我定义的作为循环控制的数
while (flag) {
System.out.println(++num); //循环控制数自增运算并输出自增后的值
if (num >= 10) { //当控制数自增到大于等于10时
flag = false; //改变循环控制标记,结束循环
}
}
}
~~~
三、文档注释的应用
这是我觉得很酷的一种注释方式。为什么这样说呢?
作为一名Java语言的使用者,我们都经常和一个东西打交道:那就是Java的API说明文档。
通过文档注释,我们也可以对自己定义的类,生成一份“说明书”,也就所谓的API说明文档。
来看一下,首先我们定义一个自己的工具类:
~~~
package com.tsr.j2seoverstudy.base;
/**
* 我的数学工具类,提供一系列相应的数学相关运算方法..
* @author Hql
* @version 1.0
*/
public class MyMathUtil {
/**
* 比较获取两个数中的最小数
* @param num1
* @param num2
* @return 最小数
*/
public static int min(int num1, int num2) {
return num1 > num2 ? num2 : num1;
}
/**
* 比较获取两个数中的最大数
* @param num1
* @param num2
* @return 最大数
*/
public static int max(int num1, int num2) {
return num1 > num2 ? num1 : num2;
}
}
~~~
我们对我们提供的类以及方法等加上了文档注释。
这时,正如我们可以通过Java提供的javac.exe工具对自己的类进行编译一样,我们可以通过另一样工具javadoc.exe生成自定义的类的说明文档:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-08-17_57b4315fb6d35.jpg)
运行完毕后,在你指定的对应目录下,你会发现多出了一系列html等文件。
找到一个名为"index.html"的文件,打开,会发现就如同Java的API说明文档一样:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-08-17_57b4315fd1ad4.jpg)
这样的说明文档是大有裨益的,尤其是在如果你向别人提供了一系列的工具类,而别人需要知道使用方法时,这样的说明文档就大有作为了。
简单的总结了Java的各种注释方式,需要我们铭记的是:
要成为一名优秀的程序员,良好的书写代码注释是十分重要的。
';
磨刀不误砍材工 – Java的基础语言要素(关键字)
最后更新于:2022-04-01 20:08:24
在我们认识Java中的标示符的时候,知道了标示符的定义规则里有一条:
Java自身提供的49个关键字不能作为标示符定义。
那么,所谓的关键字是什么呢?关键字实际上是Java中的特殊保留字。
值得留意的就是特殊与保留两个字。为什么呢?因为通俗的来说,所谓的关键字事实上我们也可以理解为标示符存在,
只不过这种标示符因为Java自身为其赋予了特殊的意义,所以作为Java自身所保留的标示符存在。
自然的,我们也就不能再单独的使用这些标示符作为自定义的标示符。
我们同样可以结合实际生活中的例子来帮助我们更形象的理解关键字的概念。
就如同我们在认识标示符时提到的概念一样。我们可以将我们自己的姓名视作是我们自身的“标示符”。
只要符合中国的取名规范,我们可以用不同的汉字组成我们自己特有的姓名。
但是,如今可能没有人会使用“北京”,“毛泽东”来作为姓名了吧。(当然只是举个例子,我也不知道是不是法律规定不能以这些作为名字)
其原因就是因为,这些类似的“标示符”在发展过程中,已经有了人们共识,特殊的意义。
当谈论起“北京”,人们都认为是在谈论中国的首都,可能没有人能意识到这是你的名字。
同理的,Java中的关键字已经被赋予了特殊的意义,如果你使用这些关键字作为自己的标示符。
那么,Java的编译器在执行编译工作时,它只会识别其本身代表的含义;并不能了解你自定义的意义;这样就会造成混淆,从而编译失败。
Java中的关键字共有49个,下面就让我们对这个49个不同的关键字,通过其“功能性”对其进行一个分类,从而分别了解它们的作用。
http://docs.oracle.com/javase/tutorial/java/nutsandbolts/_keywords.html
**访问修饰符关键字**
在具体的了解不同的访问修饰符关键字的作用之前,我们首先应当知道什么是访问修饰符。
所谓的访问修饰符,简单来说就是指:用于对成员的访问权限的限定的修饰符。那么,接下来就让我们看一看Java中具体有哪些不同的访问修饰符。****
- public :公共访问修饰符
- private :私有访问修饰符
- protected:受保护访问修饰符
除此之外,实际还有另一种访问权限:
friendly :默认访问修饰符(不书写任何访问修饰符关键字的形式,friendly自身不是访问修饰符,因为这种访问权限下,只有当前类和同一包中的类可以访问,所以这种访问权限又被称为包访问权限)
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-08-17_57b4315f92c07.jpg)
除此之外,值得注意的就是:其余几种访问修饰符都可以修饰任何成员,但是在Java中,类只能被声明为public或者默认访问权限。
**类、方法和变量修饰符关键字**
- abstract :抽象修饰符。该修饰符修饰一个类时,代表此类为一个抽象,该类不能被实例化。修饰一个方法时,代表该方法为抽象方法,该方法必须被子类实现。
- class :定义类的关键字。
- extends :用于指出子类所扩展的父类的关键字。
- final :此关键字修饰的成员:类不能被继承,方法不能被覆写,变量不能被重新初始化。
- implements :用于指出类所实现的接口的关键字。
- interface :定义接口的关键字。
- native :该关键字修饰的方法意为一个本地方法,代表该方法是用于平台相关的语言(如C)实现的。
- new :通过该关键字调用构造函数实例化一个对象。
- static :静态修饰符,被该关键字修饰的成员属于一个类,而不属于对象(实例)。
- strictfp :用于修饰类或方法,指出此类或方法中的所有表达式中的浮点数将严格遵循FP限制规则。
- synchronized :同步修饰符。用于多线程中,被修饰的方法或代码块同时只能被一个线程所访问。
- transient :瞬态修饰符。常用于修饰关联IO流操作的变量,代表防止被修饰的变量串行化。(举例来说,例如使用一个输出流将一个对象串行化输出到一个外部文件时,被该修饰符修饰的变量的值将不会被串行化输出到外部文件当中)
- volatile :指出变量可能改变,失去同步,因为它被用在多个线程当中。
**流程控制关键字**
- break :从其所在的代码块中退出
- continue :停止循环内该语句后其余代码的执行,立马开始该循环的下一次循环。
- case :根据switch测试结果执行某个代码块
- default :如果条件与swicth语句中任何一个case都不匹配,则执行这个代码块中的内容。
- do :先执行一次该代码块,之后再与while语句结合,判断是否应该再次执行该代码块。
- else :如果if运算结果为false,则执行该代码块中的内容
- for :为代码块执行条件判断
- if :执行逻辑判断,判断结果为true则执行该代码块中的内容。
- Instanceof:确定对象是否是一个类、子类或接口的实例
- return :从方法返回
- switch :指出与case语句比较的变量
- while :当某个条件为true时,重复的执行该代码块。
**错误处理关键字**
- catch :声明用于捕获处理异常的代码块
- finally:该关键字修饰的代码块通常跟在try-catch或try之后,意味当带有异常处理时,程序流程如何,最终都将执行这个代码块中的内容
- throw :把异常向上传递到调用该方法的方法。
- throws :指出该方法将可能存在异常
- try :声明将要执行单可能引发异常的代码块
- assert :断言。计算条件表达式,以验程序员的假设。
**包控制关键字**
- import :把包或类导入到代码中
- package:指出当前类属于哪个包
**基本数据类型关键字**
- boolean:布尔数据类型
- byte:字节数据类型
- char:字符数据类型
- short:短整型数据类型
- int:整型数据类型
- long:长整型数据类型
- float:单精度浮点数据类型
- double:双精度浮点数据类型
**变量关键字**
- super:引用直接父类的引用变量
- this:引用当前对象的实例的引用变量
**空返回类型关键字**
- void:指出方法没有任何返回类型
**
**
**未使用的保留字**
- goto
- const
以上就是Java中所有的49个关键字,我们会在实际应用中频繁的接触当中大部分的关键字,也有一小部分是实际开发中较少用到的。
我们可以通过实际开发更好的理解各个关键字的用法与效果,但必须要记住的就是:不要将这些关键字用作自定义的标示符。
';
磨刀不误砍材工 – Java的基础语言要素(定义良好的标示符)
最后更新于:2022-04-01 20:08:21
**一、Java中的标示符是什么?**
第一,标示符的概念:
我们指定某个东西、人,都要用到它,他或她的名字;
在数学中解方程时,我们也常常用到这样或那样的变量名或函数名。
同样的道理:在编程中,是用户编程时使用的名字,对于变量、常量、函数、语句块也有名字,我们统统称之为标识符。
第二,Java中标示符的概念:
用来给类、对象、方法、变量、接口和自定义数据类型命名的名称。
**二、怎么理解Java中的标示符?**
看过了上面的概念过后。我们已经知道简要来讲,如果一个人名叫张三,那么“张三”就是这个人的标示符。
但为了更好的理解其概念,我们不妨对比自己熟悉的语言的来看待这个问题。
我们每个人从小就在学习我们的母语中文,我们都曾用汉字写过文章。
所以,正如我们用汉字写文章一样,我们同样可以使用Java语言来写文章,
唯一不同的是,Java语言写出的文章的表现形式被我们称作:“程序”。
接着,我们通过“小说”来更形象的理解Java中的标示符。
某位作家写了一本小说,小说被划分成为很多章节,每一个章节由很多的情节构成,每个情节里会有不同的人物。
拿《三国演义》为例,第一章的标题叫做《宴桃园豪杰三结义 斩黄巾英雄首立功》,而这一章节里面又会有相关的故事情节,故事情节里又会涉及到相关人物,例如:刘备,关羽,张飞等等。
在这个关系当中,“三国演义”是该小说的标示符;“宴桃园豪杰三结义 斩黄巾英雄首立功”是该小说里第一个章节的标示符;在该章节里,会涉及相关故事情节,这些故事情节是作者用自己的书写句子完成的;而“刘备,关羽,张飞”就是在这故事情节里涉及到的相关人物的标示符。
到了这里就好理解了。我们可以这样做一个“等价”看待:《三国演义》是我们用Java实现的一个类;《宴桃园豪杰三结义 斩黄巾英雄首立功》是这个类里提供的一个函数(方法);身为函数,就意味着会实现相应的功能,这些功能是coder用自己书写的程序语句完成的; 而这些程序语句里就通常会使用到相关的变量,例如:“刘备,关羽,张飞”。
所以,简单的来讲,我们大致得到了这样一种关系“小说=类;章节=函数;段落语句=程序语句;人物=变量”。而类似于“三国演义”,“宴桃园豪杰三结义 斩黄巾英雄首立功”这样的标题就是我们为其定义的名字。
这样一来,对于标示符的理解,是不是就形象了很多?
**三、定义标示符的目的是什么?**
我自己是这样理解的:
1.用自定义的较为特定的标题,与较为统筹的群体当中的某个个体发生关联,让它区别于该群体当中的其它个体。
举例来说:为什么我们每个人都有自己的名字?因为人是一个统筹的数量庞大的群体,姓名的作用就是让我们区别于其它人的独特标示。
同样的,一个Java类里可能有很多个变量或方法等等,定义标示符的意义就在于能让它们彼此区分。
2.方便对标示符关联的个体进行调用。
举例来说:一个班级里会有很多学生,老师想要抽取某个学生回答问题。如果学生没有自己的标示符,那么老师应当怎么进行抽取呢?
同理的,一个Java类中有很多变量,如果没有其独有的标示符,我们应当怎么调用到想要调用的变量呢?
所以,总的来说,标示符的意义就在于:让一个相同类型集体中的每个个体都有自己独一无二的“名牌”。
**四、Java中标示符的定义规则**
说了这么多,现在就让我们来看一看在Java中,合法的标示符应当怎么定义。
Java中的标示符定义规则很简单:
1.标示符由大小写字母,数字,下划线和美元符号组成,但是!不能以数字作为开头。
2.Java是一门严格区分大小写的语言。所以,“Abc”和"abc"是不同的标示符。
3.Java中提供的49个关键字不能被用作标示符。
所谓规则,就意味着你必须严格遵守。
就如同假设中国的户籍登记处,允许姓名的最大有效长度为6个字。而你非要给自己取一个7个字的名字,这没有问题,只要你高兴,你可以给自己写一首诗当名字。但是重点在于户籍登记处不承认,于是,恭喜你成为一名光荣的“黑户”。
而Java已经为你提供了详细的标示符定义规则,如果你不遵守其规则的话。那么,编译器就会编译出错。
**五、良好的标示符定义规范**
首先我们需要明确的是,一个符合定义规则的合法标示符并不意味着它就是一个良好的标示符。
如何定义一个良好的标示符?通常需要遵循的规范是:
- 目的性:使用带有明确意义的单词作为标示符,简单的说就是做到该标示符的含义能够让人“一目了然”。例如:“st”和“student”都可以作为变量学生的标示符,但显然后者更让人一击命中的了解到你定义的该变量的目的是什么。
- 包名:Java中通常使用反向域名,并且单词字母全部采用小写形式。例如,你的公司的域名是:mycompany.com,那么你的包名的定义通常为:com.mycompany.xx.xx的形式
- 类名/接口名:通常由带有具体含义的一个或多个单词组成,每个单词的首字母大写。
- 方法名:通常也是由带有具体含义的一个活多个单词组成,第一个单词的字母全部采用小写形式,其余的单词首字母大写。
- 变量名:成员变量的定义规范与方法名的定义规范相同。而局部变量的定义通常所有字母都采用小写形式。
- 常量名:单词字母全部采用大写形式,不同的单词之间以下划线“_”进行分隔。
最后,值得一提的的是,规范与规则的不同之处在于:规则代表着强制,意味着你必须遵守。而规范则代表着一种建议,意味着你可以选择遵守,但也可以选择不遵守。
但事实是,既然会针对一件事物声明了一个规范,这种规范通常也就代表着绝大多数人们认同的,针对于该事物的一种较好的处理方式。
就像我们乘坐公交车时,“向老弱病残让座”就是是一个良好的道德规范。针对于这一情况,你可以选择让座,也可以选择不让,不同之处在于,选择不让通常会在别人心里留下一个不好的印象。
同样的,在Java中,如果不按照规范定义标示符,但只要你的标示符是合法的,你的程序依旧可以正常的编译运行。但这样做的坏处通常在于:
- 影响代码的阅读性:例如你的代码里定义了一个关于整数加法运算的方法,图一时方便,你定义的方法名为“jia”。那么问题就出现了,如果当其它人需要对你的代码进行调用或者修改时,可能就会找你拼命了。抛开它人不讲,可能这个方法写好过后,过了一段时间,你需要对这个程序作出修改,那么可能你自己也不一定记得这样的标示符的含义了。
- 就像绝大多数人都遵循向老弱病残让座的规范一样,几乎绝大多数程序员都遵循良好的标示符定义规范。所以正如你不让座会给多数人留下一个不好的印象一样,当你进入一个工作团队,这样的做法也会让其他程序员对你造成不好的印象。
';
磨刀不误砍材工 – 环境搭建(为什么要配置环境变量)
最后更新于:2022-04-01 20:08:19
就如同,如果我们想要游泳,前提是需要一个泳池;如果我们想要打篮球,前提是需要一个篮筐一样。
如果我们想要使用Java语言进行开发工作或者想要运行由Java语言编写的程序,那么第一步工作就是搭建一个支持Java语言的环境。
首先我们需要认识Java为我们提供的这两样东西:
JRE - Java Runtime Environment (Java运行环境)
JDK - Java Development Kit (Java开发库)
顾名思义,如果你的目的只是单纯想要在自己的系统环境下运行用Java语言编写的程序,那么通过安装JRE就足够了。
而如果你是作为一名使用Java语言的开发者,就应当选择安装JDK了。而实际上JDK自身也包含JRE,这是必然的,因为作为开发者的最终目的也是编写可运行的程序。([下载JDK](http://www.oracle.com/technetwork/java/javase/downloads/index.html))
现在就可以正式的开始在你的系统环境下搭建Java环境了,搭建的过程其实很简单:
第一步:下载好JDK,找到安装程序按操作提示进行安装。没有任何技术含量。
第二步:配置Java相关的环境变量。需要明确的是, 通常我们配置的相关环境变量有三个:Path,JAVA_PATH,JAVA_HOME;
在深入之前,我们还是先说一说配置环境变量的方法吧,以Win7操作系统为例:
**1.右键点击你的“计算机” - “属性” - “高级” - “环境变量”**
**2.新建一个名为“JAVA_HOME”的环境变量,其值为:你电脑上JDK的安装路径\bin,例如:C:\Program Files (x86)\Java\jdk1.7.0_45**
**3.找到名为“Path”的环境变量,在现有值的最前端加上:%JAVA_HOME%\bin;**
**4.新建一个名为“CLASSPATH”的环境变量,通常值我们配置为:.;%JAVA_HOME%\lib\dt.jar;%JAVA_HOME%\lib\tools.jar;****
事实上配置环境的步骤并没有什么技术含量。可能当我们接触java的第一天,就被要求记住配置它们的方法。
但其实这其中还是值得我们一看的,因为像我一样的菜鸟可能通常掌握了配置方法,却可能忽略了配置这些环境变量的目的是什么。
那不妨现在就让我们来认识一下这些可能被我们忽略的地方。
首先看一下,这样一种情况:
打开你电脑的命令行工具,然后输入命令notepad,敲回车。
这时的情况是:你发现你电脑的记事本程序被打开了。
之所以在命令行输入notepad命令,就可以打开window系统中的记事本程序。
其原因就在于环境变量的配置。所以我们可以以此为例,来认识配置环境变量的功用是什么。
还是老样子,右键点击你的“计算机” - “属性” - “高级” - “环境变量” ,找到名为"path"的环境变量。
点击打开查看它的值,然后,以我的电脑为例,你会发现类似于:C:\Windows\system32;这样的值。
然后输入该值进入到对应的系统路径下,你会发现有很多.exe的应用程序,其中就有notepad.exe。
到了这里,我们似乎已经猜到了什么。那么,就不妨动手验证一下,将path变量中的C:\Windows\system32删掉。
这次我们重新在命令行中输入notepad命令。发现得到这样的提示:'notepad' 不是内部或外部命令,也不是可运行的程序或批处理文件。
这时我们其实已经知道了,简单来说,配置Path的作用在于:
可以在任一路径下调用到Path中配置过的路径下的可执行程序。相反如果没有设置过Path,那么想调用某个目的程序,就必须进入到它所在的路径下。
这时,就让我们回过头看一下,我们在配置Java的环境变量时,
在Path里添加了类似如下的值:C:\Program Files (x86)\Java\jdk1.7.0_45\bin,
正如我们测试记事本程序所做的一样,进入到值对应的系统路径下发现:
该路径下几乎全是Java提供的相关的.exe可执行程序工具,其中最重要的两个工具程序正是:java.exe与javac.exe!
javac.exe能够对Java源文件进行编译;java.exe用于查找和运行Java的可执行文件。
接着,前面介绍过了,实际环境搭建中,我们会配置一个名为"**JAVA_HOME**"的环境变量。
这个变量的作用实际很简单,它的值永远都是你的电脑上JDK的安装路径。
以我电脑上的配置为例,使用C:\Program Files (x86)\Java\jdk1.7.0_45\bin;和%JAVA_HOME%\bin;两种方式作为环境Path的值,达到的效果实际一模一样。
以面向对象的思想来讲,我们可以认为我们将自己电脑上jdk的安装路径单独封装成了一个字符串对象"JAVA_HOME"。
因为环境变量PATH的值通常都有很长一串,所以我们使用JAVA_HOME的方式好处就在于:
如果我们JDK的安装路径发生了变化,就不用再打开Path变量,在一长串眼花缭乱的值里面去找到要修改的地方,
而直接找到"JAVA_HOME"变量进行修改就OK了。
最后,就是CLASSPATH环境变量的作用了。
要理解它的作用,首先要知道的是:在Java中,以".java"后缀结束的文件,被视为Java程序的源文件。
当编译器对".java"文件成功编译后,我们会得到一个同名的".class"文件,这个文件才是真正意义上Java的执行文件。
所以,我们配置CLASSPATH环境变量的目的实际就在于:设置虚拟机查找指定".class"文件的路径。
举个例子,就更容易明白了,我们写一个HelloWolrd:
~~~
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello java!");
}
}
~~~
然后,打开命令行工具,利用javac工具对其进行编译,编译成功得到:HelloWorld.class文件。
我将该文件拷出来,放在我的**E盘**根目录下。这时我们就可以来看一下CLASSPATH的作用了。
第一种情况,假设这时我们还没有配置过CLASSPATH环境变量,这时我们在命令行通过:java HelloWorld命令想要运行该程序,结果得到如下信息:错误: 找不到或无法加载主类 HelloWorld
然后,我重新配置CLASSPATH为:E:。这时再通过:java HelloWorld命令运行程序,发现成功得到输出信息:Hello java!
前面我们说,CLASSPATH的值通常配置为:".;%JAVA_HOME%\lib\dt.jar;%JAVA_HOME%\lib\tools.jar";
值得注意的是最前面的".","."实际上代表的当前路径。也就是说这样配置虚拟机在运行一个程序时,会首先在你当前所在路径下查找是否存在指定的".class"文件。
到此,关于Java开发环境的搭建的事,已经说的差不多了。最后总结一下三个环境变量配置的意义是什么吧。
JAVA_HOME:顾名思义,JAVA的家,也就是指当前计算机上JDK(或JRE)的安装路径。
PATH:用于设置Java中各种命令行工具的所在的文件夹路径。通过PATH的设置可以保证在系统任一路径下都能调用到Java相关的命令行工具。
CLASSPATH:JVM会在该环境变量下设置的对应该路径下查找指定的.class文件运行。
注:
我们可以将:“计算机” - “属性” - “高级” - “环境变量”这样的配置方式视作一种“一劳永逸”的懒汉式配置方式。这样配置的好处在于:一次配置后,永久有效。
同时还有另一种配置方式,在命令行工具中通过“SET”命令进行配置。这种方式是一种“临时”配置方式,也就是说:配置的环境变量,只在当前运行的命令行程序中有效,当退出命令行后,所配置的环境变量也就失效了。
两种方式各有长短,可以根据实际情况选择使用。
';
第一个专栏《重走J2SE之路》,你是否和我有一样的困扰?
最后更新于:2022-04-01 20:08:17
前两天申请了第一个专栏系列,今天一看已经申请成功了,有点小激动...申请写这个专栏系列的初衷源于:
在去年公司的某个项目告一段落的时候,终于有时间闲下来,回顾一下自己毕业进入工作这一年多来的情况。
然后警惕的发现,有这样一种潜在的危险情况,让我感到迷茫和困扰。那就是,作为一名菜鸟程序员,我发现:
也许我们能够通过自身所学习的技术与知识,来完成工作中的一些任务。
但是!值得警惕!这并不意味着我们真的已经“掌握”了这门技术。
"使用"和“掌握”是两回事,而“掌握”和“精通”又有了一道巨大的鸿沟。
如果你和我一样,也是一名经验尚浅的菜鸟,你不妨你也可以坐下来想想,是不是也常有这样一种困扰。
作为一名Java语言的使用者,我们都知道通过:
~~~
String str = new String("abc")
~~~
这样的方式能够声明一个字符串对象。但是!当你遇到类似于:
通过String str = new String("abc")在内存中产生了几个对象?这样的面试题时。
是不是会突然间感到措手不及。而当看到答案是:1个或2个的时候,就更加惆怅了。
实际上这是因为涉及到了内存中的字符串常量池。
JVM在装载运行一段程序时,如果遇到通过关键字new声明的String类型对象,其首先会检测字符串常量池中是否已经存在一个值为"abc"的对象。
如果常量池中还不存在,那么会首先向常量池中存储进一个"abc"字符串对象,然后再向堆内存中存储进一个"abc"字符串对象。
而如果常量池已经存在该对象,则会直接产生一个"abc"字符串对象存储进堆内存当中。所以答案会是:一个或两个。
又例如:当使用迭代器对一个集合容器进行迭代,如下:
~~~
ArrayList strList = new ArrayList();
Iterator it = strList.iterator();
while (it.hasNext()) {
System.out.println(it.next());
}
~~~
但可能你在查看Java的某些源码的时候,可能发现别人使用这样的书写方式:
~~~
ArrayList strList = new ArrayList();
for (Iterator it = strList.iterator(); it.hasNext();) {
System.out.println(it.next());
}
~~~
那两者之间的差别在哪呢?就实现的功能来说,完全没有任何的区别。其区别在于:
使用第一种方式,等同于你在该方法内声明了一个Iterator类型的局部变量,于是当该方法开始运行后,该局部变量也会进入到栈内存当中进行存储,直到随着该方法的执行结束而弹栈,完成内存释放。
而使用第二种方式,等同于在for循环的代码块中声明了一个局部变量,在该for循环结束之后,该局部变量就会从内存中释放,而不必等到该方法的结束。
所以其好处实际在于,从一定程度上,减少了内存开销。
出现的这样的情况,似乎不难预见。其根本还是在于我们学习一样新事物时的目的性。举例来说:
在学生时代老师在讲解某道难题时会说:大家尽量理解,如果实在无法理解,那么就记住这个方法,以后遇到类似的题目,记得使用这样的方法就行。
这没有问题,因为在中国特色的教学体制下,学生时代里大多数人的根本目的在于应对考试,你不能明白解题的原理不要紧,只要你能记住这个方法在考试中答对答案就行。
同理,当你作为一个菜鸟,一个初学者接触一门编程语言时,我们首先关心的是,使用这门语言我们能够实现什么功能。当我们毕业时,我们的首要目的在于找到一份工作。在这样的情况下,造成类似上述"情况”的出现,也就不足为奇了。
所以当我逐渐被类似于这样的情况困扰的时候,我不得不开始思考一个问题:我是否有必要重新深入的学习一下Java了?最后得到的答案是肯定的:
假设你想成为一名车手,那么首先你应该知道“如何驾驶车辆”,例如:可以通过方向盘控制汽车的方向,可以通过刹车让汽车停止运行,可以通过油门让汽车加速等等。但这不过是成为一名车手的基本素质而已。当你慢慢积累的过程中,你应该思考的是:“我如何能成为比其它人更优秀的车手”。于是你想到了:“我要比其它人更快”!这绝对是一个好的方式。但是问题来了?如果你不了解一辆汽车的原理,而只是仅仅知道“可以通过方向盘控制汽车的方向”这样的东西时,你要如何才能将你的汽车改造的比其他人更快呢?
那么,同样的:作为一名程序员,通过你学习的语言或技术来完成某样功能,这只是一名程序员所必备的素质而已。同样的一个功能,你需要20行代码来实现;而另一个人只需要5行。同样的一段计算程序,你的代码需要1分钟完成运算;而别人的只需要10秒。这就是体现价值与差距的地方了。所以说”使用“,“掌握”,“精通”,这是完全不一样的概念。
有了计划,接下来就是付诸行动。于是开始通过一些书籍和相关资料,开始重新深入的学习Java。学习主要是利用平时空余的时间,断断续续从去年10月底到今年年初,现在再回望一眼,的确收获颇丰。中间也写了一些学习总结性质的博客,但比较散乱,不够系统,更不够深入和细致。这就又有悖于我的初衷了。就好比我的初衷原本是:我做了一道数学题,然后我要理解这道为何是这样解答的,然后把解题思路和原理记录下来。结果最终变成了:我做了一道数学题,没有记录其解题思路和原理,反而将这道题靠着记忆在纸上重新默写了一遍。
于是,索性申请了一个专栏系列:
- **新的视角**:以更加细致和深入的角度记录知识结点,完成更系统性的总结归纳。也算是自己做一次系统的记忆巩固。
- 如果和我一样的菜鸟有缘看到了谋篇文章,希望能给您带来一点点帮助;而如果您发现了问题,也希望不吝指出。
所以,如果你和我一样,是一个经验尚浅的菜鸟,就让我们一起用一个小菜鸟的新视角,重走这J2SE之路。
';
前言
最后更新于:2022-04-01 20:08:15
> 原文出处:[重走J2SE之路 - 小菜鸟的新视角](http://blog.csdn.net/column/details/j2se-overstudy.html)
作者:[ghost_programmer](http://blog.csdn.net/ghost_programmer)
**本系列文章经作者授权在看云整理发布,未经作者允许,请勿转载!**
# 重走J2SE之路 - 小菜鸟的新视角
> 毕业工作一年多了,作为一名菜鸟程序员,可能你也和我一样,渐渐的有这样一种困惑:
很多技术也许我们能够使用,却发现并不能清晰其原理,于是难以深入.
惶恐之...那么,亡羊补牢,未为晚矣...
';