[Advanced Java] 高级程序设计笔记

[Java] 高级程序设计

0 目录

文章目录

1 前言

此篇文章由阿森一人完成,转载请标注原创。

此篇文章源于《Java高级程序设计》清华大学出版社一书和笔者自己的经验总结。

此篇文章除书本以外的资料均来自网络。

此篇文章代码风格符合《代码整洁之道》。

此篇文章为了观看体验,英文与中文不含有空格;尽量不使用斜体、引用等格式;尽量不使用问句;尽量不使用图片说明。

此篇文章可能存在细微错误,但笔者要求严谨,欢迎指正。

此篇文章原本包含Web编程和Tomcat相关知识,但参考书已经年代久远,此内容已被放在Spring学习笔记中。

为了您的观看体验,请使用Pixyll主题观看。

2 反射

反射是Java利用JVM底层的一种机制,目的是提高耦合和扩展性。

2.1 内存的作用和Java的运行机制

内存的作用:内存(Memory)是计算机的重要部件,也称内存储器和主存储器,它用于暂时存放CPU中的运算数据,以及与硬盘等外部存储器交换的数据。它是外存与CPU进行沟通的桥梁,计算机中所有程序的运行都在内存中进行,内存性能的强弱影响计算机整体发挥的水平。只要计算机开始运行,操作系统就会把需要运算的数据从内存调到CPU中进行运算,当运算完成,CPU将结果传送出来。

Java的运行机制:在Java运行时,会分为编译、加载两个阶段:

  1. 编译:将Java文件,在磁盘中转化为.class文件(二进制码)。
  2. 加载:将需要的.class文件从磁盘加载到内存中。

2.2 .class文件

2.2.1 Java文件转化为.class文件的特性
  • 若Java文件中只存在一个类或者接口,编译后产生一个.class文件。命名格式为“类名.class”。
  • 若Java文件中存在内部类。编译后产生多个.class文件,其中内部类命名为“外部类 + $ + 内部类.class”。
  • 若Java文件中存在多个并行类(或接口),编译时产生多个.class文件。命名格式为“类名.class”。
2.2.2 .class文件结构
名称长度功能
Magic4个字节判定是否为Java文件
Version: Major_version/Minor_version分别2个字节主次版本号,判断jvm是否有能力处理该文件
Constantpool: Constantpool_count/Constantpool不固定常量池,包含类和接口的相关常量
Access_flags2个字节指明类型,类或者接口、抽象或者具体。公共、final等修饰符
This_class2个字节索引,指向常量池中属于类的常量
Super_class2个字节指向父类全限定名
Interface: Interface_count/Interfaces不固定该类实现接口的数量/常量池引用
Field: FieldsCount/Fields不固定字段数量和信息表。描述字段的类型、描述符等
Method: Methods_count/Methods不固定方法数量和方法本身。每一个方法都有一个Method_info表,记录Method的属性
Attribute: Attributes_count/Attributes不固定属性数量和属性本身

2.3 信息加载

注意:加载——将.class文件从磁盘移动到内存;装载——包含加载、校验、准备,将.class转化为Class类

2.3.1 Java基础类的加载

1. 预先装载

  1. 在JDK目录找到并载入jvm.dll,启动虚拟机,初始化数据。
  2. 创建一个BootstrapLoader对象——启动类装载器,由C++编写。此类负责一次性加载JVM的基础类。
  3. 其中JVM的基础类包含sum.misc命下空间的Launcher类:ExtClassLoader——加载AppClassLoader的加载器,由Bootstrap Loader加载;AppClassLoader——加载类的加载器,由ExtClassLoader加载。

2. 按需装载

当需要某个类时,JVM才会去动态装载它。

  1. 装载条件:当使用该类的a. 静态方法;b. 静态属性;c. 构造方法。

    注意:类中的静态常量属性处于常量池,所以没必要初始化该类;构造方法是一种特殊的静态方法,所以当创建该类的实例时就会装载该类。

  2. 按需装载流程:

    1. 先查看是否加载。
    2. 若加载则直接调用结束,若未加载则继续。
    3. 查找对应的.class文件,载入.class文件,生成并链接(校验、准备、解析)该类的Class对象。
    4. 初始化静态变量并执行该类的静态域代码。
2.3.2 类加载器

当创建一个Java类的实例时,必须先将该类加载到内存中。JVM使用类加载器来加载类。Java加载器在Java核心和CLASSPATH环境中的所有类查找类。如果需要的类找不到,则会抛出ClassNotFoundException异常。

Java类加载器分为:bootstrap类加载器、extension类加载器和system类加载器。顺序为结构的顶端到底层。

  1. bootstrap类加载器用于引导JVM,一旦调用java.exe程序,bootstrap类加载器就开始工作。因此,它必须使用本地代码实现,然后加载JVM需要的类到函数中。另外,还负责加载所有的Java核心类,例如java.lang和java.io包。另外,bootstrap类加载器还会查找核心类库如rt.jar、il8n.jar等。这些类库根据JVM和操作系统来查找。
  2. extension类加载器负责加载标准扩展目录下面的类。这样就可以使得编写程序变得简单,只需把JAR文件复制到扩展目录下面即可,类加载器会自动地在下面查找。不同的供应商提供的扩展类库是不同的,Sun公司的JVM的标准扩展目录是/jdk/jre/lib/ext。
  3. system加载器是默认的加载器,它在环境变量CLASSPATH目录下面查找相应的类。

JVM使用类加载器决定于委派模型(delegation model),出于安全原因,每次类都需要加载,system类加载器首先调用。但是,它不会马上加载类。相反,它委派该任务给它的父类extension类加载器,然后extension类加载器也罢任务委派给它的父类bootstrap类加载器。因此,bootstrap类加载器总是先加载类。如果bootstrap类加载器不能找到所需要的类,则extension类加载器会尝试加载,如果扩展类加载器也失效,system类加载器将执行任务。如果最后系统类加载器找不到类,则会抛出一个ClassNotFoundException异常。

Java类加载器机制的优势在于可以通过扩展java.lang.ClassLoarder抽象类来扩展自己的.class文件。自定义自己的类加载器的好处在于:

  1. 要制定类加载器的某些特定规定,例如加载指定目录下的类文件、加载经过加密的.class类文件。
  2. 缓存以前加载的类。
  3. 实现加载类以预备使用。
  4. 当.class文件修改后自动加载新的类文件。
2.3.3 类的加载顺序
  • 父类先加载,子类后加载。
  • 引用类若未初始化,则不加载引用类;若引用类已初始化,则在该类之后初始化引用类。

2.4 Class类

Class类的对象用来表示运行时类或者接口的信息。Java中枚举是一种类、注解是一种接口,数据也被看做一个类。这些类的信息,在运行时都由Class类来描述。对应数组而言,具有相同元素类型和维数的数组共享一个Class对象。可以通过Class对象获取类名、父类等信息,并可通过Class类来获取该类的属性、方法、构造方法、包等。

通过实例获取的Class类可以通过反射得到该实例对应的属性值,以及注解等信息。

2.4.1 获取Class对象
  1. .class

    Class<Person> clazz1 = Person.class;
    
  2. getClass()方法

    Person person1 = new Person();
    Class<Person> clazz2 = person1.getClass();
    
  3. forName(className)方法

    String className = "xxx.xxx.reflect.packageName.Person";
    Class<?> clazz3 = Class.forName(className);
    
2.4.2 获取Construct对象
方法体返回类型说明
getConstructor(Class…parameterTypes)Constructor其中参数为指定构造参数中类型的class数组,该方法只能获取public构造方法
getConstructors()Constructor[]获取指定类的public构造函数,若没有则返回长度为0的Construct数组
getDeclaredConstructor(Class…parameterTypes)Constructor可获取所有的构造方法的对象
getDeclaredConstructors()Constructor[]同理
2.4.3 获取Method对象

这里补充两个知识点:

final方法
使用final的方法不需要再扩展,不能被子类重写。同时允许编译器将素有对此方法的调用转化为inline(行内,来源于C++)调用机制。当调用final方法时,直接将方法主体插入到调用处,并非进行例行的方法调用,例如保存断点、压栈等。这样可能会使得程序效率有所提高,然后方法主体庞大时,或在多处调用此方法时,那么调用主体代码便会迅速膨胀,反而会降低效率。

native方法
Native Method就是一个Java调用非Java代码的接口。该类方法的实现由非Java语言实现,例如C。这个特性其他的编程语言都有这一机制,例如在C++中,可以用extern ”C”告知C++编译器去调用一个C的函数。在定义一个Native Method时,并不提供实现体(有些定义一个Java Interface),因为其实现体是由非Java语言在外面实现的。

获取方法:

方法体返回值说明
getMethod(String name, Class…parameterTypes)Method同理
getMethods()Method[]同理
getDeclaredMethod(String name, Class…parameterTypes)Method同理
getDeclaredMethods()Method[]同理
2.4.4 获取Field对象
方法体返回值说明
getField(String name)Field同理
getFields()Field[]同理
getDeclaredField(String name)Field同理
getDeclaredFields()Field[]同理

2.5 运行时类型识别

运行时类型识别:在程序运行时,动态地识别对象和类的信息。

识别方法:

  1. 关键字instanceof

    二元运算符,前者是对象,后者是类。用于判断一个对象是否是该类或者该类的子类的实例。

  2. Class.isInstanceof()

    效果同上,但是该方法更适用于遍历。

2.6 反射

2.6.1 概念

Java反射机制是指在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法,修改它的任意属性,这种动态获取的信息以及动态调用对象成员的功能称为Java语言的反射机制。

2.6.2 作用
  1. 提高了程序的灵活性和扩展性。
  2. 让程序可以创建和控制任何类的对象,无需提前硬编码目标类。
  3. 让程序可以在运行时构造一个类的对象,调用一个对象的方法。
  4. 反射机制是构建框架技术的基础所在。
2.6.3 应用

注意:以下前4点仅适用于共有方法或共有属性。

1. 显式加载指定类

使用Class类中forName(String name)方法

2. 通过反射实例化类

  1. Class类的newInstance()方法

    该方法会调用该类的无参构造进行实例化。(警告:该方式在Java1.9版本中已被弃用)

  2. Constructor类的newInstance()方法

    该方法需要在Class中通过指定参数类型获取指定的构造方法,相比较第一个方法更明确化,明确调用的是哪一个构造器,而不是直接采用默认的无参构造器。

3. 通过反射执行方法

使用Method类的invoke(Object obj, Object… args)方法。

下面是invoke()方法的源码:

public Object invoke(Object obj, Object... args)
              throws IllegalAccessException,
                     IllegalArgumentException,
                     InvocationTargetException

obj——从中调用底层方法的对象(简单的说就是调用谁的方法用谁的对象)。

args——用于方法调用的参数。

  • 如果底层方法是静态的,那么可以忽略指定的obj参数。该参数可以为null。
  • 如果底层方法所需的形参数为0,则所提供的args数组长度可以为0或null。
  • 如果底层方法是静态的,并且尚未初始化声明此方法的类,则会将其初始化。
  • 如果方法正常完成,则将该方法返回的值返回给调用者;如果该值为基本类型,则首先适当地将其包装在对象中。但是,如果该值的类型为一组基本类型,则数组元素不被包装在对象中;换句话说,将返回基本类型的数组。如果底层方法返回类型为void,则该调用返回null。

4. 通过反射修改属性

使用Field的set(Object obj, Object value)方法。

obj——表示要修改的属性对象。

value——表示修改后的值。

该方法用于动态地给属性赋值。

5. 修改访问权限

使用目标对象的setAccessible(boolean flag)方法。

该方法来源于AccessibleObject类,Field类、Method类和Constructor类都继承了AccessibleObject,都允许被setAccessible()方法从私有设置为共有。

6. 反射中的各种异常分析

  1. ClassNotFoundException

    抛出该异常的原因是未在命名空间内找到指定的类,有可能是因为类名错误,或者是类文件不存在。抛出该异常时请检查指定的类是否存在,或检查类名是否正确是否完整(类名英文全类名即简单类名加上完整包名)

  2. SecurityException

    该异常是由安全管理器抛出的异常,指示存在安全侵犯。例如修改不允许修改的accessible标志时,会该异常。

  3. NoSuchMethodException

    无法找到某一特定方法时,抛出该异常。抛出异常时,可打印指定类的所有字段名,进行比较检查。

  4. NoSuchFieldException

    无法找到指定字段时,抛出该异常。抛出异常时,可打印指定类的所有字段名,进行比较检查。

  5. IllegalArgumentException

    抛出的异常表明向方法传递了一个不合法或者不正确的函数。可获取Method对象的参数,进行比较检查。

  6. InstantiationException

    当应用程序试图使用Class类中的newInstance方法创建一个类的实例,而指定的类对象无法被实例化,抛出该异常。

  7. IllegalAccessException

    当应用程序试图反射性地创建一个实例(而不是一个数组)、设置或获取一个字段,或者一个方法,但当前正在指定的方法无法访问指定类、字段、方法或构造方法的定义时,抛出该异常。例如该方法本身为私有属性,试图通过反射得到该方法被视为非法。

2.7 代理

2.7.1 代理模式

1. 概念

代理模式是指为目标对象提供一个代理对象,外部对目标对象的访问,通过代理委托进行,以达到控制访问的目的。为保持行为的一致性,代理类通常与委托类对象的直接访问,也可以很好地隐藏和保护委托类对象,同时也为实施不同控制策略预留了空间,从而在设计上获得了更大的灵活性。

2. 作用

为其他对象提供一种代理以控制对这个对象的访问。在某些情况下,一个客户不想或者不能直接引用另一个对象,而代理对象可以在客户端和目标对象之间起到中介的作用。

3. 使用方法

代理模式涉及三个角色:

  • 抽象角色:声明真是对象和代理对象的共同接口。
  • 代理角色:代理对象角色内部含有对真实对象的引用,从而可以操作真实对象,同时代理对象提供与真实对象想同的接口,以便在任何时刻都能代替真实对象。同时,代理对象可以在执行真实对象的操作时,附加其他的操作,相当于对真实对象进行封装。
  • 真实角色:代理角色代表的真实对象,是我们最终要引用的对象。

使用时创建对象后,再创建其代理对象,然后调用代理对象的方法即可。

4. 使用示例

public interface Speakable() {
    public void speak(String message);
}
public class Person implements Speakable {
    @Override
    public void speak(String message) {
        System.out.println("Speak: " + message);
    }
}
public class PersonProxy implements Speakable {
    private Person person;
    public PersonProxy(Person person) {
        this.person = person;
    }
    @Override
    public void speak(String message) {
        this.person.speak(message);
        System.out.println("Running time: " + System.currentTimeMillis());
    }
}
public class Test {
    public static void main(String[] args) {
        Person person = new Person();
        PersonProxy proxy = new PersonProxy(person);
        proxy.speak("Lesson one!");
    }
}

运行效果:

Speak: Lesson one!
Running time: 1234567

5. 评价

代理模式的确解决了很多问题,但同时也给我们增加了一些负担,因为必须为委托类维护一个代理,不易管理而且增加了代码量。Java的动态代理机制的思想则更加先进一步。

2.7.2 Java动态代理

1. 概念

利用Java的反射机制,在程序运行时,创建目标对象的代理对象,并对目标对像中的方法进行功能性增强的技术。代理对象会负责将所有的方法调用分派到委托对象上反射执行,在分派执行的过程中,开发人员还可以按需调整委托类对象及其功能,这时一套非常灵活有弹性的代理框架。

2. 使用示例

注意,在此基础上了解使用方法即可,底层其实更为复杂。

public class DynamicProxy{
    public static Speakable createSpeakableProxy(Speakable speakable){
        Speakable speakableProxy = (Speakable) Proxy.newProxyInstance(
                DynamicProxy.class.getClassLoader(),//Using this class loader to load the proxy.
                new Class[]{Speakable.class},//Interface class files which the proxy need to implements.
                new InvocationHandler() {
                    @Override
                    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                        /*functions*/
                        Object result = method.invoke(speakable, args);
                        /*functions*/
                        return result;
                    }
                });
        return speakableProxy;
    }
}
public class Test {
    public static void main(String[] args) {
        Person person = new Person();
        Speakable personProxy = DynamicProxy.createSpeakableProxy(person);
        personProxy.speak("Test message");
    }
}
2.7.3 动态代理机制的特点与不足

动态生成的代理类特点:

  1. 包:如果所带来的接口都是public的,那么它将被定义在顶层包(即包路径为空),如果所代理的接口中有非public的接口(因为接口不能被定义为protect或private,所以除了public之外就是默认的package访问级别),那么它将被定义在该接口所在包(假设代理了org.ddd.reflect包中的某些非public接口A,那么新生成的代理类所在的包就是org.ddd.reflect),这样设计的目的是为了最大程度地保证动态代理类不会因为包管理的问题而无法被成功定义并访问。
  2. 类修饰符:该代理类具有final和public修饰符,意味着它可以被所有的类访问,但是不能被再度继承。
  3. 类名:通过调用其Class类的TypeName,得到其格式是“$ProxyN”,其中N是一个逐一递增的阿拉伯数字,代表Proxy类第N次生成的动态代理类。值得注意的是,并不是每次调用Proxy的静态方法创建动态代理类都会使得N值增加,原因是如果对同一组接口(包括接口排列的顺序相同)试图重复创建动态代理类,它会返回先前已经创建好的代理类的类对象,而不会再尝试去创建一个全新的代理类,这样可以节省不必要的代码重复生成,提高了代理类的创建效率。
  4. 类继承关系:动态生成的代理类继承了类Proxy,并实现了所代理的所有接口。

实际上,每个动态代理实例都会关联一个调用处理器对象,可以通过Proxy提供的静态方法getInvocationHandler去获得代理类实例的调用处理器对象。在代理类实例上调用其代理的接口中所声明的方法时,这些方法最终会由调用处理器的invoke方法执行。当代理的一组接口有重复声明的方法且该方法被调用时,代理类总是从排在最前面的接口中获取方法对象并分派给调用处理器,而无论代理类实例是否正在以该接口(或继承于该接口的某子接口)的形式被外部引用,因为在代理类内部无法区分其当前的被引用类型。

至于被代理的接口,首先,不能有重复的接口,以避免动态代理类代码生成时的编译错误。其次,这些接口对于类加载器必须可见,否则类加载器将无法链接他们,将会导致类定义失败。再次,需被代理的所有非public的接口必须在同一个包中,否则代理类生成也会失败。最后,接口的数目不能超过65535,这时JVM设定的限制。

2.7.4 扩展阅读之AOP

AOP为Aspect Oriented Programming的缩写,意为“面向方面编程”,是可以通过预编译方式和运行期动态代理实现在不修改源代码的情况下给程序动态统一添加功能的种技术。AOP实际是GoF设计模式的延续,设计模式孜孜不倦追求的是调用者和被调用者之间的解耦,AOP可以说也是这种目标的一种实现。 其主要的功能是:日志记录,性能统计,安全控制,事务处理,异常处理等。

如果说面向对象编程是关注将需求功能划分为不同的并且相对独立、封装良好的类,并让它们有着属于自己的行为,依靠继承和多态等来定义彼此的关系的话;那么面向方面编程则是希望能够将通用需求功能从不相关的类中分离出来,能够使得很多类共享一个行为,一且发生变化,不必修改很多类,而只需要修改这个行为即可。

面向方面编程是一个令人兴奋不已的新模式。就开发软件系统而言,它的影响力必将会和有着数十年应用历史的面向对象编程一样巨大。 面向方面编程和面向对象编程不但不是互相竞争的技术而且彼此还是很好的互补。面向对象编程主要用于为同一对象层次的公用行为建模。它的弱点是将公共行为应用于多个无关对象模型之间。而这恰恰是面向方面编程适合的地方。有了AOP,我们可以定义交叉的关系,并将这些关系应用于跨模块的、彼此不同的对象模型。AOP同时还可以让我们层次化功能性而不是嵌人功能性,从而使得代码有更好的可读性和易于维护。

在Spring中提供了面向切面编程的丰富支持,允许通过分离应用的业务逻辑与系统级服务(例如审计(auditing)和事务(transaction)管理)进行内聚性的开发。应用对象只实现它们应该做的一完成业 务逻辑仅此而已。 它们并不负责(甚至是意识)其他的系统级关注点,例如日志或事务支持。

2.8 依赖注入实例

待定加入。若该文本发布后仍未被修改,则表示未安排加入。请提示笔者加入内容或者删除这段文字。

3 泛型

泛型是Java的一种机制。

但设计者为了向下兼容,出现了一些新的问题。

3.1 泛型概述

泛型利用参数化类型(参数化类型:将所操作的数据类型被指定为一个参数。例如定义方法时用形参,调用方法时用实参)使得编写的代码适用于广泛的类型。同时,在使用参数类型的时候,可以根据需要指定它使用的类型。

3.1.1 泛型的产生原因

1. 泛型保证程序的类型安全并消除了一些烦琐的类型转换

在引入泛型之前,一般的程序都使用多态和继承来提高代码的灵活性和重用性。

List list1 = new ArrayList();
List list2 = new ArrayList();

list1.add(new Integer());
list2.add(new String("字符串"));

Integer i = (Integer)list1.get(0);
String s = (String)list2.get(0);
  • 问题1:很多时候明明确定List中存放的类型,却仍然需要显示转换。

    List integerList = new ArrayList();
    integerList.add(new Integer(1));
    integerList.add(new Integer(1));
    ...
    integerList.add(new Integer(1));
    for(Objct i:integerList){
        System.out.println((Integer)i);
    }
    
  • 问题2:只允许存放Integer的List中添加字符串类型时,编译器并不会报错,这样会导致运行时从List中取出的元素无法转化为Integer类型并抛出异常。

    List integerList = new ArrayList();
    integerList.add(new Integer(1));
    ...
    integerList.add(new String("失误"));
    integerList.add(new Integer(1));
    for(Objct i:integerList){
        System.out.println((Integer)i);
    }
    

泛型机制的设计很好地解决了上面这些问题。

2. 泛型使得Java编写的代码具有更加广泛的表达能力

Java语言是一种强类型语言。强类型语言通常是指在编译或运行时,数据的类型有较为严格的区分,不同数据类型的对象在转型时需要进行严格的兼容性检查。

Integer i;
String s = "i";
age = s;//在Java中是不合法的;但在Python中合法。
  • 问题:强类型的语言对提高程序的健壮性,提高开发效率有利,但强类型语言导致一个问题:数据类型与算法在编译时绑定,这意味着必须为不同的数据类型编写相同的逻辑代码。

    public Integer add(Integer a1, Integer a2) {
        return a1+a2;
    }
    public Float add(Float a1, Float a2) {
    	return a1+a2;
    }
    public Double add(Double a1, Double a2) {
        return a1+a2;
    }
    

继承是解决这个问题的方法之一:为适用这一算法的多种数据类型抽象一个基类(或者接口),针对这个基类(或者接口)编写算法。但在实际程序设计过程中,专门为特定算法修改数据类型的设计,这不是个好习惯。另外,如果过使用已经设计好的数据类型,问题就无法解决。

泛型是解决这一问题的有效方法之一。泛型的最大价值在于:在保证类型安全的前提下,把算法与数据类型解耦。

3.2 泛型类型

在介绍之前,需要了解泛型字母的含义:

字母意义
E-Element在集合中的元素
T-TypeJava类
K-Key
V-Value
N-Number数值类型
3.2.1 泛型类

定义方法

修饰符 class 类名<代表泛型的变量> {}

//单个
public class Person<T> {}
//多个,类里面不包含Person中的T
public class Teacher<V, S> extends Person {}
//多个,类里面包含Person中的T
public class Teacher<T, S> extends Person<T> {}

使用方法

Person<Integer> p = new Person<>(1);
3.2.2 泛型方法

定义

修饰符 <泛型> 返回值类型方法名(参数列表(使用泛型)){方法体;}

//单个
public <T> void method1(T t) {}
//多个
public <K,V> void method2(K k, V v) {}
//静态
public static <T> void method3(T t) {}

使用

method1("1");
method3("1");
method2("1", 1);
3.2.3 泛型接口

定义

修饰符 interface 接口名<代表泛型的变量> {}

public interface GenericInterface<E> {
    public abstract void add(E e);
    public abstract E getE();
}

使用

//方法一
public class GenericInterfaceImpl01 implements GenericInterface<String>{
    @Override
    public void add(String s) {}
    @Override
    public String getE() {return null;}
}
//方法二
public class GenericInterfaceImpl02<E> implements GenericInterface<E> {
    @Override
    public void add(E e) {}
    @Override
    public E getE() {return null;}
}

3.3 通配符

3.3.1 通配符的产生原因

泛型的作用之一在于:使得Java编写的代码具有更加广泛的表达能力。意思就是,对于同样的算法逻辑,我仅仅编写一个方法即可。

但是存在一个问题,若方法体内传入的参数是泛型类,则这个作用就无法得到实现。

public static void printArray(ArrayList<Number> list) {}

public static void testArray(){
    ArrayList<Number> list1 = new ArrayList<>();
    ArrayList<Integer> list2 = new ArrayList<>();
    ArrayList<String> list3 = new ArrayList<>();
    printArray(list1);
    printArray(list2);//不合法
    printArray(list3);//不合法
}

Java设计者为此添加了通配符类型。它可以表示任何类型,通配符类型的符号是“?”。

3.3.2 通配符的使用方法
public static void printArray(ArrayList<?> list) {}

public static void testArray(){
    ArrayList<Number> list1 = new ArrayList<>();
    ArrayList<Integer> list2 = new ArrayList<>();
    ArrayList<String> list3 = new ArrayList<>();
    printArray(list1);
    printArray(list2);
    printArray(list3);
}

3.4 泛型边界

边界是指为某一区域划定一个界限,在界限内是允许的,超出了界限就不合法。Java的泛型边界是指为泛型参数指定范围,在范围内可以合法访问,超出这个边界就是非法访问。

Java泛型系统允许使用类型通配符上限(extends)和通配符下限(super)关键字设置边界。extends仅允许泛型值为其本身或其子类;super仅允许泛型值为其本身或其父类。(实际上类似于判断放入的类是否满足规定,True就允许,然后将类型隐式转化为规定类;False就警告)

3.4.1 含边界的泛型类

设置含边界的泛型类是为了该类为某些特定的类使用。

public class NumberFactory<T extends Number> {
    public void printNumberClass(Class<T> clazz) {}
}
public class Test {
    public static void main(String[] args) {
        NumberFactory<Integer> integerFactory = new NumberFactory<>();
        integerFactory.printNumberClass(Integer.class);
        
        NumberFactory<String> stringFactory = new NumberFactory<>();//不合法
    }
}
3.4.2 含边界的泛型方法

设置含边界泛型方法是为了该方法为某些特定的类使用。与上同理,这里不再举例。

3.4.3 通配符与边界的使用

通配符与边界使用表示传入的参数类型需要满足特定的规范。

public static  void  getElement1(Collection<? extends  Number> coll) {}
public  static  void  getElement2(Collection<? super  Number> collection) {}

Collection<Integer> list1 = new ArrayList<Integer>();
Collection<String> list2 = new ArrayList<String>();
Collection<Number> list3 = new ArrayList<Number>();
Collection<Object> list4 = new ArrayList<Object>();

/*Collection<? extends  Number> coll*/
getElement1(list1);
getElement1(list2);//不合法
getElement1(list3);
getElement1(list4);//不合法

/*Collection<? super  Number> collection*/
getElement2(list1);//不合法
getElement2(list2);//不合法
getElement2(list3);
getElement2(list4);

3.5 泛型与继承

警告:泛型与继承存在误区。泛型的类型设定不满足继承关系,而放入类型满足继承关系。

List<Number> numberList = new ArrayList<>();
List<Integer> integerList = new ArrayList<>();
numberList = integerList;//不合法
numberList.add(new Integer(1));//合法

//通配符和上下界结合作用:
List<? extends Number> numberList = new ArrayList<Number>();
List<? extends Number> integerList = new ArrayList<Integer>();
numberList = integerList;//合法

3.6 泛型擦除

泛型擦除是指泛型代码在编译后,都会被擦除成原生类型。

List<Integer> integerList = new ArrayList<>();
List<String> stringList = new ArrayList<>();
integerList.getClass().equals(stringList.getClass());//true

虽然是同样的编译结果,但是Java的编译器在类型参数擦除之前对泛型的类型进行了安全验证,以确保泛型的类型安全。例如:

List<Integer> integerList = new ArrayList<>();
integerList.add("str");//不合法
3.6.1 为何要擦除

擦除并不是一种语言特性,而是Java泛型实现的一种折中办法。因为泛型在JDK5之后才是Java的组成部分,因此这种折中是必需的。擦除的核心动机是使得泛化的代码可以使用非泛化的类库,反之亦然,这称为“迁移性兼容”。

因此Java泛型必须支持向后兼容性,即现有的代码和类库依然合法,而且与在没有泛型的JDK上运行效果一致。为了让非泛型代码和泛型代码共存,擦除成为一种可靠实用的方法。

3.6.2 如何擦除

擦除的实质是在编译过程中:

  1. 将泛型类转化为原生类型。
  2. 将原有的类型参数换成即非泛化的上界,若未指定上界则为Object。

例如:

public class Zoo<T extends Animal> {
    private T t;
    public Zoo(T t) {
        this.t = t;
    }
    public T pop() {
        return this.t;
    }
}

经过编译后:

public class Zoo {//Zoo<T extends Animal>
    public Zoo(Animal t) {//T
        this.t = t;
    }
    public Animal pop() {//T
        return t;
    }
    
    private Animal t;//T
}
3.6.3 多边界擦除

对于单边界的泛型,擦除时使用其上界替换参数类型。对于多边界的泛型擦除,则与边界声明的顺序有关,Java编译器将选择排在前面的边界进行参数替换。例如:

public class Zoo<T extends Flyable&Speakable> {
    private T t;
    public Zoo(T t) {
        this.t = t;
    }
    public T pop() {
        return this.t;
    }
}

经编译后:

public class Zoo {
    public Zoo(Flyable t) {
        this.t = t;
    }
    public Flyable pop() {
        return t;
    }
    
    private Flyable t;
}

交换Flyable和Speakable后编译代码为:

public class Zoo {
    public Zoo(Speakable t) {
        this.t = t;
    }
    public Speakable pop() {
        return t;
    }
    
    private Speakable t;
}

3.7 泛型的限制问题

3.7.1 擦除限制

擦除机制使得泛型类在运行时丢失了泛型信息,因此一些被认为时理所当然的功能,在Java泛型系统中并得不到支持。

1. 类型参数的实例化

public class Zoo<T> {
    private T t = new T();//不合法
}

为了保证类型安全,Java并不允许使用类型参数类实例化对象。因为运行时参数类型信息被擦除,无法确定类型参数T所代表的具体类型,拥有无参的构造函数,甚至T所代表的具体类型可能不能被实例化,例如抽象类。

2. instanceof判断类型

Zoo<Bird> birdZoo = new Zoo<bird>();
if(birdZoo instanceof Zoo<Bird>) {}

代码看上去没有问题,但是JVM总是提示Cannot perform instanceof check against parameterized tpye Z, Use instead its raw form Zoo since generic tpye information will be erased at runtime错误,说明instanceof不能用于参数化类型的判断上,建议使用原生类型,因为类型信息将在运行时被擦除。

对于一个泛型类来说,即使其参数类型有多种不同,但在运行时他们都共享着一个原生对象。

3. 抛出或捕获参数类型信息

在Java异常系统中,泛型类对象时不能被抛出或捕获的,因为泛型类是不能继承实现Throwable接口及其子类的。

public class GenericException<T> extends Exception {}//不合法
3.7.2 擦除冲突

泛型的擦除有可能导致与多态发生冲突。例如:

public class Animal<T> {
    public void set(T t) {}
}
public class Bird extends Animal<String> {
    public void set(String name) {
        super.set(name);
    }
}
public class GenericTest {
    public static void main(String[] args) {
        Bird bird = new Bird();
        Animal<String> animal = bird;
        animal.set("Bird");
    }
}

由于Java的方法调用采用的时动态绑定的方式,所以呈现出多样性。但擦除导致了一个问题:由于擦除的原因,泛型方法set(T t)被擦除成set(Object t),而在子类中存在方法set(String name),我们本意时让子类的set方法覆盖父类的set方法,但擦除致使他们成了两个不同的方法,类型擦除与多态产生了冲突。

为了解决这个问题,Java编译器会在子类中生成一个桥的方法。例如,上面的Bird类经过编译后:

public class Bird extends Animal {
    public Bird() {}
    public void set(String name) {
        super.set(name);
    }
    public volatile void set(Object obj) {
        set((String) obj);
    }
}

在父类引用animal调用set方法时,因为父类的set被重写为桥方法,JVM会首先调用桥方法。然后由桥方法调用子类Bird的set(String name)方法。除了传入参数的多态冲突,同时还存在另外一种获取参数的多态冲突:

public class Animal<T> {
    public T get() {return null;}
}
public class Bird extends Animal<String> {
    public String get() {return null;}
}
public class GenericTest {
    public static void main(String[] args) {
        Bird bird = new Bird();
        Animal<String> animal = bird;
        animal.get();
    }
}

Bird类编译后的代码为:

public class Bird extends Animal {
    public Bird() {}
    public String get() {return null;}
   	public volatile Object get() {
        return get();
    }
}
3.7.3 类型安全和转换

因为擦除会导致运行时类型信息的丧失,JVM为了运行Java时保证类型安全和取消不必要的类型转换,把类型安全控制和类型转换放到了编译时进行。例如:

List<Integer> list = new ArrayList<>();
list.add(3);
Integer i = list.get(0);

编译后:

List list = new ArrayList();
list.add(Integer.valueOf(3));
Integer i = (Integer) list.get(0);

经过翻译后,从list取出元素时,编译器自动增加了转型代码,这就是在使用list时,无需手动转型的原因。可以推断在编译的时候,编译器也做了其他泛型操作以确保类型安全。

3.7.4 泛型数组

Java中不能声明泛型类数组,例如:

List<Integer>[] list = new ArrayList<Integer>[2];

该代码编译时时无法通过的,因为擦除后List会失去泛型的特性变成List[]。

3.7.5 擦除总结

排除使Java程序在运行时丧失了类型变量的具体信息,因此在使用泛型时要牢记一下内容:

  1. 虚拟机中没有泛型。
  2. 所有的类型参数都将被擦除成边界。
  3. 为确保多态,必要时合成了桥方法。
  4. 类型安全检查和类型转换实在编译时运行的,必要时插入额外代码。

4 注解

注解是Java的一种机制,目的是为了解耦。

对于未有过项目经验的读者来说,这一部分非常的空洞,因为在平常写Java代码时不常用到,甚至遇到得很少。

笔者认为,该部分是为了后面的框架知识而奠定基础的。

抽象些讲,标签是对事物行为的某些角度的评价与解释。想象代码具有生命,注解就是对于代码中某些鲜活的个体的贴上去的标签。简化来讲,注解如同标签。

4.1 概述

  • 注解(也被成为元数据)是指程序功能外,在代码中添加的额外信息,这些信息可以用来修饰,标识功能代码,但不影响代码运行。
  • 注解有注解处理器进行解释,并根据特定注释,完成不同的功能规定。
  • 注解使得代码变得简洁易懂,减轻了因配置文件过于烦琐而带来的问题。在开发Java程序中,尤其是Java EE应用的时候,总是免不了和各种配置文件打交道,以Java EE中典型的SSH架构(Spring、Struts、Hibernate)来说,Spring、Struts、Hibernate这三个框架都有自己的XML格式(一种键值对的语言,常用来配置)的配置文件。这些配置文件需要与Java源代码保持同步,否则就可能出现错误。而且这些有可能到了运行时刻才被发现。把同一份信息保存在两个地方,不仅麻烦而且极易造成错误。理想的情况是在一个地方维护这些信息部分所需的信息则通过自动的方式来形成。
  • 配置文件的好处在于进一步降低耦合,是应用更易于扩展。JDK5提供的Annotation(注释,标注)使得配置更加方便。它用来修饰Java元素(类,方法,属性,参数,本地变量,包,元数据)。
  • 注解是众多引入Java SE5中的重要语言变化之一。它们可以提供用来完整地描述程序所需的信息。
  • 注解可以用来生成描述文件,甚至是新的类定义。

4.2 Java常用注解

1. @Override

Override是推翻,重写的意思。表示当前定义的方法将覆盖父类的同名、同参数方法,如果定义的方法名在父类中找不到,编译器将会提示must override or implement a supertype method错误。

2. @SuppressWarnings

SuppressWarnings是抑制警告的意思。表示无须关心的警告信息,该注解可以作用于整个类上,也可以作用于方法上。该注解只在JDK5之后的版本中才起作用,之前的版本也可以使用,但是不起作用。

该注解可以在末尾加上参数。例如:

public class Person {
    @SuppressWarnings(value = "unused")
    private String name;
    
    public void speak(String name) {
        @SuppressWarnings({"unchecked", "unused"})
        List list = new ArrayList();
        System.out.println("Speak: " + message);
    }
}

声明参数的原因是注解处理器可以根据不同类型的参数做出不同性质的操作。

3. @Deprecated

Deprecated是强烈反对,抨击;弃用的意思。表示该方法或类已过时,不鼓励使用该方法或类,因为这可能存在风险或者有更好的选择。若该注解在类的属性中声明,若使用该属性,编译器将会抛出警告信息。

4. FunctionalInterface

函数式接口注解,这个是Java 1.8版本引入的新特性。函数式编程很火,所以Java 8也及时添加了这个特性。

函数式接口 (Functional Interface) 就是一个具有一个方法的普通接口。例如:

@FunctionalInterface
public interface Runnable {
    /**
     * When an object implementing interface <code>Runnable</code> is used
     * to create a thread, starting the thread causes the object's
     * <code>run</code> method to be called in that separately executing
     * thread.
     * <p>
     * The general contract of the method <code>run</code> is that it may
     * take any action whatsoever.
     *
     * @see     java.lang.Thread#run()
     */
    public abstract void run();
}

我们进行线程开发中常用的Runnable就是一个典型的函数式接口,上面源码可以看到它就被@FunctionalInterface注解。

可能有人会疑惑,函数式接口标记有什么用,这个原因是函数式接口可以很容易转换为Lambda表达式。这是另外的主题了,有兴趣的同学请自己搜索相关知识点学习。

4.3 自定义注解

4.3.1 定义注解

自定义注解实在上述介绍注解继承上构建的。注解的定义与接口很相似,而且,注解类编译也会产生一个Class文件,这与接口和类无异。例如:

public @interface TestAnnotation {
}

它定义的形式跟接口很类似,不过前面多了一个“@”符号,上面代码创建了一个名字为TestAnnotation的注解。

在注解中有时还会保留一些参数,用户使用注解时可以为这些参数指定具体的值。当分析处理注解时,程序或工具可以利用这些值进行特定操作。在注解中声明参数与在类中有所区别,它看起来更像是声明方法。例如:

public @interface TestAnnotation {
    public int id();
    public String msg();
}

上述代码定义了TestAnnotation这个注解中拥有id和msg两个public属性,在使用的时候,我们应该给他们进行赋值。

@TestAnnotation(id = 3, msg = "hello annotation")
public class Test {}

注解中属性可以有默认值,默认值需要用default关键值指定。例如:

public @interface TestAnnotation {
    public int id() default -1;
    public String msg() default "Hi";
}
4.3.2 注解参数说明

1. 参数类型必须使用指定参数类型

注解参数只能使用指定的类型:所有基本类型,包括(int,float,boolean),String,Class,enum(枚举类型),Annotation以及以上类型的数组。

如果使用了其他类型,编译器就会抛出错误。注意:也不允许使用任何包装类型。注解也可以作为参数类型,这意味着注解可以嵌套。例如:

public @interface ID {
    public String value() default "id";
}
public @interface Entity {
    public String name() default "";
    public ID id() default @ID("id");
}

2. 注解参数的赋值要求

编译器要求注解的参数不能是不确定值,即要么在定义注解的时候就进行负值,要么在使用的时候进行负值。如果定义一个参数而未进行赋值,则编译器会抛出一个错误:The annotation must define the attribute value。

3. 注解参数的快捷方式

@interface TestAnnotation {
    public int id() default -1;
    public String msg() default "Hi";
}
@TestAnnotation()
public class Test {}

因为有默认值,所以无需要再在@TestAnnotation后面的括号里面进行赋值了,这一步可以省略。

如果一个注解内仅仅只有一个名字为value的属性时,应用这个注解时可以直接接属性值填写到括号内。

public @interface Check {
    String value();
}

上面代码中,Check这个注解只有value这个属性。所以可以这样应用。

@Check("hi")
int a;

与下面的效果是一样的

@Check(value="hi")
int a;

最后,还需要注意的一种情况是一个注解没有任何属性。比如

public @interface Perform {}

那么在应用这个注解的时候,括号都可以省略。

@Perform
public void testMethod(){}
4.3.3 元注解

元注解是Java定义的用于程序员自定义创建注解的工具,它们本身也是注解。元注解也是一张标签,但是它是一张特殊的标签,它的作用和目的就是给其他普通的标签进行解释说明的。

1. @Target

Target是目标的意思,@Target指定了注解运用的地方。简单来讲,当一个注解被@Target注解时,这个注解就被限定了运用的场景。

类比到标签,原本标签是你想张贴到哪个地方就到哪个地方,但是因为@Target的存在,它张贴的地方就非常具体了,比如只能张贴到方法上、类上、方法参数上等等。

​ ElementType。ElementType常见值如下:

  1. ElementType.ANNOTATION_TYPE:作用在注解类型上的注解。
  2. ElementType.CONSTRUCTOR:作用在构造方法上的注解。
  3. ElementType.FIELD:作用在属性上的注解。
  4. ElementType.LOCAL_VARIABLE:作用在局部变量上的注解。
  5. ElementType.METHOD:作用在方法上的注解。
  6. ElementType.PACKAGE:作用在包上的注解。
  7. ElementType.PARAMETER:作用在参数上的注解。
  8. ElementType.Type:作用在类、接口或枚举上的注解。

2. @Retention

Retention的英文意为保留期的意思。当@Retention应用到一个注解上的时候,它解释说明了这个注解的的存活时间。可选的级别被存放在枚举RetentionPolicy中,该枚举中的常量值如下:

  1. RetentionPolicy.SOURCE:注解信息仅保留在源文件中,编译时将丢弃注解信息。
  2. RetentionPolicy.CLASS:注解信息将被编译进Class文件中,但这些注解信息在运行时将丢弃。
  3. RetentionPolicy.RUNTIME:注解信息将被保留到运行时,可以通过反射来读取这些注解信息。

我们可以这样的方式来加深理解,@Retention去给一张标签解释的时候,它指定了这张标签张贴的时间。@Retention相当于给一张标签上面盖了一张时间戳,时间戳指明了标签张贴的时间周期。

@Retention(RetentionPolicy.RUNTIME)
public @interface TestAnnotation {
}

上面的代码中,我们指定TestAnnotation可以在程序运行周期被获取到,因此它的生命周期非常的长。

3. @Documented

该注释表明制作Javadoc时,是否将注释信息加入文档。如果注解在声明时使用了该注解,则在制作Javadoc时注解信息会加入Javadoc。

补充:Javadoc是Sun公司提供的一个技术,它从程序源代码中抽取类、方法、成员等注释形成一个和源代码配套的API帮助文档。也就是说,只要在编写程序时以一套特定的标签作注释,在程序编写完成后,通过Javadoc就能够同时形成程序的开发文档。

4. @Inherited

Inherited是继承的意思,但是它并不是说注解本身可以继承,而是说如果一个超类(父类)被@Inherited注解过的注解进行注解的话,那么如果它的子类没有被任何注解应用的话,那么这个子类就继承了超类的注解。例如:

@Inherited
@Retention(RetentionPolicy.RUNTIME)
@interface Test {}
@Test
public class A {}
public class B extends A {}

可以这样理解:

  • 老子非常有钱,所以人们给他贴了一张标签叫做富豪。

  • 老子的儿子长大后,只要没有和老子断绝父子关系,虽然别人没有给他贴标签,但是他自然也是富豪。

  • 老子的孙子长大了,自然也是富豪。

这就是人们口中戏称的富一代,富二代,富三代。虽然叫法不同,好像好多个标签,但其实事情的本质也就是他们有一张共同的标签,也就是老子身上的那张富豪的标签。

5. @Repeatable

Repeatable是可重复的意思。@Repeatable是Java 1.8才加进来的,所以算是一个新的特性。

什么样的注解会多次应用呢?通常是注解的值可以同时取多个。

举个例子,一个人他既是程序员又是产品经理,同时他还是个画家。

@interface Persons {
    Person[]  value();
}
@Repeatable(Persons.class)
@interface Person{
    String role default "";
}
@Person(role="artist")
@Person(role="coder")
@Person(role="PM")
public class SuperMan{
}

注意上面的代码,@Repeatable注解了Person。而@Repeatable后面括号中的类相当于一个容器注解。

什么是容器注解呢?就是用来存放其它注解的地方。它本身也是一个注解。

我们再看看代码中的相关容器注解。

@interface Persons {
    Person[]  value();
}

按照规定,它里面必须要有一个value的属性,属性类型是一个被@Repeatable注解过的注解数组,注意它是数组。

如果不好理解的话,可以这样理解。Persons是一张总的标签,上面贴满了Person这种同类型但内容不一样的标签。把Persons给一个SuperMan贴上,相当于同时给他贴了程序员、产品经理、画家的标签。

我们可能对于@Person(role=“PM”)括号里面的内容感兴趣,它其实就是给Person这个注解的role属性赋值为PM。

4.4 注解使用

注解是一系列元数据,它提供数据用来解释程序代码,但是注解并非是所解释的代码本身的一部分。注解对于代码的运行效果没有直接影响。

注解有许多用处,主要如下:

  • 提供信息给编译器: 编译器可以利用注解来探测错误和警告信息
  • 编译阶段时的处理: 软件工具可以用来利用注解信息来生成代码、Html文档或者做其它相应处理。
  • 运行时的处理: 某些注解可以在程序运行的时候接受代码的提取
    值得注意的是,注解不是代码本身的一部分。

注解主要针对的是编译器和其它工具软件(SoftWare tool)。

当开发者使用了Annotation 修饰了类、方法、Field 等成员之后,这些 Annotation 不会自己生效,必须由开发者提供相应的代码来提取并处理 Annotation 信息。这些处理提取和处理 Annotation 的代码统称为 APT(Annotation Processing Tool)。

4.4.1 注解的提取

注解通过反射获取。处理注解有以下方法:

方法体返回值说明
isAnnotationPresent(Class<? extends Annotation> annotationClass)boolean其中参数为Annotation的Class对象,判断一个Class对象是否应用了某个注解
getAnnotation(Class<? extends Annotation> annotationClass)Annotation获取Class的指定的 Annotation 对象。有则返回Annotation对象,无则返回null
getAnnotations()Annotation[]获取该Class对象上的所有注解

例如:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@interface TestAnnotation {
    public int id() default -1;
    public String msg() default "Hi";
}
@TestAnnotation()
public class Test {
    public static void main(String[] args) {
        boolean hasAnnotation = Test.class.isAnnotationPresent(TestAnnotation.class);
        if (hasAnnotation) {
            TestAnnotation testAnnotation = Test.class.getAnnotation(TestAnnotation.class);
            System.out.println("id:"+testAnnotation.id());
            System.out.println("msg:"+testAnnotation.msg());
        }
    }
}

运行结果:

id:-1
msg:

上面的例子中,只是检阅出了注解在类上的注解,其实属性、方法上的注解照样是可以的。同样还是要假手于反射。

@TestAnnotation(msg="hello")
public class Test {
    @Check(value="hi")
    int a;
    @Perform
    public void testMethod(){}
    @SuppressWarnings("deprecation")
    public void test1(){
        Hero hero = new Hero();
        hero.say();
        hero.speak();
    }
    public static void main(String[] args) {
        boolean hasAnnotation = Test.class.isAnnotationPresent(TestAnnotation.class);
        if (hasAnnotation) {
            TestAnnotation testAnnotation = Test.class.getAnnotation(TestAnnotation.class);
            System.out.println("id:" + testAnnotation.id());
            System.out.println("msg:" + testAnnotation.msg());
        }
        try {
            Field a = Test.class.getDeclaredField("a");
            a.setAccessible(true);
            Check check = a.getAnnotation(Check.class);
            if (check != null) {
                System.out.println("check value:" + check.value());
            }
            Method testMethod = Test.class.getDeclaredMethod("testMethod");
            if (testMethod != null) {
                Annotation[] ans = testMethod.getAnnotations();
                for(int i = 0; i < ans.length; i++) {
                    System.out.println("testMethod annotation:" + ans[i].annotationType().getSimpleName());
                }
            }
        } catch (NoSuchFieldException e) {
            /*...*/
        }
    }
}

运行结果:

id:-1
msg:hello
check value:hi
testMethod annotation:Perform

需要注意的是,如果一个注解要在运行时被成功提取,那么@Retention(RetentionPolicy.RUNTIME)是必须的。

4.4.2 注解使用实例1

本实例来源于网络:

我要写一个测试框架,测试程序员的代码有无明显的异常。

  • 程序员A:我写了一个类,它的名字叫做NoBug,因为它所有的方法都没有错误。
  • 我:自信是好事,不过为了防止意外,让我测试一下如何?
  • 程序员A:怎么测试?
  • 我:把你写的代码的方法都加上 @Jiecha 这个注解就好了。
  • 程序员A:好的。
package ceshi;
import ceshi.Jiecha;

public class NoBug {
    @Jiecha
    public void suanShu(){System.out.println("1234567890");}
    @Jiecha
    public void jiafa(){System.out.println("1+1="+1+1);}
    @Jiecha
    public void jiefa(){System.out.println("1-1="+(1-1));}
    @Jiecha
    public void chengfa(){System.out.println("3 x 5="+ 3*5);}
    @Jiecha
    public void chufa(){System.out.println("6 / 0="+ 6 / 0);}
    
    public void ziwojieshao(){System.out.println("我写的程序没有 bug!");}
}

上面的代码,有些方法上面运用了@Jiecha注解。这个注解是我写的测试软件框架中定义的注解。

package ceshi;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

@Retention(RetentionPolicy.RUNTIME)
public @interface Jiecha {
}

然后,我再编写一个测试类TestTool就可以测试NoBug相应的方法了。

package ceshi;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class TestTool {
    public static void main(String[] args) {
        NoBug testobj = new NoBug();
        Class clazz = testobj.getClass();
        Method[] method = clazz.getDeclaredMethods();
        StringBuilder log = new StringBuilder();
        int errornum = 0;
        for ( Method m: method ) {
            if ( m.isAnnotationPresent( Jiecha.class )) {
                try {
                    m.setAccessible(true);
                    m.invoke(testobj, null);
                } catch (Exception e) {
                    // TODO Auto-generated catch block
                    //e.printStackTrace();
                    errornum++;
                    log.append(m.getName());
                    log.append(" ");
                    log.append("has error:");
                    log.append("\n\r  caused by ");
                    log.append(e.getCause().getClass().getSimpleName());
                    log.append("\n\r");
                    log.append(e.getCause().getMessage());
                    log.append("\n\r");
                } 
            }
        }
        log.append(clazz.getSimpleName());
        log.append(" has  ");
        log.append(errornum);
        log.append(" error.");
        // 生成测试报告
        System.out.println(log.toString());
    }
}

测试的结果是:

1234567890
1+1=11
1-1=0
3 x 5=15
chufa has error:
  caused by ArithmeticException
/ by zero
NoBug has  1 error.

提示 NoBug 类中的chufa()这个方法有异常,这个异常名称叫做ArithmeticException,原因是运算过程中进行了除0的操作。所以,NoBug这个类有Bug。这样,通过注解我完成了我自己的目的,那就是对别人的代码进行测试。

4.4.3 注解使用实例2

待定加入。若该文本发布后仍未被修改,则表示未安排加入。请提示笔者加入内容或者删除这段文字。

4.4.4 注解使用实例3

待定加入。若该文本发布后仍未被修改,则表示未安排加入。请提示笔者加入内容或者删除这段文字。

5 多线程

多线程是Java的一种机制,目的是为了降低耦合和提高效率。

5.1 线程基础

对于单核的CPU来说,无法做到真正意义上的多线程;而对于多核CPU或者多个CPU同时运作可以达到多线程的作用。多线程通过提供CPU利用率来提高效率。数据库访问、磁盘IO等操作的速度比CPU执行代码速度慢很多,单线程环境下,这些操作会阻塞程序执行,导致CPU空转,因此对于会产生这些阻塞的程序来说,使用多线程可以避免在等待期间CPU的空转,提高CPU利用率。

补充:对于2023年来说,如今市场上普遍的CPU都是六核或者八核,同时Intel和AMD都具备超线程技术,一个核心能够做到两个线程,这使得程序员开发多线程提高效率越来越有必要。

超线程技术利用特殊的硬件指令,把两个逻辑内核模拟成两个物理芯片,让单个处理器都能使用线程级并行计算,进而兼容多线程操作系统和应用软件,减少CPU的闲置时间,提高CPU的运行效率。支持超线程的CPU能同时执行两个线程,但超线程中的两个逻辑处理器并没有独立的执行单元、整数单元、寄存器甚至缓存等资源。他们在运行过程中仍需要共用执行单元、缓存和系统总线接口。在执行多线程时两个逻辑处理器均是交替工作,如果两个线程都同时需要某一个资源时,其中一个要暂停并要让出资源,要待那些资源闲置时才能继续。因此,前面说超线程技术仅可看作是对单个处理器运算资源的优化利用。

操作系统通常提供两种机制实现多任务同时执行:多进程和多线程。进程和线程的区别在于进程拥有独立的内存空间,而线程通常与其他线程共享内存空间,共享内存空间有利于线程之间的通信、协调配合,但共享空间可能导致多个线程在读写内存时数据不一致,这是使用多线程必须面对的风险。相比较进程来说,线程是一种更轻量级的多任务实现方式,创建、销毁一个线程消耗的计算资源比运行要小得多。

多线程的应用在于:提高运算速度、缩短响应时间。

实际上,在Java中,每次程序运行至少启动2个线程。一个是main线程(通常称为主线程),一个是垃圾收集线程。因为每当使用Java命令执行一个类的时候,实际上都会启动JVM,而每一个JVM就是在操作系统中启动了一个进程。

5.1.1 创建线程

创建线程有两种方式:实现Runnable接口、继承Thread类(还存在一种实现Callable接口,配合其他属性使用)。线程是驱动任务运行的载体,在Java中,要执行的任务定义在run()方法中,线程启动将执行run()方法,方法执行完后任务就执行完成。

1. 实现Runnable接口

public class Test implements Runnable {
    @override
    public void run() {
        /*function*/
    }
    public static void main(String[] args) {
        new Thread(new Test()).start();
    }
}

2. 继承Thread类

public class Test extends Thread {
    //没有重写的注解
    public void run() {
        /*function*/
    }
    public static void main(String[] args) {
        new Test().start();
    }
}

实现Runnable接口比继承Thread类所具有的优势:

  • 适合多个相同的程序代码的线程去处理同一个资源。

  • 可以避免java中的单继承的限制。

  • 增加程序的健壮性,代码可以被多个线程共享,代码和数据独立。

  • 线程池只能放入实现Runable或Callable类线程,不能直接放入继承Thread的类。

所有的多线程代码都是通过运行Thread的start()方法来运行的。因此,不管是扩展Thread类还是实现Runnable接口来实现多线程,最终还是通过Thread的对象的API来控制线程的,熟悉Thread类的API是进行多线程编程的基础。

start()方法并不是单纯调用run() 方法,而是启动一个分支线程,在JVM中开辟一个新的栈空间。简单来说,start()方法只是使得该线程变为可运行态(Runnable),什么时候运行是由操作系统决定的。下面介绍线程状态会更方便了解线程的运行逻辑。

注意:因为父类的run()方法并没有抛出异常,所以在调用Thread类的一些方法时只能配合try-catch语句使用。

5.1.2 线程状态

线程分为5个状态:

  1. 新建状态(New):新创建了一个线程对象。
  2. 就绪状态(Runnable):线程对象创建后,其他线程调用了该对象的start()方法。该状态的线程位于可运行线程池中,变得可运行,等待获取CPU的使用权。
  3. 运行状态(Running):就绪状态的线程获取了CPU,执行程序代码。
  4. 阻塞状态(Blocked):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。阻塞的情况分三种:
    1. 等待阻塞:运行的线程执行wait()方法,JVM会把该线程放入等待池中。(wait()会释放持有的锁)
    2. 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池中。
    3. 其他阻塞:运行的线程执行sleep()或join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。(注意,sleep()是不会释放持有的锁)
  5. 死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。
5.1.3 优先级

在Java中有一个程序负责为每一个线程分配CPU时间片,负责分配时间片的程序称为调度器。调度器会根据一个基本原则给每个线程发配CPU时间片:越紧急的线程分配的时间片越多,以利于紧急的任务优先完成。但这并不意味着不紧急的线程没有机会分配到时间片,没有执行的机会,只能说紧急的任务有更高频率分配到时间片。调度器的时间分配原则与操作系统有密切的关系,不同操作系统对任务紧急程度的分级也不一样。

Java通过线程的优先级确定CPU时间片的分配频率,通过Thread类的getPriority()方法和setPriority()方法可以获取和设置线程的优先级。Java线程的优先级分为10个等级,最低级是1级,最高级是10级。Java用常量MIN_PRIORITY、NORM_PRIORITY、MAX_PRIORITY分别代表1级、5级、10级。优先级的数值本身没有什么意义,而不同线程优先级的比较才是分配CPU时间片频率的参考。

注意两点:

  1. main线程的默认优先级是:5
  2. 优先级较高的,只是抢到的CPU时间片相对多一些。大概率方向更偏向于优先级比较高的。
5.1.4 让位

Java通过yield ()方法暂停当前正在执行的线程对象,并执行其他线程。yield ()方法的执行会让当前线程 从“运行状态”回到“就绪状态”。

注意:在回到就绪状态之后,还有可能会再次分配到时间片。

5.1.5 休眠

休眠是指线程在运行的过程中暂时停止运行,线程调度器不会线程分配时间片。线程在休眠期间不占用CPU时间,在休眠结束后,线程继续运行。调用Thread类的sleep()方法可以使当前线程休眠。sleep(long millis)方法接受以毫秒为单位的休眠时间,时间结束后,线程自动唤醒。线程休眠时间的精确性与系统时钟有关系。

sleep()方法会抛出中断异常,通常需要配合try-catch语句使用。

5.1.6 加入

在很多情况下,主线程生成并起动了子线程,如果子线程里要进行大量的耗时的运算,主线程往往将于子线程之前结束,但是如果主线程处理完其他的事务后,需要用到子线程的处理结果,也就是主线程需要等待子线程执行完成之后再结束,这个时候需要使用join()方法。同时还有更好的方法,请跳转该文章5.6.2。

join()是Thread类的一个方法,启动线程后直接调用。join()的作用是:“等待该线程终止”,这里需要理解的就是该线程是指的主线程等待子线程的终止。也就是在子线程调用了join()方法后面的代码,只有等到子线程结束了才能执行。

join()方法会抛出中断异常,通常需要配合try-catch语句使用。

下面演示了join()方法的使用:

public static void main(String[] args) {
    Thread threadA = new Thread("A");
    threadA.start();
    /*function*/
    try {
        threadA.join();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    /*function*/
}

此时mian方法会等待threadA结束后才y会结束程序。程序在main线程中调用threadA的join方法,则main线程放弃CPU控制权,并返回threadA继续执行直到threadA执行完毕,所以结果是threadA执行完后,才到主线程执行,相当于在main线程中同步threadA,threadA执行完了,main线程才有执行的机会。

join()方法的底层实现原理时wait()方法,该方法将在线程协作中说明。

5.1.7 中断

线程在任务的run()方法执行完毕,或者run()方法抛出未捕获的异常时,线程将终止。由于外部的原因,其他线程可能希望终止另外一个线程的正常执行,Thread类的interrupt()方法可以用来请求终止线程。

interrupt()方法并不能直接强制线程的执行,而是设置线程对象的中断状态。每个线程都有一个boolean型的属性标志线程是否被外部中断(这个标志被称为中断状态),如果在线程对象上调用interrupt()方法,中断状态就会被设置为true,标志已经有其他线程请求该线程中断运行。从以上分析中可以看出,实际上线程中断和线程终止运行没有直接的关系,线程是否对外部的中断做出相应,或者终止线程的运行,完全由线程本身做出决定。

线程是否终止运行线程本身做出决定是合理的。每个线程都是一个完整的指令运行序列,线程必须保证所执行的任务在任何时候必须得到完整的处理,而不能随意地放弃部分或者全部任务的执行,如果这样随意对待任务的执行是非常危险的。例如,在医用X光机中用一个线程来管理X光的打开或者关闭,如果线程已经打开X光,在病人检查过程中,另外一个线程强制终止这个线程的执行,将带来危险的结果。基于以上原因,在Java早期版本中,用于强制停止和暂停线程运行的stop()、supend()方法已遭到弃用。

线程在合适的时机对外部的中断请求做出响应也是必需的。线程应该在能保证任务完整性的前提下时常检查自身的中断状态,并作出合适的响应。在线程任务定义中,经常采用以下模式的代码以便对中断做出响应:

//如果线程没有被请求中断,并且还有未完成的工作,则继续执行循环体
while (还有工作未完成) {
    if (Thread.currentThread().isInterrupt()) {
        //响应中断请求,首先决定是否终止线程,如果要终止线程,需要完成必须的结束工作
        //(例如关闭资源占用)后退出run()方法
    }
    //处理未完成的工作
}

在以上代码中,通过静态方法Thread.currentThread()可以取得当前线程,通过线程对象的isInterrupted()方法获得线程对象是否被其他线程提出中断请求。Thread类还有静态方法interrupted()可以获得当前线程的中断状态,与对象方法isInterrupted()不同的是:在调用interrupted()后,会清除线程的中断状态,即置中断状态为false,而isInterrupted()不会清除中断状态。

如果线程处于不可中断阻塞状态(获取对象锁时导致的阻塞,不可中断的I/O操作导致的阻塞),线程没执行,所以没有机会检查中断状态。

如果线程处于可中断阻塞状态(sleep()、wait()、join()等方法调用产生的阻塞状态时),另外一个线程对其提出的中断请求,线程会抛出InteruptedException异常或者ClosedByInterruptException异常,并且跳出阻塞状态,线程可以通过捕获这两个异常来对中断请求做出响应。线程如果处于不可中断阻塞状态,不会请求做出响应。

5.1.8 捕获异常

线程在运行过程中,如果抛出未处理的异常,线程本身会终止。例如,如果线程的run()方法抛出异常,运行run()方法的线程已经终止,这个线程已经没有机会来处理这个异常,那么这个异常将抛向JVM,通常JVM会直接把异常显示在控制台上。

main()方法并不会捕获到线程抛出的异常,因为主线程和子线程是完全不同的两个指令序列,虚拟机以同样的方式对待两个线程,因而主线程没责任来处理子线程抛出的异常,并且主线程也可能没有时间来处理主线程抛出的异常(主线程并不知道子线程何时抛出异常,此时主线程正在执行自己的指令序列,完成自己的工作,根本没有时间来处理异常)

可以使用Thread类的setUncaughtExceptionHandler()方法为任何线程安装一个异常处理器,如果线程抛出未处理的异常,则由这个处理器进行处理。也可以用Thread类的静态方法setUncaughtExceptionHandler()为所有线程设置一个默认的异常处理器。

下面演示了线程异常处理器的使用:

public class Test {
    public static void main(String[] args) {
        Thread exceptionThread = new Thread(new ExceptionHandlerThread());
        System.out.println("开始启动线程0");
        exceptionThread.setUncaughtExceptionHandler(new UncaughtExceptionTestHandler());
        exceptionThread.start();
        System.out.println("线程0启动完成");
        Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionDefaultTestHandler());
        Thread exceptionThread1 = new Thread(new ExceptionHandlerThread());
        System.out.println("开始启动线程1");
        exceptionThread1.start();
        System.out.println("线程1启动完成");
    }
}
class ExceptionHandlerThread implements Runnable {
    public void run() {
        System.out.println("线程已经开始执行任务");
        throw new RuntimeException();
    }
}
class UncaughtExceptionTestHandler implements Thread.UncaughtExceptionHandler {
    public void uncaughtException(Thread t, Throwable e) {
    	System.out.printf("线程[%s]抛出异常,由UncaughtExceptionTestHandler进行处理%n",t.getName());
    }
}
class UncaughtExceptionDefaultTestHandler implements Thread.UncaughtExceptionHandler {
    public void uncaughtException(Thread t, Throwable e) {
        System.out.printf("线程[%s]抛出异常,由UncaughtExceptionDefaultTestHandler进行处理%n",t.getName());
    }
}

运行结果为:

开始启动线程0
线程0启动完成
线程已经开始执行任务
开始启动线程1
线程1启动完成
线程已经开始执行任务
线程[Thread-0]抛出异常,由UncaughtExceptionTestHandler进行处理
线程[Thread-1]抛出异常,由UncaughtExceptionDefaultTestHandler进行处理

从输出可以看出,线程的异常得到了处理,虽然Java提供了线程未捕获异常的处理机制,但还是建议为了提高程序的稳定性,在线程任务中对异常进行捕获,而不是由线程异常处理器来处理异常。

5.2 线程工具

5.2.1 线程工具类

1. 执行器 Executor

Executor时一个简单的标准化接口,用于定义类似于线程的自定义子系统,包括线程池,异步IO和轻量级任务框架。根据所使用的具体Executor类的不同,可能在新创建的线程中、现有的任务执行线程中或者调用execute()的线程中执行任务,并且可能顺序或者并发执行。Executor的子接口ExecutorService提供了多个完整的异步任务执行框架。ExecutorService管理任务的排队和安排,并允许受控制的关闭。ExecutorService的子接口ScheduledExecutorService添加了对延迟的、定期任务执行的支持。ExecutorService提供了安排异步执行的方法,可执行由Callable表示的任何函数,结果类似于Runnable。Future返回函数的结果,允许确定执行是否完成,并提供取消执行的方法。

类ThreadExecutor和ScheduledThreadPoolExecutor分别实现了ExecutorService接口和ScheduledExecutorService接口,两个类提供可调的、灵活的线程池。线程池可以解决两个不同问题:由于减少了每个任务调用的开销,他们通常可以在执行大量异步任务时提供增强的性能,并且还可以提供绑定和管理资源(包括执行集合任务时使用的线程)的方法。每个ThreadPoolExecutor还维护着一些基本的统计数据,如完成的任务数。Executor类提供大多数Executor的常见类型和配置的工厂方法,以及使用他们的几种实用工具方法。其他基于Executor的实用工具包括具体类FutureTask,它提供Future的常见可扩展实现,以及ExecutorCompletionService,它有助于协调对异步任务组的处理。

2. 队列 Queue

java.util.concurrent ConcurrentLinkedQueue类提供了高效的、可伸缩的、线程安全的非阻塞FIFO队列。java.util.concurrent中的5个实现都支持扩展的BlockingQueue接口,该接口定义了put和take的阻塞版本:LinkedBlockingQueue、ArrayBlockingQueue、SynchronousQueue、PriorityBlockingQueue和DelayQueue。这些不同的类覆盖了生产者-使用者、消息传递、并发任务执行和相关并发设计的大多数常见使用的上下文。

3. 计时 TimeUnit

TimeUnit类为指定和控制基于超时的操作提供了多重粒度(包括纳秒级)。该包中的大多数类除了包含不确定的等待之外,还包含基于超时的操作。在使用超时的所有情况中,超时指定了在表明已超时前该方法应该等待的最少时间。在超时发生后,实现会“尽力”检测超时。但是,在检测超时与超时之后再次实际执行线程之前可能要经过不确定的时间。

4. 同步器

4个类可协助实现常见的专用同步语句:

  • Semaphore时一个经典的并发工具。
  • CountDownLatch时一个极其简单但又极其常用的实用工具,用于保持给定数目的信号、事件或条件前阻塞执行。
  • CyclicBarrier时一个可重置的多路同步点,在某些并行编程风格中很有用。
  • Exchanger允许两个线程在集合点交换对象,用于多流水线设计。

(详见5.5 同步器)

5. 并发集合 Concurrent Collection

除队列外,此包还提供了几个用于多线程上下文中的Collection实现:ConcurrentHashMap、CopyOnWriteArrayList和CopyOnWriteArraySet。

此包中与某些类一起使用的Concurrent前缀,时一种简写,表明与类似的“同步”类有所不同,例如,java.util.Hashtable和Collections.synchronizedMap(new HashMap())是同步的,但ConcurrentHashMap则是“并发的”。并发集合是线程安全的,但是不允许单个排他锁定的管理。在ConcurrentHashMap这一特定情况下,它可以安全地允许进行任意数目的并发读取,以及数目可调的并发写入。需要通过单个锁定阻止对集合的所有访问时,“同步”类时很有用的,其代价是较差的可伸缩性。在期望多个线程访问公共集合的其他情况中,通常“并发”版本要更好一些。当集合是未共享的,或者仅保持其他锁定时集合是可访问的情况下,非同步集合则要更好一些。

大多数并发Collection实现(包括大多数Queue)与常规的java.util约定也不同,因为它们的迭代器提供了弱一致的,而不是快速失败的遍历。弱一致的迭代器是线程安全的,但是在迭代时没必要冻结集合,所以它不一定反映自迭代器创建以来的所有更新。

6. 锁 Lock

为锁定和等待条件提供一个框架的接口和类,它不同于内置同步和监视器。该框架允许更灵活地使用锁定和条件,但以更难用的语法为代价。

Lock接口支持那些语义不同(重入、公平等)的锁定规则,可以在非阻塞式结构的上下文(包括hand-over-hand和锁定重排算法)中使用这些规则。主要实现是ReentranLock。

ReadWriteLock接口以类的方式定义了一些读取者可以共享而写入者独占的锁定。此包只提供了一个实现,即ReentrantReadWriteLock,因为它适用于大部分的标准用法上下文。但程序员可以创建自己的、适用于非标准要求的实现。

Condition接口描述了可能会与锁定有关联的条件变量。这些变量在用法上与使用Object.wait访问的隐式监视器类似,但提供了更强大的功能。需要特别指出的是,单个Lock可能与多个Condition对象关联。为了避免兼容性问题,Condition方法的名称与对应的Object版本中的不同。

AbstractQueuedSynchronizer类是一个非常有用的超类,可以用来定义锁定以及依赖于排队阻塞线程的其他同步器。LockSupport类提供了更低级别的阻塞和接触阻塞支持,者对那些实现自己的定制锁定类的开发人员很有用。

5.2.2 执行器

执行器(Executor)能辅助管理Thread对象,从而简化并发程序的开发。Executor在客户程序与任务之间提供了一个间接层。这个间接层负责执行任务,因而客户程序只需定义并发执行的任务,提交给执行器,执行器负责并行地执行任务,并以合适的方式报告执行结果。

补充:Executor是一个接口,位于java.util.concurrent包下,它的主要作用是为我们提供任务与执行机制(包括线程使用和调度细节)之间的解耦。比如我们定义了一个任务,我们是通过线程池来执行该任务,还是直接创线程来执行该任务呢?通过Executor就能为任务提供不同的执行机制。执行器的实现方式各种各样,常见的包括同步执行器、一对一执行器、线程池执行器、串行执行器等等。

  • 同步执行器(DirectExecutor)——该执行器会运行放入的Runnable实例的run()方法。

    class DirectExector implements Executor {
        @Override
        public void execute(Runnable command) {
            command.run();
        }
    }
    
  • 一对一执行器(ThreadPerTaskExecutor)——该执行器会对于放入的Runnable实例创建一个新的线程并执行任务。

    class ThreadPerTaskExecutor implements Executor {
        @Override
        public void execute(Runnable command) {
            new Thread(command).start();
        }
    }
    
  • 线程执行器(ThreadPoolExecutor)——该执行器拥有线程池功能的执行器,任务提交后将由线程池负责执行。
    该执行器会不断地查找任务队列中是否含有任务。通过调用该执行器的execute()方法会将放入的Runnable实例放入任务队列中,然后执行器检测之后会调用Runnable的run()方法。

  • 串行执行器(SerialExecutor)——该执行器是一种具有串行功能的执行器,所有任务被加入到一个先进先出队列中,然后内部的另外一个执行器会按照队列的顺序执行任务。前一任务执行完后负责启动后一任务的执行,这样就形成了串行。

5.2.3 线程池

构建一个新的线程是有一定代价的,因为涉及与操作系统的交互。若程序中创建了大量的生命期很短的线程,应该使用线程池。一个线程池中包含许多准备运行的空闲线程。将Runnable对象交给线程池,就会有一个线程调用run()方法。当run()方法退出时,线程不会死亡,而是在池中准备为下一个请求提供服务。

另一个使用线程池的理由是减少并发线程的数目。创建大量线程会大大降低性能甚至使虚拟机崩溃。如果有一个会创建许多线程的算法,应该使用一个线程数“固定的”线程池以限制并发线程的总数。

执行器(Executor)类有许多静态工厂方法用来构建线程池:

方法描述
Executors.newCachedThreadPool()必要时创建新线程;空线程被保留60秒
Executors.newFixedThreadPool()该池包含固定数量的线程;空闲线程会一直被保留
Executors.newSingleThreadPool()只有一个线程的“池”,该线程顺序执行每一个提交的任务
Executors.newScheduledThreadPool()用于预定执行而构建的固定线程池,替代java.util.Timer
Executors.newSingleThreadScheduledExecutor()用于预定执行而构建的单线程“池”

下面演示了Executors.newCachedThreadPool的使用:

public static void main(String[] args) {
    ExecutorSerivce executorService = Executors.newCachedThreadPool();
    for (int i = 1; i <= 10; i++) {
        Thread thread = new Thread();
    	executorService.execute(thread);
    }
    executorService.shutdown();
}

调用ExecutorService的shutdown()方法后,ExecutorService拒绝接受新任务,而在调用shutdown()之前提交的任务会继续执行,如果所有任务执行完毕,ExecutorService会停止运行,通过isTerminated()方法可以获取任务是否执行完毕,通过isShutDown()方法可以获得执行器是否已经关闭。

shutdownNow()方法与shutdown()方法类似,但调用shudown()方法后,执行器试图停止所有提交的任务,如果任务还未开始执行,则不再执行这些任务,如果任务已经开始执行,则尝试通过Thread.interrupt()中断任务的执行,如果任何任务屏蔽或无法响应中断,则可能永远无法终止该任务。

5.2.4 返回值的任务

Runnable时执行任务的独立任务,但是它无法返回值,在实际编程中,经常需要在线程执行完成后,向主线程任务执行结果。在Java SE5中引入的Callable接口规定了一种有返回值的任务。Callable是一种具有类型参数的泛型接口,它的类型参数表示任务执行后的返回值类型。Callable有唯一的方法call(),相当于Runnable接口的run()方法,唯一不同的是方法call()有返回值,而run()没有。

可以通过ExecutorService的submit()方法向执行器提交具有返回值的任务。submit()方法提交任务后返回一个Future对象。

Future表示异步计算的结果。它提供了检查计算是否已经完成的方法,以等待计算的完成,并检索计算的结果。可以用isDone()方法检测任务是否已经执行完成,如果任务执行完成,则返回true。计算完成后只能使用get()方法来检索结果,如果有必要,计算完成前可以阻塞此方法。取消则由cancel()方法来执行,可以通过isCancelled()方法检测任务是否已经取消。执行器的shutdownNow()方法也可以导致任务取消,还提供了其他方法,以确定任务是正常完成还是被取消。一旦计算完成,就不能再取消计算。如果为了可取消性而使用Future但又不提供可用的结果免责可以声明Futrue<?>形式类型,并返回null作为基础任务的结果。

下面演示了Callable和Futrue的使用:

public class Test {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newCachedThreadPool();
        ThreadTest thread = new ThreadTest();
        Future<Integer> future = executorService.submit(thread);
        while (true) {
            if (future.isDone()) {
                try {
                    int x = future.get();
                } catch (InterruptedException e) { }
                break;
            } else if (future.isCancelled()) break; 
            try {
                sleep(100);
            } catch (InterruptedException e) { }
        }
    }
}
class ThreadTest implements Callable<Integer> {
    public Integer call() throw Exception {
        sleep(1000);
        return 1;
    }
}

不局限于单个线程使用,可以配合多个线程使用,同时可以再自定义一个Thread子类的Monitor类,配合 ConcurrentHashMap<Future<int>, int>用来检测输出结果。总之,熟练使用这些工具类可以更简便地设计多线程。

5.3 线程共享资源

在实际应用中,多个线程常常希望共享数据。例如,在模拟股票交易的程序中,一个线程可能要减少一个账户的股票金额,而另外一个线程可能要增加这个账户的股票金额,两个线程就要共享账户的股票金额。

线程在CPU上执行是分时间片进行的,线程调试器可能随时中断一个线程的指令执行,而去执行另外一个线程的指令,并且什么时候会被中断是不可能预测的,这种分时执行可能产生意想不到的结果。

5.3.1 竞争条件

当线程需要修改一个变量的时候,它们会先复制得到的数据,然后通过加减,将自己得到的数据替换成原来的数据。对于股票的例子,一个线程需要减少金额,一个需要增加金额,那么理想状态下是这样的:

  1. “减少线程”运行。
  2. 得到当前金额;通过减法后得到减少金额;将当前金额修改为减少金额。
  3. “减少线程”结束。
  4. “增加线程”运行。
  5. 得到当前金额;通过加法后得到增加金额;将当前金额修改为增加金额。
  6. “增加线程”结束。

但因为线程的不确定性,很有可能两个线程所执行的操作顺序是如下情况:

  1. “减少线程”运行;
  2. 得到当前金额;通过减法后得到减少金额;
  3. “增加线程”运行;
  4. 得到当前金额;通过加法后得到增加金额;
  5. 将当前金额修改为减少金额。
  6. “减少线程”结束。
  7. 将当前金额修改为增加金额。
  8. “增加线程”结束。

这明显是错误的,增加金额覆盖了减少金额的结果。多个线程在执行过程中相互干扰的现象就称为竞争条件(race condition)。

真正在虚拟机上执行的是编译后的虚拟机指令,虚拟机指令比Java代码的粒度更小,这种可以随意打断产生的结果可能更为混乱。

如果多个线程在执行过程中不相互共享数据,竞争条件就不会产生;如果在读写共享数据时不会被任意打断后插入其他线程对共享数据读写的代码,也不会产生竞争条件。由于功能的需要,不共享数据是不可能的,唯一解决的办法就是调度器保证在读写共享数据时不被任意打断后插入其他线程对共享数据读写的代码,Java用称为锁的机制来保护这一点。

5.3.2 Lock对象

多个线程对共享资源的竞争读写可能导致程序执行的混乱,产生错误的运行结果。Java通过对共享资源加锁的机制来解决对共享资源的竞争读写。加锁机制基本目标是保证在同一个时间内,只有一个线程对共享数据进行读写,如果有两个线程同时试图访问共享数据,就要排队,让队列线程中的数据依次读写共享数据。

以多人公用电话亭的例子说明锁的机制,如果有多个人想使用电话亭,为了避免使用冲突,为电话亭加上一把只能从里面开关的锁,进入电话亭的 人从里面把锁锁上,然后打电话,这时外面的人只能排队,打完电话后,里面的人打开锁出来,下一个等待的人进入电话亭,这样就可以避免两个人共占一个电话亭产生冲突。

Java提供了两种方法为共享资源加锁:一种是用synchronized关键字,另一个种是用Lock对象,两种方法的实现原理是一样的,使用Lock对象加锁更加灵活一些。

ReentrantLock类

Java的concurrent框架为共享资源加锁提供了Lock接口,类ReentrantLock实现Lock接口,是常用的锁对象。ReentranLock类通常使用的方式如下:

Lock lock = new ReentrantLock();
try {
	lock.lock();
	/*function*/
} finally {
	lock.unlock();
}

在对共享数据读写之前,通过ReentrantLock类的lock()方法获取锁(实际上应该是获取对锁的控制,或者说是获取钥匙)。在同一个时间锁只能被一个线程获取。若该锁没有被另一个线程保持,则获取该锁并立刻返回。如果该锁被另一个线程保持,则出于线程调度的问目的,阻塞当前线程,并且在获得锁之前,该线程将一直出于休眠状态。保持计数是指拥有锁的线程共几次调用了lock()方法,每调用一次保持计数加1。

如果线程对共享资源访问完毕,通过ReentrantLock类的unlock()方法试图释放对锁的拥有,为其他线程获取锁提供机会。

注意:获取锁后的代码应该采用try-finally进行保护,如果代码出现异常,线程就没有机会释放锁,导致其他线程永远没有机会访问共享资源。

ReentrantLock类被称为可重入锁,即同一个线程得到对锁的控制后,还可以继续调用这个锁的lock()方法试图获取锁,此时锁使用保持计数来记录线程有多少次获取了锁,即如果线程第一次通过调用lock()获得锁,则锁的保持计数置为1,这个线程以后每调用一次lock()方法,则保持计数加1,这个线程每调用一次unlock()方法,则保持计数减1,如果计数减到0,则线程才真正释放对锁的拥有。可以通过getHoldCount()方法查询锁的保持计数。

下面演示了锁的重入:

class Test {
    private final ReentrantLock lock = new ReentrantLock();
    public void f() {
        lock.lock();
        try {
            ref();
        } finally {
            lock.unlock();
        }
    }
    public void ref() {
        lock.lock();
        try {
            /*function*/
        } finally {
            lock.unlock();
        }
    }
}

在上面的代码中,f()方法中调用ref()方法,ref()方法也获取了对锁的控制。递归调用是另外一个需要重复获取锁的典型例子。

需要说明的是:锁能够有效地解决对共享资源竞争访问的问题,但并不是没有代价的。仔细分析可以发现,锁实际上使得线程只能顺序地调用含锁的方法,无论多少个线程并行请求锁,但在具体执行时,同一个时刻只有一个线程的读写得到处理,这对程序运行效率多线程的优势完全不能发挥。因此,使用锁的时候必须要慎重,不要滥用。在程序设计时,应该把对共享数据访问的代码尽量集中,对集中后的代码采用锁来保护。

5.3.3 锁测试与超时

线程在调用Lock对象的lock()方法获取另一个线程所持有的锁时,当前线程将会阻塞,并且其他线程不能中断阻塞。但一些使用场景并不是必须要获取锁,如果发现锁已经被其他线程占用则做其他处理,Concurrent框架对此提供了lockInterruptibly()方法、tryLock()方法、tryLock(long time, TimeUnit unit)方法。

tryLock()方法仅在调用时锁为空闲状态才获取该锁。如果锁可用,则获取锁,并立即返回true。如果锁不可用,则立即返回值false。此方法典型使用语句如下:

Lock lock = new ReentrantLock();
if (lock.tryLock()) {
    try {
        /*deal data function*/
    } finally {
        lock.unlock();
    }
} else {
    /*else function*/
}

此用法可确保如果获取了锁,则会释放锁;如果未获取锁,则不会试图将其释放。

trylock(long time, TimeUnit unit):如果所在给定等待时间内没有被另一个线程保持,且当前线程未被中断,则获取该锁,并立即返回true。如果超出了指定的等待时间,则返回值未false。如果该时间小于等于0,则此方法根本不会等待。

如果锁被另一个线程保持,则出于线程调度目的,阻塞当前线程,并且在发生一下三种情况之一以前,该线程将一直处于休眠状态:

  1. 锁由当前线程获得。
  2. 其他某个线程中断当前线程。
  3. 已超过指定的等待时间。

如果当前线程在进入此方法时已经设置了该线程的中断状态(isInterrupt()返回true),或者在等待获取锁的同时被中断,则抛出InterruptedException,并且清除当前线程的已中断状态。

lockInterruptibly():如果该锁没有被另一个线程保持,则获取该锁并立即返回。如果锁被另一个线程保持,则处于线程调度目的,阻塞当前线程,并且在发生一下两种情况之一以前,该线程将一直处于休眠状态:

  1. 锁由当前线程获得。
  2. 其他某个线程中断当前线程。

如果当前线程在进入此方法时已经设置了该线程的中断状态(isInterrupt()返回true),或者在等待获取锁的同时被中断,则抛出InterruptedException,并且清除当前线程的已中断状态。与此方法相反,lock()方法是不可以中断的。

5.3.4 synchronized关键字

前面使用Lock对象来保护对共享数据的访问,Java还提供了一种嵌入到语言内部的机制。从Java SE1.0版开始,Java中的每一个对象都有一个内部锁,使用这个内部锁能起到Lock对象类似的保护目标。synchronized是语言本身的一个关键字,通过使用synchronized关键字就能使用到对象的内部锁。

synchronized可以使用到一个方法的定义上,也可以作为语句使用到一个代码块上面。synchronized使用到方法上的方式如下:

public synchronized void f() {
    /*function*/
}

上面的f()方法就受到锁的保护,也可使用到代码块上,例如:

public void f() {
	synchronized (this) {
        /*function*/
    }
}

synchronized(this)对所包含的代码块进行保护,上面两种方式在功能上基本一样,都能保证所保护的代码一次只能被一个线程执行。

上面的代码从表面上并没有使用到锁,但实际上都是用到了内部锁,Java设计设希望锁对程序员来说是透明的,可以简化程序的设计。上面的代码相当于下面显式使用锁的代码:

public void f() {
    this.lock();
    try {
        /*function*/
    } finally {
        this.unlock();
    }
}

由于在Java中,任何对象都有内部锁,因此this.lock()是可能实现的,但获取锁是Java内部实现的。如果synchronized使用在方法上,则在执行到方法的代码时就要去获取锁,在退出方法时释放锁。如果使用在代码块上,则在进入到代码时就要去获取锁,在退出代码块时释放锁。

synchronized作为语句使用不仅能获取this对象的锁,还能获取任意对象的锁,例如:

public final Object locker = new Object();
public void f() {
    synchronized (locker) {
        /*function*/
    }
}

上面的代码就是使用locker对象的锁,相当于使用下面的显式锁:

public final Object locker = nw Object();
public void f() {
    locker.lock();
    try {
        /*function*/
    } finally {
        locker.unlock();
    }
}
5.3.5 原子性

原子性是指一个操作的一系列子操作不会被打断,这个操作一旦开始执行,要不完全成功,要不完全失败。这里的操作是一个抽象上的概念,是指程序执行过程中有特定功能、相对独立、有特定名称的执行单元。可以是一个服务、操作、Java的代码指令、JVM指令、汇编指令、CPU指令、CPU操作等,一个操作通常可以在更微观的层次上分为更小的操作,例如:

public int increase() {
    return i++;
}

其中,i++看起来应该是单一的操作,但如果从JVM指令度来看,这个操作被分为下面几条JVM指令(可以从Java自带的javap.exe程序查看class文件的指令):

public int increase();
 Code:
	0: aload_0
    1: dup
	2: getfield		#12; //Field i:I
	5: dup_x1
	6: iconst_1
	7: iadd
	8: putfild		#12; //Field i:I
	11: ireturn

实际上,JVM指令在CPU上执行也会被编译成一系列CPU指令,CPU指令也能继续往下分。例如,CPU指令集中常见的一个指令比较交换指令CAS,它完成两个操作,一个比较,一个交换,后一个完不完成依赖于前一个操作的结果,从逻辑上来说,它们是两个操作。

既然由一系列的子操作组成,那么就有肯呢个再完成到子操作的某一步时被打断,例如,被进程调度打断,被线程调度打断,甚至可能被硬件中断请求打断,如果子操作能够被打断,并且子操作又使用了公共资源,就可能造成执行的混乱,因此为了让一个操作有预期地执行结果,就要求有某一种机制保证,在特定的操作执行过程中是不允许被打断的,即保证操作的原子性。

锁机制是保证原子性的方法之一,在操作的子序列执行之前加锁,在操作完成后解除锁。例如,在方法上加上synchronized可以保证操作的原子性,例如:

public synchronized int increase() {
    return i++;
}

方法increase()就变成了一个原子操作,可以保证前面的多条指令不会被打断。

锁虽然能保证操作的原子性,但是加锁的代码运行效率较差,这是需要注意的问题。特别在一些底层代码中使用锁,例如前面的i++,甚至简单的赋值,例如:

long i = 1, j;
j = i;

其中j=i就不是原子性的,因为long是64位数,如果在32位的机器上,这个赋值必须用两条指令才能完成。如果这样简单的赋值上就使用锁,代价实在是太大了。如果不使用锁能够保证操作的原子性,是非常好的主意,但这是比较困难的一件事,除非对JVM有深入的研究。在Java的concurrent框架中提供了许多包装类用于原子的整数、浮点、数组等运算,在编写多线程程序时可以使用这些类。下面时原子类的简介:

  • AtomicBoolean:可以用原子方式更新的boolean值。

  • AtomicInteger:可以用原子方式更新的int值。

  • AtomicIntegerArray:可以用原子方式更新其元素的int数组。

  • AtomicIntegerFieldUpdater<T>:基于反射的实用工具,可以对其指定类的指定volatile int字段进行原子更新。

  • AtomicLong:可以用原子方式更新其元素的long值。

  • AtomicLongArray:可以用原子方式更新其元素的long数组。

  • AtomicLongFieldUpdater<T>:基于反射的实用工具,可以对指定类的指定volatile long字段进行原子更新。

  • AtomicMarkableReference<T>:AtomicMarkableReference维护带有标记位的对象引用,可以原子方式对其进行更新。

  • AtomicReference<T>:可以用原子方式更新的对象引用。

  • AtomicReferenceArray<T>:可以用原子方式更新其元素的对象引用数组。

  • AtomicReferenceFieldUpdater<T, V>:基于反射的实用工具,可以对指定类的指定volatile字段进行原子更新。

  • AtomicStampedReference<T>:AtomicStampedReference维护带有整数“标志”的对象引用,可以用原子方式对其进行更新。

5.3.6 线程的局部变量

多个线程同时读写共享资源将产生冲突,导致程序运行错误,如果每个线程都有独立的变量副本(独立的存储空间),每个线程对不同的副本进行读写,自然不会产生冲突,Java提供的线程局部(thread-local)变量就是这样的变量。

线程局部(thread-local)变量是一种自动化机制,可以使用相同的变量名为不同的线程创建不同的变量存储,即对象(或者类)相同的变量名,在不同的线程中访问,返回的值是不一样的。

ThreadLocal<T>类提供了线程局部(thread-local)变量。这些变量不同于他们的普通对应物,因为访问某个变量(通过其get和set方法)的每个线程都有自己的局部变量,它独立于变量的初始化副本。ThreadLocal实例通常是类中的private static字段,它们希望将状态(例如,用户的ID或事务ID)与某一个线程相关联,而不是在线程执行过程中的相对象中通过参数传递。

下面演示了ThreadLocal的使用:

public class Test extends Thread {
    private String name = null;
    public Test(String name) {
        this.name = name;
    }
    public void run() {
        PersonManager.threadName.set(this.name);
        /*function*/
        String personName = PersonManager.threadName.get();
    }
}
public static class PersonManager {
    private static ThreadLocal<String> threadName = new ThreadLocal<String>();
}

从线程的角度看,每个线程都保持一个对其线程局部变量副本的隐式引用,只要线程是活动的并且 ThreadLocal 实例是可访问的;在线程消失之后,其线程局部实例的所有副本都会被垃圾回收(除非存在对这些副本的其他引用)。

通过ThreadLocal存取的数据,总是与当前线程相关,也就是说,JVM 为每个运行的线程,绑定了私有的本地实例存取空间,从而为多线程环境常出现的并发访问问题提供了一种隔离机制。

在ThreadLocal类中有一个Map,用于存储每一个线程的变量的副本。以实现为每一个线程维护变量的副本。

概括起来说,对于多线程资源共享的问题,同步机制采用了“以时间换空间”的方式,而ThreadLocal采用了“以空间换时间”的方式。前者仅提供一份变量,让不同的线程排队访问,而后者为每一个线程都提供了一份变量,因此可以同时访问而互不影响。

5.4 线程协作

前面讲述了多个线程共享资源的问题,锁机制保证了顺序读写共享资源,在实际工作中,经常要求线程之间有更紧密的协作。例如,一类线程负责接受任务,另外一类线程负责处理任务,那么接受任务的线程在接收到新任务时要通知任务处理线程处理新任务,或者任务处理线程处理完任务后要通知任务接收器分配新任务。

Java提供了两套用于线程之间协作的机制:一个是使用Object类的wait()方法和notify()方法,另一个时使用Condition类的await()方法和signal()方法。

5.4.1 wait与notify

wait()方法可以使一个线程任务等待条件发生变化,而这个条件只能由其他线程任务来改变。例如,任务处理线程(handler)要等待任务接受线程(receiver)分配新的任务,那么任务处理线程可以采用以下代码实现等待:

//class Receiver
public void addTask(Task task) {
    synchronized () {
        this.getTasks().add(task);
    }
}
public List getTasks() {
    return this.tasks;
}
//class Handler
public void run() {
    synchronized (receiver.getTask()) {
        while(receiver.getTask().size() == 0) {};
        Task task = receiver.getTasks().get(0);
        receiver.getTasks().remove(0);
    }
} 

如果receiver中没有新任务,handler一直空循环等待,称为忙等待。忙等待虽然没做实质的事情,单线程调度器仍会给handler分配执行时间片,显示这是一种对CPU计算能力的浪费。上面代码的问题不仅如此,更糟糕的是synchronized块锁定了receiver.getTasks(),那么其他线程无法获得锁,包括receiver,即使receiver接收到新的任务,也无法添加任务列中,这样就形成了无法解决的矛盾:handler要接受到新的任务才能释放对任务列表的锁定,而receiver要获得任务列表的锁后才能向列表中添加新的任务,就会造成无休止的等待。

wait()方法可以解决这个问题,如果调用wait()方法,就会导致本线程等待,并且释放本线程拥有的锁,给其他线程提供改变条件的机会。当其他线程修改了条件后,就调用notify()(或者notifyall()通知等待的线程退出等待。如上面代码可以修改为:

//class Receiver
public void addTask(Task task) {
    synchronized (this.tasks) {
        this.getTasks().add(task);
        this.getTasks().notifyall();
    }
}
public List getTasks() {}
//class Handler
public void run() {
    synchronized (receiver.getTasks()) {
        if (receiver.getTasks().size == 0) {
            receiver.getTasks().wait();
        }
        Task task = receiver.getTasks().get(0);
        receiver.getTasks().remove(0);
    }
}

在handler类中,如果任务列表中没有任何任务就本线程等待,并且释放拥有的对receiver.getTasks()的锁,那么弱国receiver接收到新的任务,就锁定this.tasks,并添加新任务到任务列表中(注意:代码中的this.tasks,receiver.getTasks()是对同一个对象的引用),并且通知所有因为在对象tasks调用wait()方法而等待的线程试图退出等待,之所以是“试图退出等待”是因为等待的线程是否真正立刻退出等待要看是否能获取在tasks上的锁,如果获得锁就可以立刻退出,其他线程要等获得锁的线程释放后,获得锁后再推出等待,这样等待的线程顺序退出等待的状态,即等待的线程是否能退出等待需要两个条件:是否得到通知,是否获得锁。如果多个线程在等待,具体哪个线程最先获得锁,是由线程调度器决定的。

receiver类中的通知方法this.getTasks().notifyall()只是告诉任务有变化,但并不能保证一定能获得任务(这种现象被称为虚假唤醒(spurious wakeup)),因此handler类中等待的代码应该修改为下面的形式:

//class handler
public void run() {
    synchronized (receiver.getTasks()) {
        try {
            while (receiver.getTasks().size() == 0) {
            	receiver.getTasks().wait();
        } catch (InterruptedException e) {}
        Task task = receiver.getTasks().get(0);
        receiver.getTasks().remove(0);
    }
}

if改为while后,就意味着真正获得锁后,并且还有待分配的任务时,才真正能取得任务。

关于wait()和notifyall()还有如下问题需要说明:

  1. wait()方法还有形如void wait(long timeout)的重载形式,接受一个时间参数,表示等待的最长时间,如果超过时间,线程退出等待。
  2. 和notifyall()功能类似的还有notify()方法,notify()只通知一个在等待的线程,而不是所有等待的线程,具体通知哪一个线程,有线程调度器根据相关的策略选择。要谨慎使用notify(),如果得到通知的线程不能处理很好地响应通知,而其他线程有没有机会得到通知,很可能造成等待的线程永远等待下去。如果正确使用notify(),其效率要比notifyall()高。
  3. 调用wait(),notify(),notifyall()的线程必须拥有锁,即这些方法必须在同步方法(synchronized)或者同步代码块中调用,否则在运行时将抛出IllegalMonitorStateException异常。
  4. sleep()方法、wait()方法都有使当前线程等待的功能,但sleep()方法与锁没关系,即sleep()不必再同步方法(synchronized)或者同步代码块中调用;sleep()也不存在释放锁的问题。
  5. 如果线程在调用wait()方法后处于等待状态时,线程被中断(其他线程调用本线程的interrupt()方法),将抛出InterruptedException异常,并且清除当前线程的中断状态(即isInterrupted()返回false)。
5.4.2 Condition对象

在concurrent框架中提供了显示的、更加灵活的工具类来实现线程间的协作。使用互斥并允许等待的基本类时Condition,可以通过在Condition上调用await()方法来实现线程等待。当外部条件发生变化,意味着某个任务可以继续执行时,可以调用Condition的signalAll()方法,或者signal()方法来通知等待的线程,从而结束等待线程的等待。

与wait()方法不同的是,可以在锁对象上创建多个Condition对象,每个Condition对象代表一中不同的等待类型。

Condition是个接口,基本的方法就是await()和signal()方法。

Condition依赖于Lock接口,生成一个Condition的基本代码是lock.newCondition()。

调用Condition的await()和signal()方法,都必须在lock保护之内,就是说必须在lock.lock()和lock.unlock之间才可以使用

  • Conditon中的await()对应Object的wait()。

  • Condition中的signal()对应Object的notify()。

  • Condition中的signalAll()对应Object的notifyAll()。

笔者尝试了很多方法,发现Condition只能配合锁对象进行使用,而无法配合Object对象使用(无法获取Object的锁对象)。简单来说,Java语言设计师创建Condition对象的目的是为了配合Lock对象独家使用,synchronized没有任何可以配合的方法。因为设计Lock对象的目的就是为了更加灵活地使用锁机制,而Condition对象将灵活的锁机制发展为灵活的协作机制。相比较Object的等待和唤醒,Condition能够提供能加丰富的唤醒角度。

下面演示了Condition类的使用:

public class Test {
    public static void main(String[] args) {
        Receiver receiver = new Receiver();
        new Handler(receiver).start();
        new Handler(receiver).start();
        receiver.f();
    }
}
class Receiver {
    final Lock lock = new ReentrantLock();;
    final Condition = lock.newCondition();
   
    public void f() {
        lock.lock();
        try {
            /*function*/
        } finally {
            lock.unlock();
        }
    }
}
class Handler extends Thread {
    final Receiver receiver;
    final Lock lock;
    final Condition condition;
    public Handler(Receiver receiver) {
        this.receiver = receiver;
        this.lock = receiver.lock;
        this.condition = receiver.condition;
    }
    
    @Override
    public void run() {
        lock.lock();
        try {
            condition.await();
            /*function*/
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}

Condition类的等待方法除了await()方法,还有以下等待方法:

  1. boolean await(long time, TimeUnit unit):当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态。
  2. long awaitNanos(long nanosTimeout):当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态。
  3. void awaitUninterruptibly():当前线程在接到信号之前一直处于等待状态。
  4. boolean awaitUntil(Date deadline):当前线程在接到信号、被中断或到达指定最后期限之前一直处于等待状态。
5.4.3 死锁

线程在进入同步(synchronized)方法或者同步代码块时,就需要获得锁,如果此时锁已经被其他线程拥有,那么这个线程就会处于阻塞状态,直到其他线程释放锁,阻塞的这个线程获得锁,阻塞可能出现一种极端的情况:某个线程任务的完成需要等待另一个线程的任务完成,而后者又在等待别的任务,形成一个等待链,如果等待链的最后一个任务等待的正是等待链的第一个任务,等待链最终形成等待环,在等待环中线程都不能继续,这种无休止的等待就是死锁。

例如,你购买匡威的鞋子需要穿匡威的鞋子才能进匡威的店;高度近视的人,眼镜丢了,要先找到眼镜,才能看清东西,才能找到眼镜;翻墙软件需要注册账号,但注册账号的网站需要翻墙。这都是死锁的现实例子。

根据上面的例子,可以总结出线程死锁必须同时满足以下4个条件:

  1. 互斥条件,即资源是不能被共享的。例如注册账号和翻墙是互斥的,两个事情不能同时达到。
  2. 至少有一个线程在使用一个资源却在等待另一个线程所持有的一个资源。例如需要买匡威才能穿匡威,需要穿匡威才能买匡威。
  3. 资源不能被线程抢占。例如,不能在找到眼镜之前就突然看清东西;不能直接不翻墙就注册账号。
  4. 必须有循环的等待,永远等待。例如,永远翻不了墙。

如果死锁要发生,必须同时满足以上四个条件;所以要防止死锁,只需要破外其中一个条件就可以了。例如,可以线上购买匡威;让其他人帮忙找到眼镜。在程序中,最容易的办法是破坏第4个条件,将某一个线程的等待设置为时间等待,超出时间就停止运行

5.5 同步器

4个类可协助实现常见的专用同步语句:

  • Semaphore时一个经典的并发工具。
  • CountDownLatch时一个极其简单但又极其常用的实用工具,用于保持给定数目的信号、事件或条件前阻塞执行。
  • CyclicBarrier时一个可重置的多路同步点,在某些并行编程风格中很有用。
  • Exchanger允许两个线程在集合点交换对象,用于多流水线设计。
5.5.1 Semaphore

Semaphore的意思是信号量。

Semaphore的原理

Semaphore内部维护了一个计数器,其值为可以访问的共享资源的个数。一个线程要访问共享资源,先获得信号量,如果信号量的计数器值大于1,意味着有共享资源可以访问,则使其计数器值减去1,再访问共享资源。如果计数器值为0,线程进入休眠。当某个线程使用完共享资源后,释放信号量,并将信号量内部的计数器加1,之前进入休眠的线程将被唤醒并再次试图获得信号量。

Semaphore的使用场景

  1. 多个共享资源互斥使用。
  2. 并发线程的控制。

Semaphore的使用

Semaphore通常维护一个集合。当集合存在空位时,唤醒等待线程并允许线程访问;当集合不存在空位时,使请求的线程进入休眠。简单来讲,Semaphore是wait()和notifyall()的一种衍生使用。

例如,管理员对有限个物品进行管理,当用户想要使用物品时,需要对管理员发出请求。这时候Semaphore充当的是真假指示器,代表请求是否可用。若可用,管理员通过用户的需求做出回应;若不可用,用户则排队等待。

以停车场管理员为例,下面演示了Semaphore的使用:

public class Manager {
    private static final int MAX_AVAILABLE = 5;
    private final Semaphore available = new Semaphore(MAX_AVAILABLE, true);
    private final boolean[] hasCar = new boolean[MAX_AVAILABLE];
    private final Car[] cars = new Car[MAX_AVAILABLE];

    boolean park(Car car) throws InterruptedException {
        available.acquire();
        int index = findAvailable();
        if (index != -1) {
            cars[index] = car;
            return true;
        } else return false;
    }

    boolean out(Car car) {
        int index = isParking(car);
        if (index != -1) {
            cars[index] = null;
            available.release();
            return true;
        } else return false;
    }

    private synchronized int findAvailable() {
        for (int i = 0; i < MAX_AVAILABLE; i++) {
            if (!hasCar[i]) {
                hasCar[i] = true;
                return i;
            }
        }
        return -1;
    }

    private synchronized int isParking(Car car) {
        for (int i = 0; i < MAX_AVAILABLE; i++) {
            if (hasCar[i] && cars[i] == car) {
                hasCar[i] = false;
                return i;
            }
        }
        return -1;
    }

}
class User extends Thread {
    private Manager manager;
    private final Car car = new Car();
    private boolean isPack = false;

    public void park() {
        try {
            if(manager.park(car)) isPack = true;
        } catch (InterruptedException ignored) { }
    }

    public void out() {
        if(manager.out(car)) isPack = false;
    }
    
    public void run() {
        while (!isPack) park();
        try {
            sleep(1000);
        } catch (InterruptedException ignored) { }
        out();
    }
}
class Car {
}

对于使用Semaphore强调几个特殊的点:

  1. 需要通过锁来控制boolean数组。
  2. 功能分离,查找位子和操作车分离,保证锁的使用效率。
  3. acquire()方法会抛出InterruptiontedException异常,特殊情况需要对异常进行处理。

需要说明

将信号量初始化为1,使得它在使用时最多只有一个可用的状态,从而可用做一个相互排斥的锁。这通常也成为二进制信号量,因为它只能有两种状态:一个可用的许可,或零个可用的许可。按此方法使用时,二进制信号量具有某种属性(与很多Lock实现不同),即可由线程释放“锁”,而不是由所有者(因为信号没有权的概念)。在某些专门的上下文(如死锁恢复)中这会很有用。

此类的构造方法可选地接受一个公平参数。当设置为false时,此类不对线程获取许可的顺序做保证。特别地,闯入时允许的,也就是说可以在已经等待线程队列前为调用acquire()的线程分配一个许可。从逻辑上来说,就是新线程将自己置于等待线程队列的头部。当公平设置为true时,信号量保证对任何调用获取方法的线程而言,都按照处理它们调用这些方法的顺序(即先进先出)来选择线程、获取许可。

通常,应该将用于控制资源访问的信号量初始化为公平的,以确保所有线程都可以访问资源。为其他的种类的同步控制使用信号量时,非公平排序的吞吐量优势通常要比公平考虑更为重要。

此类还提供便捷的方法来同时acquire和释放多个许可。注意:在未将公平设置为true时使用这些方法会增加不确定的延期风险。

5.5.2 CountDownLatch

CountDownLatch的意思是信号量。它是一个同步辅助类,在完成一组正在其他线程中执行的操作之前,它允许一个或多个线程一直等待。

CountDownLatch的方法

  • CountDownLatch(int count):count为需要等待的线程数量。
  • await():调用此方法的线程会被阻塞,直到CountDownLatch的count为0。
  • await(long timeout, TimeUnit unit):与上面方法相同,配置了时间等待,返回值为boolean类型。
  • countDown():会将count减1,直至为0。

用给定的计数初始化CountDownLatch。在计数到达零之前,调用await()方法的线程会一直受阻塞。之后,CountDownLatch会释放所有等待的线程,后续调用await()的线程都将立即返回。这种现象只出现一次——计数无法恢复。如果需要重置计数,需要使用CyclicBarrier。

CountDownLatch的使用

CountDownLatch是一个通用同步工具,它有很多用途。将计数1初始化的CountDownLatch用作一个简单的开/关锁存器,或入口:在通过调用countDown()的线程打开入口前,所有调用await()的线程都一直在入口处等待。用N初始化的CountDownLatch可以使一个线程在N个线程完成某项操作之前一直等待,或者使其在某项操作完成N次前一直等待。

CountDownLatch的一个有用特性是,它不要求调用countDown()方法的线程等到计时达到零时才继续,而在计数达到零之前,它只是阻塞任何线程继续通过一个await()方法。

CountDownLatch与线程的join()方法很相似,但CountDownLatch更加灵活。

一种典型的用法是:创建两个CountDownLatch,第一个作用是代表启动信号;第二个作用是代表完成信号。

  • 启动信号:在所有工作线程完成准备工作前,阻止所有工作线程的执行直至某线程准备完成。
  • 完成信号:在所有工作线程完成所有工作后,开启某项线程运行。

用Worker代表工作线程,用Driver表示某一特殊线程。下面演示了CountDownLatch的第一种使用方法:

public class Worker implements Runnable {
    private final CountDownLatch workSignal;
    private final CountDownLatch driveSignal;

    public Worker(CountDownLatch workSignal, CountDownLatch driveSignal) {
        this.workSignal = workSignal;
        this.driveSignal = driveSignal;
    }

    @Override
    public void run() {
        try {
            workSignal.await();
            /*function*/
            driveSignal.countDown();
        } catch (InterruptedException ignored) { }
    }
}

public class Driver implements Runnable {
    private final CountDownLatch workSignal;
    private final CountDownLatch driveSignal;
    
    public Driver(CountDownLatch workSignal, CountDownLatch driveSignal) {
        this.workSignal = workSignal;
        this.driveSignal = driveSignal;
    }

    @Override
    public void run() {
        try {
            /*function*/
            workSignal.countDown();
            driveSignal.await();
            /*function*/
        } catch (InterruptedException ignored) { }
    }
}

另一种典型用法是:将一个问题分成N个部分,用执行每个部分并让锁存器倒计数的Runnable来描述每个部分,然后将所有Runnable加入到Executor队列。当所有的子部分完成后,协调线程就能够通过await。(当线程必须用到这种方法反复倒计数时,可以改为使用CyclicBarrier。)

下面演示了CountDownLatch的第二种使用方法:

public class Test {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch signal = new CountDownLatch(5);
        Executor executor = Executors.newFixedThreadPool(5);
        for (int i = 1; i <= 5; i++) {
            executor.execute(new Worker(signal));
        }
        signal.await();
        /*function*/
    }
}
class Worker implements Runnable{
    private final CountDownLatch signal;

    public Worker(CountDownLatch signal) {
        this.signal = signal;
    }

    @Override
    public void run() {
        /*function*/
        signal.countDown();
        /*function*/
    }
}
5.5.3 CyclicBarrier

CyclicBarrier的意思是阻栅。它是一个同步辅助类,允许一组线程互相等待,直到到达某个公共屏障点(common barrier point)。在涉及一组固定大小的线程的程序中,这些线程必须不时地相互等待,此时CyclicBarrier很有用,因为该barrier在释放等待线程后可以重用,所以称它为循环的barrier。

CyclicBarrier支持一个可选的Runnable命令,在一组线程中的最后一个线程到达之后(但在释放所有线程之前),该命令只在每个屏障点运行一次。若在继续所有参与线程之前更新共享状态,此屏障操作很有用。

CyclicBarrier的使用

例如,几个旅行团需要途径哈尔滨,深圳,郑州,最后到达广州。旅行团中有自驾游的,有徒步的,有乘坐旅游大巴的;这些旅行团同时出发,并且每到一个目的地,都要等待其他旅行团道道此地后再同时出发,直到都到达终点站广州。

简而言之,每个旅行团每到达一个地方就调用await()方法,当所有旅行团到达后就再开始下一段旅行。

下面演示了CyclicBarrier的使用:

public class Test implements Runnable{
    private final CyclicBarrier barrier;
    private final int time;

    public Test(CyclicBarrier barrier, int time) {
        this.barrier = barrier;
        this.time = time;
    }

    @Override
    public void run() {
        try {
            for (int i = 1; i <= time; i++) {
                /*function*/
                barrier.await();
            }
        } catch (InterruptedException | BrokenBarrierException ignored) { }
    }
}

如果屏障操作再执行时不依赖于正挂起的线程,则线程组中任何线程在获得释放时都能执行该操作。为方便此操作,每次调用await()都将返回能到达屏障处的线程的索引。然后可以选择哪个线程应该执行屏障操作,例如:

if (barrier.await() == 0) {
    /*function*/
}

对于失败的同步尝试,CyclicBarrier使用了一种要么全部全么全不(all-or-none)的破坏模式:如果因为中断、失败或者超时等原因,导致线程过早地离开了屏障点,那么在该屏障点等待的所有其他线程也将通过BrokenBarrierException(如果它们几乎同时被中断,则用InterruptiontedException)以反常的方式离开。

5.5.4 Exchanger

Exchanger的意思是交换器。它可以用于两个线程进行数据交换,线程将要交换的数据提交给交换器的exchange()方法,交换器负责匹配伙伴线程,并且在exchange方法返回时接收其伙伴的返回数据。

Exchanger可能被视为SynchronousQueue的双向形式。Exchanger可能在应用程序(例如遗传算法和管道设计)中很有用。

Exchange的使用

下面演示了Exchange的使用:

public class Test implements Runnable{
    private final Exchanger<Objects> exchanger;

    public Test(Exchanger<Objects> exchanger) {
        this.exchanger = exchanger;
    }

    private Objects f() {
        return null; 
    }
    
    @Override
    public void run() {
        try {
            Objects o = f();
            o = exchanger.exchange(o);
            /*function*/
        } catch (InterruptedException ignored) { }
    }
}

5.6 补充

5.6.1 进程关闭后的线程

我们在运行Java代码中的main()方法时,就是开启了一个进程。Java执行时若依照代码逻辑开启新线程,即使新线程处于死循环状态,会持续运行,但仍然会因为你停止了进程而关闭。

5.6.2 线程的守护性

Java代码运行开始会默认创建两个线程,一个是主线程即通过main()方法运行的线程,一个是垃圾回收线程。主线程就是非守护线程(也可以称为被守护线程,常称为用户线程),垃圾回收线程就是守护线程,负责回收主线程的垃圾对象,当所有守护线程结束后,垃圾回收线程才会随之结束。

设置线程的守护性

在指定线程运行前,利用该线程的setDaemon()方法即可设置。值得一提的是,程序员无法设置主线程为守护线程,因为运行Java代码时必定需要开启主线程,从而主线程无法被修改其守护性。同时,一般线程默认为非守护线程,则为设置守护线程的情况下很有可能主线程运行结束后,其子线程仍然在运行。

下面演示了线程的守护性的设置:

Thread thread = new Thread();
thread.setDaemon(true);
thread.setDaemon(false);//default
thread.start();

守护与非守护的区别

  • 守护线程会随着主线程一起销毁。
  • 非守护线程与主线程的生命周期互不相连。

下面演示了守护线程的使用:

public static void test1() throws InterruptedException {
    Thread t1 = new Thread(()-> {
        while (true) {
            try {
                Thread.sleep(1000);
                System.out.println("SubThread: I am running");
            } catch (Exception ignored) {
            }
        }
    });
    t1.setDaemon(true);
    t1.start();
    Thread.sleep(3000);
    System.out.println("MainThread: have done.");
}

运行结果:

SubThread: I am running
SubThread: I am running
MainThread: have done.
//Program shutdown automatically.

下面演示了非守护线程的使用:

public static void test2() throws InterruptedException {
    Thread t1 = new Thread(()-> {
        while (true) {
            try {
                Thread.sleep(1000);
                System.out.println("SubThread: I am running");
            } catch (Exception ignored) {
            }
        }
    });
    t1.start();
    Thread.sleep(3000);
    System.out.println("MainThread: have done.");
}

运行效果:

SubThread: I am running
SubThread: I am running
MainThread: have done.
SubThread: I am running
SubThread: I am running
SubThread: I am running
//Shutdown the program by user.

6 序列化

序列化是Java提供的一种机制。

传统的Java程序中的对象对内存都有依赖关系,即如果Java程序的进程终止了,该进程所使用的内存将被收回,该进程创建的对象将被销毁。然而,有时在进程终止后,保存Java对象的信息仍然非常有用,例如资料保存、网络传输对象。Java的序列化功能能够将对象转化为二进制字节流,然后通过流写入文件在硬盘进行存储或通过网络进行传输。之后的程序可以对流化后的对象进行读写操作。

6.1 对象序列化

6.1.1 序列化实例

Java提供了两个标识用于声明序列化类:Serializable、Externalizable。只要实现这两个接口中的其中一个,类的对象都将可以进行序列化。

Java的序列化实例流程如下:

  1. 根据某种序列化算法,将对象转换成字节流。Java支持程序员自定义序列化算法,也可以直接默认使用JDK提供的序列化算法。
  2. 将字节流写入到数据流中。
  3. 使用输出流存储或传输对象自己序列。

对象序列化成字节流存储或传输后,当下一次使用它时,需要将其翻译回来原来的样子,这个过程称为反序列化,即序列化的逆过程。

Java的反序列化流程如下:

  1. 从存储介质中,读取对象的字节流。
  2. 将字节流读取到流载体中。
  3. 通过反序列化将字节流翻译成对象。

下面定义了一个可序列化的类Person:

public class Person implements Serializable {
    private static final long serialVersionUID = 1L;
    private String name = "simple";
    private Integer age = 15;
    public String getName() {
        return this.name;
    }
}

类内定义了一个长整型常量serialVersionUID,是用来识别类的,不同的类有不同的serialVersionUID,若序列化和反序列化的serialVersionUID不一致,则无法进行反序列化操作。

下面演示了序列化的使用:

public class Test1 {
    public static void main(String[] args) throws IOException {
        Person person = new Person();
        File file = new File("test.txt");
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream(file));
        objectOutputStream.writeObject(person);
        objectOutputStream.flush();
        objectOutputStream.close();
    }
}

此时文件“test.txt”就存有该对象的二进制数据。

下面演示了反序列化的使用:

public class Test2 {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        File file = new File("test.txt");
        ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(file));
        Person person = (Person) objectInputStream.readObject();
        System.out.println(person.getName());
        objectInputStream.close();
    }
}

输出结果:

simple
6.1.2 序列化注意

在Java中,并不是所有的对象都需要进行序列化,下面给出两个原因:

  1. 安全问题。Java中有的类属于敏感类,此类的对象数据不便对外公开,而序列化的对象数据很容易进行破解,无法保证器数据的安全性,因此一般这种类型的对象不会进行序列化。
  2. 资源问题。可以使用序列化字节流创建对象,而且这种创建是不受限制的,有时过多地创建对象会造成很大的资源问题。
6.1.3 序列化机制

1. 类成员序列化

在对象的类成员中,有以下三种类成员不会被序列化进都流中:

  1. 静态变量

    静态变量属于类的属性,并不会属于某个具体实例,因此在序列化的时候无须进行序列化,反序列化时,可以直接获取类的静态成员引用。

  2. 方法

    方法知识一系列操作的集合,方法不会依赖于对象,不会因对象的不同而操作不同,反序列化时,可以从类中获取方法信息。

  3. 添加transient属性的变量

    Java支持通过对变量添加transient属性来表示该变量在进行序列化时被忽略,不会被保存在序列化的结果中。

    private transient String age;
    

    常用于标记一些敏感信息。即使被标记为了transient,但仍然可以通过重写序列化方法来实现序列化。

2. 继承关系的序列化

下面两种情况:

  1. 若父类未实现serializable接口,不会被保存在序列化的结果中。
  2. 若父类已实现serializable接口,将会被保存在序列化的结果中。

3. 引用关系的序列化

例如以下情况:

public class Person implements Serializable {
    private String name = "simple";
    private Hair hair = new Hair();
    private Tool tool;
}
public class Hair implements Serializable {
    private String color = "Black";
}
public class Tool implements Serializable {
    private String name = "knife";
}

下面两种情况:

  1. 若已被初始化,例如Person中创建了一个Hair实例,则该实例以及其属性也会被序列化,并保存在序列化的结果中。
  2. 若未被初始化,例如Person中未创建Tool实例,则不会保存在序列化的结果中,因为它指向null;

需要说明的是,当引用类没有实现Serializable接口时,JVM会抛出java.io.NotSerializableException。

6.1.4 序列化标识ID

上文提到过Person中有一个变量serialVersion:

public class Person implements Serializable {
    private static final long serialVersionUID = 1L;
    }
}

为了保证序列和反序列的类相同,Java要求实现序列化接口的类都必须声明一个serialVersionUID静态属性,如果没有该属性JVM也会自动声明该属性,并未该属性赋值。该属性的值是唯一的,用于表示不同的序列化类。只有类的序列化表示完全相同,Java才会进行反序列化工作。

6.2 自定义序列化

本节将介绍Java序列化的两个重要接口Serializable和Externalizable。Serializable是一种mark interface,即标记接口,它没有任何的属性和方法,仅用于表示序列化语义;Externalizable继承自Serializable,但它的内部一定量两个方法,用于制定自定义序列化策略。

6.2.1 Serializable接口

Serializable接口中并未定义任何方法或字段,然而有时需要对序列化的对象做一些特殊的处理,以满足特定的功能需求,例如安全检测、传输信息描述等。Java为此指定了一套特殊的机制,用于解决这些特定问题。

1. 定制序列化策略

进行序列化传输时,有时不仅需要对象本身的数据,还需要传输一些额外的辅助信息,以保证信息的安全、完整和正确。Java利用反射机制,提供了一套有效的机制,允许在序列化和反序列化时,使用定制的方法进行相应的处理。当传输的双方协定好序列化策略后,只要在需要传输的序列化类中添加一组方法来实现这组策略(不是重写,而是自己创建该方法),在序列化时便会自动调用这些规定好的方法进行序列化和反序列化。方法如下:

private void writeObejct(java.io.ObjectOutputStream out) throws IOException
private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException

这两个方法的作用分别是将特定的对象写入到输出流中以及从输出流中恢复特定的对象,通过这两个方法,用户即可实现自定义的序列化。

下面演示了这两个方法的使用:

public class Person implements Serializable {
    private static final long serialVersion = 1L;
    private String name = "simple";
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    private void writeObject(ObjectOutputStream out) throws IOException {
        Date date = new Date();
        out.writeObject(date);
        out.defaultWriteObject();
        System.out.println("Serialized.");
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        Date date = (Date) in.readObject();
        Date now = new Date();
        long offset = now.getTime() - date.getTime();
        if (offset >= 100) {
            System.out.println("Overtime, deserialize failed.");
            return;
        }
        System.out.println("Allowed deserialize.");
        in.defaultReadObject();
    }
}

测试1:

public class Test {
    public static void main(String[] args) throws IOException, ClassNotFoundException, InterruptedException {
        Person person = new Person();
        File file = new File("test.txt");
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream(file));
        objectOutputStream.writeObject(person);
        objectOutputStream.flush();
        objectOutputStream.close();

        ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(file));
        person = (Person) objectInputStream.readObject();
        System.out.println(person.getName());
        objectInputStream.close();
    }
}

运行结果:

Serialized.
Allowed deserialize.
simple

测试2:

public class Test {
    public static void main(String[] args) throws IOException, ClassNotFoundException, InterruptedException {
        Person person = new Person();
        File file = new File("test.txt");
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream(file));
        objectOutputStream.writeObject(person);
        objectOutputStream.flush();
        objectOutputStream.close();

        Thread.currentThread().sleep(100);
        ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(file));
        person = (Person) objectInputStream.readObject();
        System.out.println(person.getName());
        objectInputStream.close();
    }
}

运行结果:

Serialized.
Overtime, deserialize failed.
null

2. 限制序列化对象的数量

反序列化机制不是简单地将二进制流转化为对象流然后创造对象(因为这无法在Java中实现),而是先利用对应类的空参构造方法创造一个实例(无论是否public都会被调用,因为其底层是通过反射来实现的),然后将对应的属性值传递给实例。这会导致单例模式失效,单例对象被读取两次后不同。

下面演示了单例模式被破坏:

public class Person implements Serializable {
    private static final long serialVersion = 1L;
    private static Person instance;

    private Person() {
    }

    public static Person getInstance() {
        if (instance == null) instance = new Person();
        return instance;
    }
}

测试:

public class Test {
    public static void main(String[] args) throws IOException, ClassNotFoundException, InterruptedException {
        Person person = Person.getInstance();
        File file = new File("test.txt");
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream(file));
        objectOutputStream.writeObject(person);
        objectOutputStream.flush();
        objectOutputStream.close();

        ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(file));
        Person person1 = (Person) objectInputStream.readObject();
        objectInputStream = new ObjectInputStream(new FileInputStream(file));
        Person person2 = (Person) objectInputStream.readObject();
        objectInputStream.close();
        if (person1 == person2) {
            System.out.println("==");
        } else {
            System.out.println("!=");
        }
    }
}

运行结果:

!=

结果并不是我们想要的,因为它证明了获取的并不是同一个对象,这就意味着单例模式在这种情况下会失效。

Java设计者提供了另一种方法,让我们在序列化和反序列化时,可以根据自己的需要,写入或读取指定的实例。使用这种机制,需要在序列化中添加以下两个方法:

方法体说明
private Object readResolve()如果用户在序列化类种添加了该方法,则在进行反序列时,使用该方法返回的对象,作为反序列化对象
private Object writeReplace()如果用户在序列化类中添加了该方法,则在进行序列时,序列化该类返回的对象,因此可以指定任意的对象进行序列化

下面演示了这两个方法的使用:

public class Person implements Serializable {
    private static final long serialVersion = 1L;
    private static Person instance;

    private Person() {
    }

    public static Person getInstance() {
        if (instance == null) instance = new Person();
        return instance;
    }

    private Object readResolve() {
        return getInstance();
    }
    private Object writeReplace() {
        return getInstance();
    }
}

测试类与上相同,运行结果:

==
6.2.2 Externalizable接口

Externalizable接口继承自Serializable接口,与Serializable几口不同的是,它内部定义了两个方法用于指定序列化策略和反序列化策略。这两个方法是readExternal()和writeExternal()。它的运行机制是,序列化时使用writeExternal()方法将对象写入输出流中,反序列化时,JVM首先使用一个无参的构造方法实例化一个对象,然后调用该对象的readExternal()方法反序列化一个新对象,因此要求序列化类必须拥有无参的构造函数。此外,还可以使用writeReplace()和readResolve()这两个方法来替换序列化和反序列化的对象。

下面演示了Externalizable的使用方法:

public class Person implements Externalizable {
    private static final long serialVersion = 1L;
    private String name = "simple";
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }

    @Override
    public void writeExternal(ObjectOutput out) throws IOException {
        out.write(name.getBytes());
    }

    @Override
    public void readExternal(ObjectInput in) throws IOException {
        this.name = in.readLine();
    }
}

测试:

public class Test {
    public static void main(String[] args) throws IOException, ClassNotFoundException, InterruptedException {
        Person person = new Person();
        File file = new File("test.txt");
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream(file));
        objectOutputStream.writeObject(person);
        objectOutputStream.flush();
        objectOutputStream.close();

        ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(file));
        person = (Person) objectInputStream.readObject();
        System.out.println(person.getName());
    }
}

运行效果:

simple

实现Externalizable接口的序列化类需要由用户决定传输哪些数据,并制定相应的规则,这样可以提高序列化的效率,也可以保证序列化的安全性。但这种方式要比Serializable方式复杂得多。

6.2.3 Serializable与Externalizable对比
区别SerializableExternalizable
实现复杂度实现简单,Java对其由内建支持实现复杂,由开发人员自己完成
执行效率所有对象由Java统一保存,性能较低开发人员决定哪个对象保存,可能造成速度提升
保存信息保存时占用空间大部分存储,可能造成空间减少

7 JDBC

JDBC是Java的一种扩展。

警告:学习此章节需要对数据库知识有一定了解。

JDBC(Java Data Base Connectivity)是Java设计者为数据库编程提供的一组接口。这组接口对于开发者来说,是访问数据库的工具;对于数据库提供商来说,是驱动程序的编写规范,从而保证Java可以访问他们的数据库产品。因此使用JDBC后,开发者可以更加专注于业务的开发,而不必为特定的数据库编写程序。

JDBC面向的是两个方向:开发者和数据库提供商。对于开发者来说,只要使用数据库提供商提供的驱动程序,就可以方便地访问数据库了;对于数据库提供商来说,他们的职责就是提供JDBC的规范,编写正确的驱动程序。

JDBC的架构:用户即开发者通过使用JDBC,运行相应数据库的驱动,然后获取连接,驱动就自动连接相应数据库。(此处的驱动是封装的链接器,主要用于Java和数据库的链接)JDBC使用可插拔的方式,让用户以同一种方式访问数据库。

JDBC的生命周期:

  1. 加载数据库驱动。
  2. 注册数据库驱动。
  3. 获取连接会话。
  4. 进行数据库操作。
  5. 关闭并释放连接。

在JDBC的生命周期中,需要用到几个重要的类或接口,例如Connection、Driver、DriverManager以及一些具体的驱动类。JDBC使用了接口Driver和接口Connection,JDBC设计者并未对其进行实现,具体的实现留给了数据库提供商,正是这种面向接口的编程,使得JDBC的扩展更加灵活健壮,使得我们可以对JDBC进行插拔操作。

7.1 JDBC的核心类和接口

7.1.1 java.sql.Connection

该接口的实现类负责维护Java开发者与数据库之间的会话。特定的数据库需要实现该接口,以便开发者能正确地操作数据库。开发者拥有该类的实例后,就可以访问数据库了,并可以执行特定的操作。下面列出了该接口中一些重要且常用的方法:

  1. Statement createStatement() throws SQLException:该方法返回一个用于指定静态SQL的Statement对象,开发者通过该方法获得Statement实例后,即可通过该实例执行静态SQL语句并获取返回结果。
  2. PreparedStatement prepareStatement(String sql) throws SQLException:该方法返回一个SQL命令执行器PreparedStatement,PreparedStatement与Statement不同的是,该类在初始化时需要传入一个SQL,SQL需要的条件值,可通过参数的方法设置(例如setInt(int index, int value)、setString(int index, String value)),该类会预编译SQL命令,因此在执行效率上高于Statement,但是它只能执行特定的SQL语句。
  3. void commit() throws SQLException:提交数据库操作进行的数据库操作,默认情况下connection会自动提交,即执行每条SQL语句后都会自动提交,如果取消了自动提交,则必须使用此方法进行提交,否则对数据库的操作将无效。
  4. void rollback() throws SQLException:取消当前事务的所有数据库操作,将已经修改的数据还原成初始状态。
7.1.2 java.sql.Driver接口

数据库提供商提供的驱动类必须实现此接口,才能接入到JDBC中。此接口的实现代码如下:

package java.sql;
public interface Driver {
    Connection connect(String url, java.util.Properies info) throws SQLException;
    boolean acceptsURL(String url) throws SQLException;
    DriverProperiyInfo[] getPropertyInfo(String url, Properties info) throws SQLException;
    int getMajorVersion();
    int getMinorVersion();
    boolean jdbcCompliant();
}

这就是Jvaa驱动程序的规格,所有的数据库提供商提供的驱动都必须实现这个接口,这个接口中包含6个方法:

  1. Connection connect(String url, java.util.Properies info) throws SQLException:该方法负责创建与指定数据库的会话,通过会话来进行数据库操作。该方法拥有两个参数,其中参数url表示数据库的连接地址,例如:

    jdbc:mysql://localhost:3306/student
    

    参数info表示创建会话所需的参数,参数用Properties类来进行描述,常见的参数为用户名、密码等。如果连接失败,该方法会抛出SQLException。

  2. boolean acceptsURL(String url) throws SQLException:该方法通常负责检查url是否符合特定数据的连接协议,如果url格式合法,则返回true,否则返回false。

  3. DriverProperiyInfo[] getPropertyInfo(String url, Properties info) throws SQLException:可以通过该方法获取驱动程序提供的信息。该方法返回信息由驱动开发者根据自身的情况决定。该方法需要传入两个参数:

    1. url即数据库的连接地址。
    2. info连接数据库所必须的验证等辅助信息。
  4. int getMajorVersion():该方法返回驱动程序的主版本号( 例如,版本1.5.1254.0中的1表示主版本)。

  5. int getMinorVersion():该方法返回驱动程序的此版本号( 例如,版本1.5.1254.0中的5表示次版本)。

  6. boolean jdbcCompliant():检测驱动程序是否符合JDBC标准,以及支持的SQL是不是标准的SQL或其扩展集。

7.1.3 java.sql.DriverManager

如果说数据库提供商开发的驱动是插头,那么DriverManager类就是插座。用户需要将数据库驱动注册到DriverManager中,这样才能访问和操作数据库。DriverManager是JDBC的核心和管理者,DriverManager含有两个内部类,分别是DriverInfo和DriverService(注意:这两个都比较重要)。

DriverInfo类的源代码如下:

class DriverInfo {
    Driver driver;
    Class driverClass;
    String driverClassName;
    public String toString() {
        return ("driver[className=" + driverClassName + "," + driver + "]");
    }
}

DriverInfo类用于表示驱动类信息,其中包含三个属性,分别是driver、driverClass、driverClassName:

  1. driver的类型是接口Driver,因此它可以被赋予所有具体实现类的实例。
  2. driverClass表示驱动类的具体类型,比如com.mysql.jdbc.Driver(这个老版本,新版本为:com.mysql.cj.jdbc.Driver)。
  3. driverClassName表示具体驱动类的类名。

DriverService类通过Service的配置文件,加载java.sql.Driver的实现类(即具体的数据库驱动类)以便这些类能够被实例化。

DriverService类的源代码如下:

class DriverService implements java.security.PrivilegedAction {
    Iterator ps = null;
    public DriverService() {...};
    public Object run() {
        ps = Service.providers(java.sql.Driver.class);
        try {
            while (ps.hasNext()) {
                ps.next();
            }
        } catch(Throwalble t) {}
        return null;
    }
}

在DriverService类中,首先定义了一个迭代器实例,在run方法中通过Service类的providers获取java.sql.Driver的实现类,这些实现类被配置在service文件中,可以在META-INFO的service文件夹下找到这些配置文件,获取驱动类的迭代器后,逐一遍历迭代器中的驱动。

在DriverManager中除了这两个重要的内部类外,还有很多功能方法,下面列出了DriverManager中常用的方法。

1. getConnection()

在该方法中DriverManager选择合适的驱动程序与数据库建立连接,然后把该连接返回给用户。

方法原型:public static Connection getConnection(String url, String user, String password) throws SQLException

方法源代码:

public static Connection getConnection(
    	String url, String user, String password) throws SQLException {
    java.util.Properties info = new java.util.Properties();
    ClassLoarder callerCL = DriverManager.getCallerClassLoarder();
    if (user != null) {
       info.put("user", user); 
    }
    if (password != nul) {
        info.put("password", password);
    }
    return (getConnetion(url, info, callerCL));
}
private static Connection getConnetcion(
    	String url, java.util.Properites info, ClassLoarder callerCL) throws SQLException {
    java.util.Vector drivers = null;
    synchronized (DriverManager.class) {
        if (callerCL == null) {
            callerCl = Thread.currentThread().getContextClassLoarder();
        }
    }
    if (!initialized) {
        initialize();
    }
    synchronized (DriverManager.class) {
        drivers = readDrivers;
    }
    SQLException reason = null;
    for (int i = 0; i < drivers.size(); i++) {
        DriverInfo di = (DriverInfo) drivers.elementAt(i);
        if (getCallerClass(callerCL, di.driverClassName) != di.driverClass)
            continue;
        try {
            Connection result = di.driver.connect(url, info);
            if (result != null)
                return (result);
        } catch (SQLException ex) {
            if (reason == null)
                reason = ex;
        }
    }
}

大致思路:用户先调用DriverManager的getConnection(String url, String user, Stirng password)方法获取与数据库的会话连接,在该方法内部DriverManager首先遍历已经注册的数据库驱动,然后通过驱动程序的acceptsURL(String url)方法找到能过够解析url的合适驱动,接着通过该驱动去连接数据库,建立会话,最后把会话反馈给用户。这个方法都是以接口的形式访问数据库驱动,这样可以确保它不依赖于具体的驱动。

代码解读:代码中包括两个静态方法,一个是公有的静态方法getConnection(String url, String user, String password),另一个是私有的静态方法getConnection(String url, java.util.Properties info, ClassLoarder callerCL)。

  1. 在公有的getConnection静态方法中,首先获取了当前驱动程序的类加载器,然后将连接数据库所需要的参数封装成Properties类的实例,然后将类加载器、参数信息,以及连接字符串作为参数传递给私有的getConnection静态方法。
  2. 在私有的getConnection静态方法中,首先获取当前类的类加载器,然后遍历注册的所有数据库驱动。在遍历算法内部,首先判断驱动类是不是当前类加载器加载的,如果不是则跳过,如果是则尝试着进行连接,如果连接通过就会返回一个Connection接口的实现类,终止遍历,建立会话结束。

在这个方法的实现中,使用驱动类型为Driver接口,也就是说,它是针对Driver接口进行编程,这样做的好处是,任何实现Driver接口的驱动类都可以对其进行赋值,从而实现插拔。

2. registerDriver()

数据库提供商编写的驱动程序,必须通过注册,才能在驱动管理器中进行应用。

方法原型:public static synchronized void registerDriver(java.sql.Driver driver) throws SQlException

方法源代码:

public static synchronized void registerDriver(
    	java.sql.Driver driver) throws SQLException {
    if (!initialized) {
        initialize();
    }
    DriverInfo di = new DriverInfo();
    di.driver = driver;
    di.driverClass = driver.getClass();
    di.driverClassName = di.driverClass.getName();
    writeDrivers.addElement(di);
    readDrivers = (java.util.Vector) writeDrivers.clone();
}

这是一个共有的静态方法,方法内部首先判断DriverManager有没有进行初始化,如果没有则进行初始化。然后创建一个驱动信息类,并对其进行赋值,然后再驱动集中添加驱动,最后更新驱动集。该方法一般都有驱动程序进行调用注册,具体情况将在介绍具体驱动类时详述。

3. getDriver()

通过该方法可以获取能解析url的合适驱动。

方法原型:public static Driver getDriver(String url) throws SQLException

方法源代码:

public static Driver getDriver(String url) throws SQLException {
    	java.util.Vector drivers = null;
    if (!initialized) {
        initialize();
    }
    synchronized (DriverManager.class) {
        drivers = readDrivers;
    }
    ClasssLoarder callerCL = DriverManager.getCallerClassLoarder();
    for (int i = 0; i < drivers.size(); i++) {
        DriverInfo di = (DriverInfo) drivers.elementAt(i);
        if (getCallerClass(callerCL, di.driverClassName) != di,driverClass) {
            continue;
        }
        try {
            if (di.driver.acceptsURL(url)) {
                return (di.driver);
            }
        } catch (SQLException ex) { }
    }
    throw new SQLException("No suitable driver", "08001");
}

代码解读:该方法首先定义了一个驱动信息集合,然后判断DriverManager是否初始化,如果没有则进行初始化,接着读取驱动信息集合。接着遍历驱动信息集合。在遍历代码块中,首先判断有没有权限访问驱动类,如果没有则跳过,如果有则调用驱动类的acceptsURL(String url)方法解析数据库连接串。如果能解析数据库连接串,则返回该驱动。遍历结束后,如果仍没找到合适的驱动,则抛出没有合适的驱动类异常。

7.2 驱动的实现

介绍了JDBC的核心类和接口后,下面介绍驱动的具体实现。以MySQL的驱动类作为例子,下面为com.mysql.jdbc.Driver(老版本,新版本为com.mysql.cj.jdbc.Driver)驱动类的源代码:

public class MySqlDriver extends NonRegisteringDriver implements java.sql.Driver {
    static {
        try {
            java.sql.DriverManager.registerDriver(new Driver());
        } catch(SQLException E) {
            throw new RuntimeException("Can't register driver!");
        }
    }
    public Driver() throws SQLException {
        /*...*/
    }
}

该类继承了NonRegisteringDriver类并实现了java.sql.Driver接口,具体的数据库连接与处理细节都放到NonRegisteringDriver中进行了实现,在com.mysql.jdbc.Driver中只实现了注册功能。在介绍java.sql.DriverManager中的组成方法时,我们曾说过驱动的注册多由驱动自身完成,那么我们看看驱动是如何注册到DriverManager中的。在Driver类中,有一段静态代码域,可以看到在这段代码域中,调用了DriverManager的registerDriver()方法,对驱动进行了注册,代码域会在类加载的时候执行,这就是为什么在使用驱动之前都需要调用Class类的forName方法加载驱动的原因。

NonRegisteringDriver也继承了接口java.sql.Driver,在其内部可以看出数据库连接与处理的详细逻辑:属性块、父类方法块、工具与解析方法块。

7.2.1 属性块

1. 协议匹配常量

private static final String REPLICATION_URL_PREFIX = "jdbc:mysql:replication://";
private static final String URL_PREFIX = "jdbc:mysql://";
private static final String MXJ_URL_PREFIX = "jdbc:mysql:mxj://";
private static final String LOADBALANCE_URL_PREFIX = "jdbc:musql:loadbalance://";

这些常量定义了MySQL的数据库驱动所能理解和接受的协议。通过解析,若发现连接字符串不是使用这些协议,MySQL驱动将禁止数据库连接。

2. 参数名称常量

private static final String HOST_PROPERTY_KET = "HOST";
private static final String PASSWORD_PROPERTY_KEY = "password";
private static final String DBENAM_PROPERTY_KEY = "DBNAME";
private static final String USER_PROPERTY_KEY = "user";
private static final String PROTOCOL_PROPERTY_KEY = "PROTOCOL";
private static final String PATH_PROPERTY_KEY = "PATH";

上面列出了部分参数名称常量在类NonRegisteringDriver内部。

3. 其他常量

/** Should the driver generate debugging output? */
public static final boolean DEBUG = false;
/** Should the driver generate method-call traces? */
public static final boolean TRACE = false;

这些常量用于控制调试以及输出信息。

7.2.1 父类方法块

1. connection(String url, Properties info)

该方法根据url辨别出用户使用的协议,根据用户的协议创建合适的连接,然后将连接返回给用户。参数info表示建立连接时所需要的参数,比如用户名和密码等,该方法的代码如下:

public java.sql.Connection connect(
    	String url, Properites info) throws SQLException {
    if (url != null) {
        if (StirngUtils.startsWithIgnoreCase(url, LOADBALANCE_URL_PREFIX)) {
            return connectLoadBalanced(url, info);
        } else if (StringUtils.startsWithIgnoreCase(url, REPLICATION_URL_PREFIX)) {
            return connectReplicationConnection(url, info);
        }
    }
    Properites props = null;
    if ((props = parseURL(url, info)) == null) {
        return null;
    }
    if(!"1".equals(props.getProperty(NUM_HOSTS_PROPERTY_KEY))) {
        return connectFailover(url, info);
    }
    try {
        Connection newConn = com.mysql.jdbc.ConnectionImpl.getInstance(
        	host(props), port(props), props, database(props), url);
        return newConn;
    } catch (SQLException sqlEx) {
        throw sqlEx;
    } catch (Exception ex) {
        SQLException sqlEx = SQLError.createSQLException(
            Messages.getString("NonRegisteringDriver.17") + 
        	ex.toString() +
        	Messagex.getString("NonRegisteringDriver.18")),
        	SQLError.SQL_STATE_UNABLE_TO_CONNECT_TO_DATASOURCE, null);
        sqlEx.initCause(ex);
        throw sqlEx;
    }
}

方法解读:在该方法中,首先判断url是否为空,如果不为空则判断url符合哪种协议,然后根据具体的协议创建合适的连接,此处的协议包括以下两个:

  • jdbc:mysql:loadbalance://

    该协议主要用于集群环境,可以时服务器负载平衡,它采用负载平衡算法随机地从服务器列表中选择一个服务器创建连接。

  • jdbc:mysql:replication://

    该协议主要用于备份赋值数据,一般服务器分为主服务器和备份服务器,在一部的赋值操作下,实现数据从一台服务器到另一台服务器的备份。

如果url为空或者不是这两个协议中的任何一个,则判断能否解析这个url字符串,如果不能则返回null,表示创建失败,否则继续,接着判断服务器的数量。如果是1,则建立故障转移连接,如果不是则继续尝试连接,并返回一个ConnectionImpl实例。

2. acceptsURL(String url)

该方法通过解析URL来判断连接字符串是否合法。其代码实现如下:

public boolean acceptsURL(String url) throws SQLException {
    return (parseURL(url, null) != null);
}

该方法调用了parseURL方法来解析连接字符串,如果返回为空,则表示来连接字符串不合法,否则合法。

3. parseURL(String url, Properties defaults)

该方法用于解析来连接字符串和连接参数,并将解析后的结果放入到一个Properties实例中,其代码如下:

if (!StringUtils.startsWithIgnoreCase(url, URL_PREFIX) &&
    !StringUtils.startsWithIgnoreCase(url, MXJ_URL_PREFIX) &&
    !StringUtils.startsWithIgnoreCase(url, LOADBALANCE_URL_PREFIX) &&
    !StringUtils.startsWithIgnoreCase(url, REPLICATION_URL_PREFIX)) {
    return null;
}

该代码块的功能是:如果url与指定的协议不匹配,则返回null,表示连接字符串不合法,解析终止。

下面列出的为连接字符串中的参数解析代码块:

int index = url.indexOf("?");
if (index != -1) {
    String paramString = url.substring(index+1, url.length());
    url = url.substring(0, index);
    StringTokenizer queryParams = new StringTokenizer(paramString, "&");
    while (queryParam.hasMoreTokens()) {
        String parameterValuePair = queryParam.nextToken();
        int indexOfEquals = StringUtils.indexOfIgnoreCase(0, parameterValuePair, "=");
        String parameter = null;
        String value = null;
        if (indexOfEquals != -1) {
            parameter = parameterValuePair.substring(0, indexOfEquals);
            if (indexOfEquals+1 < parameterValuePair.length()) {
                value = parameterValuePair.substring(indexOfEquals+1);
            }
        }
        if ((value != null && value.length() > 0)) &&
            (parametet != null && parameter.length() > 0)) {
            try {
                urlProps.put(parameter, URLDecoder.decode(value, "UTF-8"));
            } catch (UnsupporedEncodingException badEncoding) {
                urlProps.put(parameter, URLDecoder.decode(value));
            } catch (NoSuchMethodError nsme) {
                urlProps.put(parameter, URLDecoder.decode(value));
            }
        }
    }
}

方法解读:该方法首先找到“?”的位置,其后为连接参数。然后使用“&”符号把参数对分开,参数对以键和值的方式存在,其形式是key=value。因此通过对“=”可以把键和值分离开来,放入到Properties实例中,记录下来。

4. database(Properites props)

该方法用于返回数据库的名称,其代码实现如下:

public String dataBase(Properites props) {
    return props.getProperty(DBNAME_PROPERTY_KEY);
}

该方法的实现很简单,从配置实例中读取数据库的名称,类似这样的方法还有很多,比如读取连接端口、读取用户名等。

7.3 ODBC

开放数据库互连(Open Database Connectivity, ODBC)是微软公司开发服务结构(Windows Open Services Architechture, WOSA)中有关数据库的一个组成部分,它建立了一组规范,提供了一组对数据库访问的标准API(应用程序编程接口),这些API利用SQL来完成其大部分任务。ODBC本身也提供了对SQL的支持,用户可以直接将SQL语句送给ODBC,然ODBC不适合直接在Java中使用,因为它使用C语言接口。从JAva调用本地C代码在安全性、实现、坚固性和程序的自动移植性方面都有许多缺点,因此JDBC的开发人员创建了JDBC-ODBC的桥接,以ODBC为基础进行数据库连接。JDBC API对于基本SQL抽象和概念是一种自然的Java接口,它建立在ODBC上,保留了ODBC的基本设计特征。两种接口都基于X/Open SQL CLI(调用级接口),他们直接最大的区别在于:JDBC以Java的风格与有点为基础并进行优化,因此更加易于使用。

7.3.1 建立连接

注意:书籍已经过时,此部分为笔者更新。

想要进行数据库的连接,首先需要建立与数据库之间的连接会话,所有的操作都是基于这个会话基础上进行的。建立连接的代码如下:

public class MySqlDAO {
    public static Connection getConnection() throws Exception {
        String driverName = "com.mysql.cj.jdbc.Driver";
        String url = "jdbc:mysql://localhost:3306/student";
        String userName = "root";
        String password = "123456";
        Class.forName(driverName);
        Connection con = DriverManager.getConnection(url, userName, password);
        return con;
    }
}

这段代码的主要功能是建立于数据库之间的连接会话。下面来逐一分析这段代码,首先是连接参数定义:

String driverName = "com.mysql.cj.jdbc.Driver";
String url = "jdbc:mysql://localhost:3306/student";
String userName = "root";
String password = "123456";

这段代码块首先定义了几个属性:驱动名称、数据库连接字符串、数据库用户名以及数据库密码。定义这些属性请注意以下几个点:

  1. 驱动名称必须为全名,而且保证路径正确。
  2. 连接字符串分为三部分:
    1. 连接协议:jdbc:mysql://。
    2. 数据库地址:localhost:3306/,又包含主机地址和数据库端口号。
    3. 数据库名称:student。
  3. 确保用户名和密码都正确,否则会拒绝连接。

接着是加载数据库驱动:

Class.forName(driverName);

前面介绍过,加载数据库驱动的时候,驱动程序会自动调用DriverManager中的registerDrivera(Driver driver)方法,将自身注册到管理器中。

接下来是创建并获取连接:

Connection con = DriverManager.getCOnnection(url, userName, password);

此处调用了驱动管理器的getConnection()的三参数方法,驱动管理器对该方法有多个重载。该方法会根据传入的参数选择合适的连接会话返回给用户。至此已经获取了数据库的连接会话,可以在此会话基础上进行数据库操作了。注:执行这段代码前,请确保将数据库驱动的JAR包加入到项目中。

7.3.2 通过Statement执行SQL语句

下面将介绍获取连接会话后,通过Statement对数据进行增删改查。在对数据库进行操作前,需要获取Statement对象,对于执行数据的命令。因此需要在MySqlDAO中添加如下方法:

public static Statement getStatement() throws Exception {
    Statement stmt = getConnection().createStatement();
    return stmt;
}

1. 创建表

代码如下:

public class DBTest {
    public static void mian(String[] args) throws Exception {
        Statement stmt = MySqlDAO.getStatement();
        String sql = "create table student(no int primary key, name char(20))";
        stmt.execute(sql);
        stmt.close();
    }
}

这段代码首先通过MySqlDAO获取了数据库命令执行对象Statement实例,然后定义了一条数据库语句。该语句的作用是,创建一张student表,表中有两个字段:一个整数型的no(学号),另一个是字符类型的name(姓名)。接着调用Statement对象的execute(String sql)方法,执行这条数据库语句,执行后,数据库中即可发现多了一张student表。

2. 插入数据

代码如下:

public class DBTest1 {
    public static void main(Stringp[] args) throws Exception {
        Statement stmt = MySqlDAO.getStatement();
        String sql = "insert into student values(1, 'jim')";
        stmt.execute(sql);
        stmt.close();
    }
}

插入操作的代码与创建表的代码很相似,不一样的是执行的SQL语句不同,修改和删除也与插入操作类似,在此就不进行介绍。

3. 读取数据

public class DBTest2 {
    public static void mian(String[] args) throws Exception {
        Statement stmt = MySqlDAO.getStatement();
        String sql = "select * from student where no=1";
        ResultSet rs = stmt.executeQuery(sql);
        while (rs.next()) {
            System.out.println("no: " + rs.getInt("no"));
            System.out.println("name: " + rs.getString(2));
        }
    }
}

这段代码的功能是读取表student中no等于1的数据并打印出来。首先仍是先获取数据库命令执行对象Statement实例,接着定义一条查询SQL语句,执行这条语句会返回一个结果集,结果集中包含student表中no等于1的学生信息,调用结果集ResultSet的next()方法,逐行读取学生信息,并将学生信息打印在命令行上。rs提供了很多方法用于获取属性字段,可以通过索引也可以通过字段名称。需要注意一点,字段的属性必须正确,不能通过getInt()方法获取字符串字段的值。可以发现,对于字段较少的表,这种获取信息的方法还可以接受,表中的字段有急事甚至上百的时候让人难以接受。

7.3.3 通过PreparedStatement执行SQL语句

本章的前面曾介绍过Statement与PreparedStatement的区别,下面将介绍如何使用PreparedStatement。首先需要获取PreparedStatement实例,因此向MySqlDAO中添加下面方法:

public static PreparedStatement preparedStatement(
    	String sql) throws Exception {
    return getConnection().prepareStatement(sql);
}

该方法获取一个PreparedStatement对象,并为其指定SQL语句,获取PreparedStatement实例后,下面展示如何使用:

public class DBTest3 {
    public static void main(Stirng[] args) throws Exception {
        String sql = "select * from student where no=?";
        PreparedStatement ps = MySqlDAO.preparedStatement(sql);
        ps.setInt(1, 1);
        ResultSet rs = ps.executeQuery();
        while (rs,next()) {
            System.out.println("no: " + rs.getInt("no"));
            System.out.println("name: " + rs.getString(2));
        }
    }
}

这段代码仍然是先指定一条SQL语句,但这条语句不同的是它采用“?”代替了具体的值,PreparedStatement对象允许在执行SQL语句是才进行参数指定。PreparedStatement对象有多个设置参数值的方法,在设置参数时,需要知道参数的值类型,比如int类型可以使用setInt(int paramIndex, int value)进行参数设定,前一个参数表示参数的索引,即它代表着第几个问好,后面的参数为属性值。PreparedStatement对象会预先编译SQL语句,因此它的执行效率高于Statement,但它只能执行预先设定的SQL,因此多用于指定特定SQL的场景中。PreparedStatement的其他数据库操作于此类似,因此不再赘述。

7.4 JDBC进阶

本节将对JDBC的高级操作进行进一步的讨论,这些操作包括:事务操作、存储过程操作以及数据库连接池。

7.4.1 事务

事务是指一组操作的组合,这组操作作为一个逻辑单元来执行。因此,它在执行的过程中,要么不执行,要么全部执行。如果执行过程中出现错误,必须退回到原来的初始状态。

JDBC对事务的操作提供了支持,主要通过COnnection类的rollback()、commit()以及setAutoCommit(boolean autoCommit)等方法进行事务回滚、提交操作。

下面演示了事务的实例:

public class DBTest4 {
    public static void main(String[] args) {
        Connection con = null;
        try {
            con = MySqlDAO.getConnection();
            con.setAutoCommit(false);
            Statement stmt = con.createStatement();
            String sql1 = "select max(no) from student";
            ResultSet rs = stmt.executeQuery(sql1);
            int no = 0;
            while (rs.next()) {
                no = rs.getInt(1) + 1;
            }
            String sql2 = "insert into student values("+ no +", 'wahaha')";
            stmt.execute(sql2);
            con.commit();
        } catch (Exception e) {
            try {
                con.rollback();
            } catch (SQLException el) {
                el.printStackTrace();
            }
        }
    }
}

这个例子仍然使用以前定义的MySqlDAO工具类来获取连接。获取连接后,首先将连接会话Connection的自动提交取消,再通过连接会话创建了数据库命令执行对象Statement;接着执行了一条查询语句,当前最大的学生的学号no,获取这个no值后将其增加1,获取新的学号值,并将新的学生作为此值插入到数据库中;最后提交事务。在这个事务操作的过程中,如果出现了错误,会在异常捕获后,对事物进行回滚。

7.4.2 存储过程

存储过程相当于存在再数据库服务器中的函数,它接受输入并返回输出。该函数会在数据库服务器上进行编译,供用户多次使用,因此可以大大提高数据操作的效率。MySQL中创建一个存储过程的语句如下:

Delimiter $$
Create procedure insertNewStudent (in newName varchar(20), out nowNo int)
	begin
		declare maxid int;
			select max(no)+1 into maxid from student;
			insert into student values(maxid, newName);
			select max(no) int newNo form student;
		end
$$

这个存储过程的作用是向Student表中插入一条新纪录,输入参数是新记录学生的姓名,输出参数是新学生的学号。存储过程的内部首先获取当前最大的学生号的值,然后将其加1作为新学生的学生号,并把胜场的学生号和姓名插入到数据库中。下面对这一SQL语句进行说明。首先需要定义一个接受符,因为在存储过程中,“;”被用做一句语句结束,因此不能作为整个SQL接受标志,我们定义的结束符是“$$”,定义方法为:

Delimiter $$

接着创建存储过程,存储过程名称是必需的,输入参数和输出参数是可选的。

Create procedure insertNewStudent (in newName varchar(20), out nowNo int)

下面看看存储过程的主题:

begin
	declare maxid int;
		select max(no)+1 into maxid from student;
		insert into student values(maxid, newName);
		select max(no) int newNo form student;
	end
$$

首先以begin作为开始,再定义一个整体变量maxid为新生的学生号,接着从学生表中找出当前最大的学生号并将其加1赋给maxid。将新的学生号以及姓名插入到学生白表中,然后在此查询最大的学生号,赋值给输出参数。以end表示存储过程体结束,最后以$$表示存储过程声明结束。我们心在数据库中执行这条SQL语句。

下面演示了JDBC调用方法:

public class DBTest {
    public static void mian(String[] args) throws Exception {
        Connection con = MySqlDAO.getConnection();
        String sql = "call insertNewStudent('coco',?);";
        CallableStatement stmt = con.prepareCall(sql);
        stmt.registerOutParameter(1, Types.INTEGER);
        stmt.execute();
        System.out.println("new student no: " + stmt.getInt(1));
        stmt.close();
        con.close();
    }
}

这段代码调用了上面创建的存储过程insertNewStudent。首先仍是获取连接,然后定义一条访问存储过程的语句,注意输出参数需要以“?”代替。接着创建一个可以执行存储过程的命令对象,并设置其对象的存储过程的输出类型,接着执行存储过程命令对象并将存储过程的输出参数打印在控制台上,最后关闭命令对象和连接。

将部分逻辑放到数据库服务器上进行运行,这种提前编译的SQL可以大大提高效率,但也给服务器带来了性能上的压力。除此之外,存储过程在当前的数据库产品中移植性交叉,因此,如果大量使用存储过程会给程序的移植带来一定的困难。

7.4.3 数据库连接池

数据库创建连接是一项非常耗费资源的操作,因此节省创建和释放联系的性能消耗,人们提出了池的概念。池的概念被广泛应用在服务器端软件的开发上。使用池结构可以明显提高应用程序的速度,改善效率和降低系统资源开销。所以在现在的应用服务器端的开发中,池的设计和实现是开发工作中的重要一环。

池可以想象成一个容器,保存着各种需要的对象。对这些对象进行复用,从而提高系统性能。从结构上,它应该具有容器对象和具体的元素对象。从使用方法来看,可以直接取得池中的元素来用,也可以把要做的任务交给它处理,所以从目的上看,池应该有两种类型:

  1. 用于处理客户提交的任务的,通常用Thread Pool(线程池)来描述它。
  2. 客户从池中获取有关的对象进行使用,通常用Resource Pool(资源池)来描述它。

它们可以分别解决不同的问题。数据库连接池就是一种资源池。为了更好的管理数据库的连接,人们设计了数据库连接池。

下面演示了一个简单的数据库连接池:

1. MyCon类

该类表示一个有状态的数据连接。

public class MyCon {
    public static final int FREE = 100;
    public static final int BUZY = 101;
    public static final int CLOSED = 102;
    private Connection con;
    private int State = FREE;
    public MyCon(Connection con) {
        this.con = con;
    }
    public Connection getCon() {
        return con;
    }
    public int getState() {
        return state;
    }
    public void setState(int state) {
        this.state = state;
    }
}

2. ConPool类

该类为数据库连接池实例。

该类使用单例模式(设计模式之一)。

public class ConPool {
    private List<MyCon> freeCons = new ArrayList<>();
    private List<MyCon> buzyCons = new ArrayList<>();
    private int max = 10;
    private int min = 2;
    private int current = 0;
    private static ConPool instance;
    private ConPool() {
        while (this.min > this. current) {
            this.freeCons.add(this.createCon());
        }
    }
    public static ConPool getInstance() {
        if (instance = null)
            instance = new ConPool();
        return instance;
    }
    public MyCon getCon() {
        MyCon myCon = this.getFreeCon();
        if (myCon != null) {
            return myCon;
        } else {
            return this.getNewCon();
        }
    }
    private MyCon getFreeCon() {
        if(freeCons.size() > 0) {
            MyCon con = freeCons.remove(0);
            con.setState(MyCon.BUZY);
            this.buzyCons.add(con);
            return con;
        } else {
            return null;
        }
    }
    private MyCon getNewCon() {
        if (this.current < this.max) {
            MyCon myCon = this.createCon();
            myCon.setState(MyCon.BUZY);
            this.buzyCons.add(myCon);
            return myCon;
        } else {
            return null;
        }
    }
    private MyCon createCon() {
        try {
            Connection con = MySqlDAO.getConnection();
            MyCon myCon = new MyCon(con);
            this.current++;
            return myCon;
        } catch (Exception e) {}
        return null;
    }
    public void setFree(MyCon con) {
        this.buzyCons.remove(con);
        con.setState(MyCon.FREE);
        this.freeCons.add(con);
    }
    public String toString() {
        return "Current connections: " + this.current + 
            "Free connections: " + this.freeCons.size() +
            "Buzy connections: " + this.buzyCons.size();
    }
}

3. MySqlDAO类

该类用于数据库连接的获取与操作。

public class MySqlDAO {
    public static Connection getConnetion() throws Exception {
        String driverName = "com.mysql.cj.jdbc.Driver";
        String url = "jdbc:mysql://localhost:3306/student";
        String userName = "root";
        String password = "123456";
        Class.forName(driverName);
        Connection con = DriverManager.getConnection(url, userName, password);
        return con;
    }
}

4. DBTest类

该类为测试类,输出池中连接情况。

public class DBTest {
    public static void main(String[] args) throws Exception {
        System.out.println(ConPool.getInstance().toString());
        MyCon con = null;
        for (int i = 1; i <= 5; i++) {
            con = ConPool.getInstance().getCon();
        }
        System.out.println(ConPool.getInstance());
        ConPool.getInstance().setFree(con);
        System.out.println(ConPool.getInstance());
    }
}

运行结果:

Current connections: 2 Free connections: 2 Buzy connections: 0
Current connections: 5 Free connections: 0 Buzy connections: 5
Current connections: 5 Free connections: 1 Buzy connections: 4

8 网络编程

网路编程是Java的一种扩展。

警告:该章节含有部分Java IO流知识,该部分为Java基础,不在本文章展示。

8.1 网络概述

计算机网络可以将地理位置不同、具有独立功能的计算机及外部设备通过通信线路连接起来,在网络操作系统、网络管理软件、网络硬件设备以及网络通信协议的管理和协调下,实现资源共享和信息传递功能。下面将介绍网络编程三要素:协议、IP地址和端口。

8.1.1 网络协议

在计算机网络中,要做到有条不紊地交换数据,就必须遵守一些视线约好的规则,这些规则明确规定了所交换的数据的格式以及有关的同步问题。这些为进行网络中的数据交换而建立的规则、标准或约定称为网络协议,它们是计算机网络不可缺少的组成部分。由于计算机网络协议的复杂性,其结构一般是层次式的。层次式有以下几个好处:

  1. 各层功能相互独立。

    网络协议在各层相互独立,可以降低耦合,提供接口互相调用。

  2. 扩展修改灵活。

    需要修改协议功能时,只需修改指定层服务即可,层与层之间不相互影响。

  3. 易于实现和维护。

    分层将负责的实现问题分解成一些小而独立的问题,易于实现和维护。

  4. 协议指定独立灵活。

    各层只需实现自身的协议,因而协议不会过于复杂,易于指定和执行。

1. OSI模型

OSI是国际标准组织指定的开放系统互联模型,这是一套层次清晰、理论完整的系统模型。它将网络通信工作分为7层,分别是物理层、数据链路层、网络层、运输层、会话层、表示层、应用层。但由于其协议过于复杂、运行效率低、指定周期长等缺点,并未得到广泛的应用。二得到广泛应用的确实TCP/IP模型,TCP/IP模型成了实际上的网络协议标准。

2. TCP/IP模型

TCP/IP模型是一个4层的网络体系结构,包含网络协议接口层、网际层、运输层和应用层、下面分别介绍这些层中定义的功能和实现的协议。

  1. 网络接口层

    网络接口层与OSI参考模型中的物理层和数据链路层相对应。网络接口层是TCP/IP与各种LAN或WAN的接口。网络接口层在发送端将上层的IP数据报(Datagram)封装成帧后发送到网络上;数据帧通过网络到达接收端,该节点的网络接口层对数据帧拆封,并检查帧中包含的MAC地址(Media Access Control Address,类似于网卡身份证)。如果该地址就是本机的MAC或者是广播地址,则上传到网络层,否则丢弃该帧。

  2. 网际层

    网际层的主要功能是实现互联网络环境下的端到端数据分组传输,这种端到端数据分组传输采用无连接交换的方式来完成。为此,网际层提供了基于无连接的数据传输、路由选择、拥塞控制和地址映射等功能,这些功能主要由4个协议来实现:IP、ARP、RARP和ICMP,其中IP提供数据分组传输、路由选择等功能,ARP和RARP提供逻辑地址与物理地址映射功能,ICMP提供网络控制和差错处理功能。

  3. 运输层

    运输层为应用进程之间提供端到端的逻辑通信,运输层还要对收到的报文进行差错检测。其实现的协议主要有面向连接的TCP和面向无连接的UDP。

  4. 应用层

    应用层直接为用户的应用进程提供服务。该层实现的应用协议很多,如HTTP、SMTP(电子邮件传输协议)、FTP(文件传输协议)等。

8.1.2 IP地址

互联网为每台计算机提供了一个编号,以便其他计算机能找到它并与其通信,这个编号就称作IP地址。IP地址就如同住址一样,别人只有依据这个住址才能找到笔者家。目前主要用的地址有IPv4和IPv6两种。

1. IPv4

IP地址使用32位二进制数表示,通常表示成4组,每组8位形式。我们看到的IP地址多为点分十进制的形式,即每组之间用“.”分开,并用十进制表示,如127.0.0.1。

2. IPv6

由于IPv4的地址资源有限,逐渐不能满足日益膨胀的网络,在这样的环境下,IPv6应运而生。IPv6使用128位是二进制数表示计算机地址,大大增加了地址的数量,此外,IPv6还提供了其他很多功能,保证了网络快速安全地运行。

目前Java对这两种IP地址均进行了封装,其实现类分别是Inet4Address和Inet6Address,它们都继承了类InetAddress。

InetAdress是Java对IP地址的封装,在java.net中有许多类都是用到了InetAddress包括ServerSocket、Socket、DatagramSocket等。InetAddress的实例对象包含以数字形式保存到 IP地址,同时还可能包含主机名(如果用主机名来获取InetAddress的实例,或者使用数字来构造,并启用了反向主机名解析的功能)。InetAddress类提供了将主机名解析为IP地址和反向解析的方法。

InetAddress对域名进行解析时使用本地极其配置或者网络命名服务(如域名系统(Domian Name System,DNS)和网络信息服务(Network Information Service,NIS)来实现。对于DNS来说,本地需要想DNS服务器发送查询的请求,然后服务器根据一系列的操作,返回对应的IP地址,为了提高效率,通常本地会缓存一些主机名与IP地址的映射,这样访问相同的地址时,就不需要重新发送DNS请求。在java.net.InetAddress类中同样采用了这种策略。在默认情况下,会缓存一段有限时间的映射,对于主机名解析不成功的结果,会缓存非常短的时间(10s)来提高性能。

由于InetAddress的构造方法不会共有的,因此只能通过InetAddress提供的静态方法获取InetAddress对象,其获取方法如下:

  • static InetAddress[] getAllByName(String name)
  • static InetAddress getByAddress(bytep[] addr)
  • static InetAddress getByAddress(String host, byte[] addr)
  • static InetAddress getByName(String name)
  • static InetAddress getLocalHost()

这些静态方法中最常用的是getByName(String host)方法,该方法只需要传入目标主机的名字,InetAddress会尝试连接DNS服务器,并且获取IP地址的操作。

下面演示了InetAddress的获取:

public class Test {
    public static void main(String[] args) throws UnknownHostException {
        InetAddress address = InetAddress.getByName("www.baidu.com");
        System.out.println("=======Get baidu's IP=======");
        System.out.println(address);
        InetAddress[] addresses = InetAddress.getAllByName("www.baidu.com");
        System.out.println("====Get baidu's IP list=====");
        for (InetAddress a: addresses) {
            System.out.println(a);
        }
    }
}

运行结果:

=======Get baidu's IP=======
www.baidu.com/14.119.104.254
====Get baidu's IP list=====
www.baidu.com/14.119.104.254
www.baidu.com/14.119.104.189

另一个常用的方法为getLocalHost(),该方法返回本地的IP地址

下面演示了getLocalHost的使用方法:

public class Test {
    public static void main(String[] args) throws UnknownHostException {
        InetAddress address = InetAddress.getLocalHost();
        System.out.println(address);
    }
}

运行结果:

Asen/192.168.31.6

获取InetAddress对象后,就可以使用其定义的方法。其常用的方法如下:

  • String getHostAddress():用于获取本地IP地址字符串。
  • String getHostName():用于获取本地计算机的名称。
  • Boolean isReachable(int timeout):判断在只当的时间内是否可达。
8.1.3 端口

网络的通信,本质上是两个应用程序的通信。如果说IP地址可以唯一的标识网络中的设备,那么端口号就可以唯一标识设备中的应用程序,也就是应用程序的标识。

端口号由两个字节表示的整数,其取值范围为0~65535。在其范围中,端口号又分为三种端口:

  1. 公认端口:0~1023之间的端口号,用于一些知名的网络服务和应用,比如端口80分给www,端口443分配给HTTPS。
  2. 注册端口:1024~49151的端口号分配给用户进程或应用程序。
  3. 动态/私有端口:49152~65535之间的端口号。理论上,不应把常用效劳分配在这些端口上。实际上,有些较为特别的程序,特别是一些木马程序就十分喜爱用这些端口,由于这些端口常常不被引起留意,简单荫蔽。

因为端口号是区分设备中的应用程序,如果端口号被另外一个服务或应用所占用,则会导致当前程序启动失败。

8.1.4 套接字

socket的原意是“插座”,在计算机通信领域,socket被翻译为“套接字”,它是计算机之间进行通信的一种约定或一种方式。socket的实现是由操作系统支持的,用户仅需要了解如何使用socket就能很好的实现计算机之间的通讯。

通过套接字这种约定,一台计算机可以接收其他计算机的数据,也可以向其他计算机发送数据。

套接字的典型应用就是Web服务器和浏览器:浏览器获取用户输入的URL,向服务器发起请求,服务器分析接收到的URL,将对应的网页内容返回给浏览器,浏览器再经过解析和渲染,就将文字、图片、视频等元素呈现给用户。例如我们每天浏览网页、网络聊天、收发email等等。

套接字用于实现网络上的两个程序之间的连接和通信。而在连接的两端都分别有一个套接字。套接字的通信处于较低的层次,由用户编写的程序管理使用。TCP/IP中通常包含三种套接字:

1. 流套接字

流套接字(SOCK_STREAM)用于提供面向连续、可靠的数据传输服务。该服务将保证数据能够实现无差错、无重复发送,并按顺序接收。流套接字之所以能够实现可靠的数据服务,原因在于其使用了传输控制协议,即TCP(Transmisssion Control Protocol,传输控制协议)。

2. 数据报套接字

数据报套接字(SOCK_DGRAM)提供了一种无连接的服务。该服务并不能保证数据传输的可靠性,数据有可能在传输过程中丢失或出现数据重复,且无法保证顺序地接收到数据。数据报套接字使用UDP(User Datagram Protocol,用户数据报协议)进行数据的传输。由于数据报套接字不能保证数据传输的可靠性,对于有可能出现的数据丢失情况,需要在程序中做相应的处理。

因为数据报套接字所做的校验工作少,所以在传输效率方面比流格式套接字要高。同时,数据报套接字也没有想象中的糟糕,不会频繁的丢失数据,数据错误只是小概率事件。

3. 原始套接字

原始套接字与标准套接字(标准套接字指的是前面介绍的流套接字和数据报套接字)的区别在于:原始套接字可以读写内核没用处理的IP数据包,而流套接字只能读取TCP的数据,数据报套接字只能读取UDP的数据。因此,如果要访问其他协议发送的数据,就必须使用原始套接字。

Java提供多种套接字类,如用于流套接字的Socket、ServerSocket,用于数据报套接字的DatagramSocket、MulticastSocket,这些类将在下文详细讨论。

8.2 TCP编程

8.2.1 TCP协议

TCP(Transmission Control Protocol,传输控制协议)是面向连接的通信协议,即传输数据之前,在发送端和接收端建立逻辑连接,然后再传输数据,它提供了两台计算机之间可靠无差错的数据传输。在TCP连接中必须明确客户端与服务器端,由于客户端向服务器端发出连接请求,每次连接的创建都需要经过“三次握手”,而每次结束连接则需要经过“四次挥手”正常结束或者以其他方式异常结束。

8.2.2 原理

TCP通信协议在通信的两端各建立一个Socket对象,从而在通信的两端形成网络虚拟链路,一旦建立了虚拟的网络链路,两端的程序就可以通过虚拟链路进行通信。

Java使用基于TCP协议的Socket网络编程实现,使用Socket对象来代表两端的通信端口,并且利用Java的IO流实现数据的传输。Java为服务器端封装了ServerSocket用来接收请求,当接收到请求后,会返回请求方一个Socket对象。通常情况下,请求端被称为客户端(Client)程序,回复端被称为服务器端(Server)程序。

8.2.3 核心类

java.net包有很多用于网络编程的类,其中有两个常用于TCP编程:

1. Socket类

Socket是建立网络连接时使用的。在连接成功时,应用程序两端都会产生一个Socket实例,操作这个实例,完成所需的会话。

常用构造器

常用构造方法说明
Socket(InetAddress address, int port)创建一个流套接字并将其连接到指定IP地址的指定端口号
Socket(String host, int port)创建一个流套接字并将其连接到指定主机上的指定端口号

常用方法

常用方法返回类型说明
getOutputStream()OutputStream返回此套接字的输出流
getInputStream()InputStream返回此套接字的输入流
shutdownOutput()void禁用此套接字的输出流
close()void关闭此套接字,套接字中的输入/输出流也将被关闭

此外Socket还提供了很多其他方法,如connect(SocketAddress endpoint)用于连接到远程服务器、getInetAddress()获取远处服务器的地址等。

2. ServerSocket类

ServerSocket类实现服务器套接字,等待请求通过网络传入,基于该请求指导某些操作,然后可能向请求者返回结果。

常用构造器

构造方法说明
ServerSocket(int port)在本地创建绑定到指定端口的服务器套接字

常用方法

常用方法返回值说明
accept()Socket侦听要连接到此套接字并接受它
setTimeout(int timeout)void设置accept()的最大阻塞时间,超出会抛出异常
close()void关闭此服务器套接字
8.2.4 实现步骤
  1. 在服务端指定一个端口号来创建ServerSocket,并使用accept()方法进行侦听,这将阻塞服务器线程,等待用户请求。
  2. 在客户端指定服务的主机IP和端口号来创建Socket,并连接服务端ServerSocket,此时服务端accept()方法被唤醒,同时返回一个和客户端通信的Socket。
  3. 在客户端和服务端分别使用Socket来获取网络通信输入/输出流,并按照一定的通信协议对Socket进行读/写操作。
  4. 通信完成后,在客户端和服务端中分别关闭Socket。

注意:Java程序运行完毕退出和被杀进程时,TCP通信会被当作异常而关闭。

8.2.5 基本实现

客户端中使用Socket的步骤为:

  1. 创建客户端的Socket对象。
  2. 获取输入流,写入数据。
  3. 获取输出流,读出并操作数据。
  4. 释放资源。

下面演示了客户端发送并接收:

public class Client {
    public static void main(String[] args) throws IOException {
        System.out.println("========Client========");
        Socket socket = null;
        try {
            socket = new Socket("localhost", 6666);
        } catch (IOException e) {
            System.out.println("Notice: can not connect the server.");
            System.exit(0);
        }

        PrintWriter writer = new PrintWriter(socket.getOutputStream());
        BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));

        String order = "Client: send test.";
        String reply;

        writer.println(order);
        writer.flush();
        System.out.println("Notice: Has been sent.");

        do {
            reply = reader.readLine();
        } while (reply == null);
        System.out.println(reply);

        socket.close();
    }
}

单独运行该程序抛出UnknownHostException或IOException错误,而UnknownHostException继承于IOException。通常情况下,只要服务器没有启动,客户端则无法使用或使用离线模式,所以需要处理该异常。

服务端使用Socket的步骤为:

  1. 创建服务器端的ServerSocket对象。
  2. 通过accept()方法得到与客户端连接的Socket对象。
  3. 获取输入流,读出并操作数据。
  4. 获取输出流,写入数据。
  5. 释放资源。

下面演示了服务端接收并返回:

public class Server {
    public static void main(String[] args) throws IOException {
        System.out.println("========Server========");
        ServerSocket serverSocket = new ServerSocket(6666);
        System.out.println("Notice: Waiting for accept.");
        Socket socket = serverSocket.accept();

        PrintWriter writer = new PrintWriter(socket.getOutputStream());
        BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));

        String order;
        String reply = "Server: test successful.";

        do {
            order = reader.readLine();
        } while (order == null);
        System.out.println(order);

        writer.println(reply);
        writer.flush();

        socket.close();
        serverSocket.close();
    }
}

下面演示了上面代码的执行过程:

  1. 运行客户端:

    ========Client========
    Notice: Can not connect the server.
    
  2. 运行服务器:

    ========Server========
    Notice: Waiting for accept.
    
  3. 运行客户端:

    ========Client========
    Notice: Has been sent.
    Server: Test successful.
    
    ========Server========
    Notice: Waiting for accept.
    Client: Send test.
    

上面的Client/Server程序只能实现Server和一个客户的对话。在实际应用中,往往在服务器上运行一个永久的程序,接收来自其他多个客户端的请需求,提供相应的服务。为了实现在服务器方给多个客户提供服务功能,需要利用多线程实现多客户机制。服务器总是在指定的端口上监听是否有客户请求,一旦监听到客户请求,服务器就会启动一个专门的服务线程来响应该客户的请求,而服务器本身在启动完线程后马上又进入监听状态,等待下一个客户的到来。

下面是大概的设计框架:

服务端:

服务类说明
Server类服务器类,利用while循环接收请求,每当接收后,开启一个处理线程
ServerHandler类服务处理类,实现Runnale,利用while循环接收、处理和回复客户命令

客户端:

客户类说明
Client类客户类,发送请求,并开启发送线程和接收线程
ClientSender类客户发送类,实现Runnable,利用while循环读取并发送客户命令
ClientReceiver类客户接收类,实现Runnable,利用while循环接收并处理服务器的回复

还能够再优化的是,服务器再添加一个线程,将ServerHandler拆解成ServerReceiver和ServerHandler,前者用于接收命令,后者用于处理命令,同时还需要使用线程协同等操作,为更加复杂但更加符合设计理念的框架。

8.3 UDP编程

8.3.1 UDP协议

UDP(User Datagram Protocol,用户数据报协议)是一种面向无连接的网络协议。它与可靠连接协议TCP相比,它不能保证发送的数据一定能被对方按顺序接收到,如果数据再传送过程中丢失,它也不会自动重发。然而由于它不需要像TCP那样,每次通讯都要建立一条特定的连接通道,进行传输控制,UDP本身的数据就自带了传输控制信息,因此UDP传输能节省系统开销,而且在数据的传输效率上UDP要比TCP高。因此一些对数据顺序以及质量要求不高的场景下,经常使用UDP进行数据传输。

8.3.2 原理

Java语言应将UDP封装成对象,易于我们使用。在通信的两端各建立一个DatagramSocket对象,用于直接发送,或通过监听指定端口接收数据报。数据报通常是指起始点和目的地都使用无连接网络服务的的网络层的信息单元。在Java中用DatagramPacket对象表示,其含有来自发送端的各种信息。

8.3.3 核心类

1. DatagramPacket类

与TCP发送和接收信息都使用流不同,UDP传输的数据单位为数据报文。一个数据报文在Java中用一个DatagramPacket实例来表示。发送信息时,Java程序创建一个数据报文即DatagramPacket实例将需要发送的信息进行封装。接收信息时,Java也首先创建一个DatagramPacket实例,用于存储接收的报文信息。

DatagramPacket类中,除了包含需要传输的信息外,还包含IP地址和端口等信息用于指明目标地址和端口以及源地址和端口。此外,DatagramPacket的内部还用length和offset字段用于说明缓冲区的大小和起始偏移量。

常用构造器

常用构造方法说明
DatagramPacket(byte[] buf, int offset, int length)构造一个DatagramPacket实例,用于接收指定偏移和长度的数据报
DatagramPacket(byte[] buf, int length)构造一个DatagramPacket实例,用于接收指定长度的数据报
DatagramPacket(byte[] buf, int offset, int lenghth, InetAddress address, int port)构造一个DatagramPacket实例,用于发送指定偏移和指定长度数据报报道指定主机的指定端口号上
DatagramPacket(byte[] buf, int length, InetAddress address, int port)构造一个DatagramPacket实例,用于发送指定长度数据报报道指定主机的指定端口号上

常用方法

常用方法返回类型说明
getLength()int返回要发送端的数据长度或接收到的数据的长度
getData()byte[]返回数据缓冲区

2. DatagramSocket类

DatagramSocket用于发送和接收UDP数据包,在Java中即为接收和发送DatagramPacket对象。DatagramSocket与TCP的Socket不同,它子啊通信之前无须事先建立连接。数据报的发送路由时自有选择的,通信的两端在通信过程中发送的不同数据包可能并不在同一路径上。因此,对于一方发送多个数据包来说,其数据包到达的顺序可能与发送的顺序不同。

可以把DatagramSocket看成一个邮箱,把需要发送的数据报文DatagramPacket看成信件。信件上需要注明地址,而信箱需要关心发送和接收信件即可。当然每个信箱都有唯一的编址,以便能正确地接收到信件。Java中UDP数据报的传输过程与此类似。首先创建一个数据包实例即DatagramPacket实例,该实例需要像信笺上的收信人地址一样,指明目标地址和目标端口。然后将此数据包交予DatagramSocket实例进行发送。在接收方会一直监听是否有数据包到达,当有数据报到达时,就会创建一个DatagramPacket对象,来接受存储这个报文,接收的报文中存储了发送者的地址和端口。

常用构造器

常用构造方法说明
DatagramSocket()创建一个数据报套接字,并未指明监听的地址和端口,一般为发送方使用
DatagramSocket(int port)创建一个数据报套接字,该套接字指明了监听的端口,一般为接收方使用
DatagramSocket(int port, InetAddress laddr)创建一个数据报套接字,该套接字指明了监听的地址(主机拥有多个地址)和监听的端口,该数据包只接受发往指定地址和端口的数据报

常用方法

常用方法返回类型说明
send(DatagramPacket p)void从此套接字发送数据报包
receive(DatagramPacket p)void从此套接字接收数据报包(阻塞式的接收)
connect(InetAddress address, int port)void连接指定IP地址和端口的目的地址,建立连接后,接收方就只能接收目标地址发送的数据报,其他的数据报将被丢弃
disconnect()void断开于之前的连接,若之前未连接则什么都不做
8.3.4 基本实现

发送端实现步骤:

  1. 建立UDP的Socket服务,创建对象时如果没有明确端口,系统会自动分配一个未使用的端口。
  2. 明确要发送的具体数据。
  3. 将数据封装成了数据包。
  4. 用Socket服务的send()方法将数据包发送出去。
  5. 关闭资源。

下面演示了发送端的发送:

public class SendEnd {
    public static void main(String[] args) throws IOException {
        System.out.println("==========SendEnd===========");
        DatagramSocket socket = new DatagramSocket();

        byte[] data = "SendEnd: Send test.".getBytes();
        DatagramPacket packet = new DatagramPacket(data, data.length, InetAddress.getByName("localhost"), 6666);

        socket.send(packet);
        System.out.println("Notice: Data has been sent.");
        socket.close();
    }
}

接收端实现步骤:

  1. 创建UDP的Socket服务,必须要明确一个端口,用于监听并接收数据。
  2. 定义数据包,用于存储接收到数据。
  3. 通过Socket服务的接收方法将收到的数据存储到数据包中
  4. 通过数据包的方法获取数据包中的具体数据内容,比如IP、端口、数据等等。
  5. 关闭资源。

下面演示了接收端的接收:

public class ReceiveEnd {
    public static void main(String[] args) throws SocketException {
        System.out.println("=========ReceiveEnd=========");
        DatagramSocket socket = new DatagramSocket(6666);

        byte[] dataContainer = new byte[1024];
        DatagramPacket packet = new DatagramPacket(dataContainer, dataContainer.length);

        System.out.println("Notice: Waiting for receive.");
        try {
            socket.receive(packet);
        } catch (IOException e) {
            System.out.println("Notice: Receive failed.");
        }
        
        System.out.println("Notice: Data has been received.");
        System.out.println("\t" + new String(packet.getData(), 0, packet.getLength()));
        
        socket.close();
    }
}

下面演示了代码的执行过程:

注意:因为UDP的独特性,发送与接收端可以不像TCP那样需要同时运行,但发送行为必须在接收行为之前。

  1. 运行发送端:

    ==========SendEnd===========
    Notice: Data has been sent.
    
  2. 运行接收端:

    =========ReceiveEnd=========
    Notice: Waiting for receive.
    
  3. 运行发送端:

    ==========SendEnd===========
    Notice: Data has been sent.
    
    =========ReceiveEnd=========
    Notice: Waiting for receive.
    Notice: Data has been received.
    	SendEnd: Send test.
    

对于简单的发送和接受数据来说,要实现一对一聊天或多人聊天则需要更多操作,例如老师对学生聊天,则需要在老师和学生的程序中分别创建两个线程,然后这两个线程分别用于发送和接受信息,同时为了安全性可以使用connect()方法指定连接位置。例如多人聊天,则需要在用户和信息分配器中分别创建两个线程用于发送和接受信息,信息分配器的接收线程则用于接收所有信息,发送线程则将所有新接收的信息返回。

8.4 HTTP编程

8.4.1 HTTP简介

HTTP时Hyper Text Transfer Protocol(超文本传输协议)的缩写。它的发展是万维网协会(World Wide Web Consortium)和Internet工作小组(Internet Engineering Task Force,IETF)合作的结果,它们最终发布了一系列的RFC(Request For Comments,是一系列以编号排定,收集了有关互联网相关信息的文件),RFC1945定义了HTTP/1.0版本,其中最著名的就是RFC2616,RFC2616定义了今天普遍使用的一个版本——HTTP 1.1

HTTP是用于从WWW服务器传输超文本到本地浏览器的传送协议,它可以使浏览器更加高效,使网络传输减少。它不仅保证计算机正确快速地传输超文本文档,还确定传输文档中的哪一部分,以及哪部分内容首先显示(如文本优先于图形:看视频网站时首先显示的是文字)等。

8.4.2 协议族中的HTTP

HTTP是一个构建与TCP之上的应用协议层协议,有时也承载了TLS或SSL协议层,这时候就成了人们常说的HTTPS。正因为是以TCP为基础的传输协议,因此它具有TCP传输安全可靠、需建立连接等特性。

安全传输层协议(TLS)用于在两个通信应用程序之阿金提供保密性和数据完整性。SSL为Netscape所研发,用以保障在Internet上数据传输的安全,利用数据加密(Encryption)技术,可以确保数据在网络上传输过程中不会被截取及窃听。HTTP在TCP/IP协议族中处于应用层,它是建立在可靠传输协议TCP之上的。HTTP默认使用80端口进行数据传输,HTTPS协议默认使用443端口进行数据传输。

8.4.3 HTTP传输模式

HTTP数据传输由请求和响应构成,是一个标准的客户端服务器模型。HTTP永远都是客户端发起请求,服务端响应。

HTTP的传输过程一般分为以下4步完成:

  1. 建立HTTP连接,该链接基于TCP可靠传输之上。
  2. 建立连接后,客户端向服务器发送HTTP请求,请求的方式由GET、POST等。
  3. 服务端接收到请求后,进行处理,将处理的结果返回给客户端。
  4. 应答结束后,关闭HTTP连接。

由于每次答应结束后,都需要关闭连接,因此HTTP是一个无状态的协议,同一个客户端的这次请求和上次请求没有对应关系。

8.4.4 HTTP格式

1. 请求消息格式

请求消息由客户端向服务器发送,其消息由请求行、消息头和消息体构成。其中,请求行由Method、Request-URI(Uniform Resource Identifier,统一资源标识符)和HTTP/Version组成。请求行下面是消息头,消息头下面是消息体。下面为一个HTTP请求数据包:

POST /index.jsp?name=ddd&aa=age HTTP/1.1
Aceept: image/gif, image/jpeg, image/pjpeg, */*
Aeccpt-Language: zh-cn
User-Agent: Mozilla/4.0 (compatible; MSIE 8.0)
Content-Type: application/x-www-form-urlencoded
Aceept-Encoding: gzip, deflate
Host: 192.168.1.104:808
COntent-lenght: 11
Connection: Keep-Alive
Cache-Control: no-cache

name=shepherd

数据的第一行为请求行,它包括请求方法POST,请求的Request-URI地址/index.jsp,协议的版本HTTP/1.1。

POST /index.jsp HTTP/1.1

HTTP中有多种请求方法可供选择。HTTP/1.1支持7种请求方法,分别是GET、POST、HEAD、OPTIONS、PUT、DELETE和TRACE。其中,GET和POST是最常用的两种请求方法。

Request-URI表示请求访问的地址。一般都为相对路径,大都以“/”开始,从这段数据的请求行可以看出,请求访问的地址是/index.jsp。

HTTP/Version表示请求使用的是HTTP版本,这段数据使用的是HTTP/1.1。

请求行下面的就是消息头,消息头种包含很多客户端的运行环境信息,以及消息体信息。例如浏览器使用的语言、消息体的长度、链接状态等。消息头下面是个换行符,该换行符将消息头和消息体却分开来。

2. 响应消息格式

当服务器处理完客户请求后,就会以响应消息的形式,向客户端发送处理结果。响应消息也分为三个部分:响应行、消息头、消息体。

效应消息的响应行与请求行略有变化,首先是HTTP/Version即使用HTTP版本,接着是空格,空格后是响应的状态码Status,接着再是一个空格,空格后是相应描述Description。

HTTP/1.1 200 OK
Server: Apache-Coyote/1.1
Content-Type: text/html;charset=ISO-8859-1
Content-Length: 482
Data: Wed, 05 Sep 2012 01:53:16 GMT

...

响应的状态码显示了请求的处理结果,这个结果可以响应描述Description来进行简单的描述。常用的响应状态码分为5组,它们分别以1~5作为开头。因为与本章知识偏离,这里不多阐述。

8.4.5 简单介绍HTTP服务器

我们知道,HTTP基于TCP之上的传输协议,因此可以通过建立TCP连接,然后解析客户端的HTTP请求,并将处理结果以HTTP的格式发往客户端,即可已完成一次HTTP传输。HTTP服务器就是解析HTTP的请求信息,并得到解析信息;然后根据信息做后续事情。浏览器可以在网址输入栏上输入对应的IP地址(或服务器所占用的地址)加上端口也可以进行HTTP请求。在Java中HTTP传输形式为字符串,需要通过字符串解析来完成响应的识别,同时发送响应也利用字符串形式发送。值得一提的是,通常并不需要Java代码手把手来创建服务器,而是直接使用Tomcat以及类似的工具就能够创建一个本地服务器。下面会讲述Tomcat的基本原理和Web编程。

  1. 创建UDP的Socket服务,必须要明确一个端口,用于监听并接收数据。
  2. 定义数据包,用于存储接收到数据。
  3. 通过Socket服务的接收方法将收到的数据存储到数据包中
  4. 通过数据包的方法获取数据包中的具体数据内容,比如IP、端口、数据等等。
  5. 关闭资源。

下面演示了接收端的接收:

public class ReceiveEnd {
    public static void main(String[] args) throws SocketException {
        System.out.println("=========ReceiveEnd=========");
        DatagramSocket socket = new DatagramSocket(6666);

        byte[] dataContainer = new byte[1024];
        DatagramPacket packet = new DatagramPacket(dataContainer, dataContainer.length);

        System.out.println("Notice: Waiting for receive.");
        try {
            socket.receive(packet);
        } catch (IOException e) {
            System.out.println("Notice: Receive failed.");
        }
        
        System.out.println("Notice: Data has been received.");
        System.out.println("\t" + new String(packet.getData(), 0, packet.getLength()));
        
        socket.close();
    }
}

下面演示了代码的执行过程:

注意:因为UDP的独特性,发送与接收端可以不像TCP那样需要同时运行,但发送行为必须在接收行为之前。

  1. 运行发送端:

    ==========SendEnd===========
    Notice: Data has been sent.
    
  2. 运行接收端:

    =========ReceiveEnd=========
    Notice: Waiting for receive.
    
  3. 运行发送端:

    ==========SendEnd===========
    Notice: Data has been sent.
    
    =========ReceiveEnd=========
    Notice: Waiting for receive.
    Notice: Data has been received.
    	SendEnd: Send test.
    

对于简单的发送和接受数据来说,要实现一对一聊天或多人聊天则需要更多操作,例如老师对学生聊天,则需要在老师和学生的程序中分别创建两个线程,然后这两个线程分别用于发送和接受信息,同时为了安全性可以使用connect()方法指定连接位置。例如多人聊天,则需要在用户和信息分配器中分别创建两个线程用于发送和接受信息,信息分配器的接收线程则用于接收所有信息,发送线程则将所有新接收的信息返回。

8.4 HTTP编程

8.4.1 HTTP简介

HTTP时Hyper Text Transfer Protocol(超文本传输协议)的缩写。它的发展是万维网协会(World Wide Web Consortium)和Internet工作小组(Internet Engineering Task Force,IETF)合作的结果,它们最终发布了一系列的RFC(Request For Comments,是一系列以编号排定,收集了有关互联网相关信息的文件),RFC1945定义了HTTP/1.0版本,其中最著名的就是RFC2616,RFC2616定义了今天普遍使用的一个版本——HTTP 1.1

HTTP是用于从WWW服务器传输超文本到本地浏览器的传送协议,它可以使浏览器更加高效,使网络传输减少。它不仅保证计算机正确快速地传输超文本文档,还确定传输文档中的哪一部分,以及哪部分内容首先显示(如文本优先于图形:看视频网站时首先显示的是文字)等。

8.4.2 协议族中的HTTP

HTTP是一个构建与TCP之上的应用协议层协议,有时也承载了TLS或SSL协议层,这时候就成了人们常说的HTTPS。正因为是以TCP为基础的传输协议,因此它具有TCP传输安全可靠、需建立连接等特性。

安全传输层协议(TLS)用于在两个通信应用程序之阿金提供保密性和数据完整性。SSL为Netscape所研发,用以保障在Internet上数据传输的安全,利用数据加密(Encryption)技术,可以确保数据在网络上传输过程中不会被截取及窃听。HTTP在TCP/IP协议族中处于应用层,它是建立在可靠传输协议TCP之上的。HTTP默认使用80端口进行数据传输,HTTPS协议默认使用443端口进行数据传输。

8.4.3 HTTP传输模式

HTTP数据传输由请求和响应构成,是一个标准的客户端服务器模型。HTTP永远都是客户端发起请求,服务端响应。

HTTP的传输过程一般分为以下4步完成:

  1. 建立HTTP连接,该链接基于TCP可靠传输之上。
  2. 建立连接后,客户端向服务器发送HTTP请求,请求的方式由GET、POST等。
  3. 服务端接收到请求后,进行处理,将处理的结果返回给客户端。
  4. 应答结束后,关闭HTTP连接。

由于每次答应结束后,都需要关闭连接,因此HTTP是一个无状态的协议,同一个客户端的这次请求和上次请求没有对应关系。

8.4.4 HTTP格式

1. 请求消息格式

请求消息由客户端向服务器发送,其消息由请求行、消息头和消息体构成。其中,请求行由Method、Request-URI(Uniform Resource Identifier,统一资源标识符)和HTTP/Version组成。请求行下面是消息头,消息头下面是消息体。下面为一个HTTP请求数据包:

POST /index.jsp?name=ddd&aa=age HTTP/1.1
Aceept: image/gif, image/jpeg, image/pjpeg, */*
Aeccpt-Language: zh-cn
User-Agent: Mozilla/4.0 (compatible; MSIE 8.0)
Content-Type: application/x-www-form-urlencoded
Aceept-Encoding: gzip, deflate
Host: 192.168.1.104:808
COntent-lenght: 11
Connection: Keep-Alive
Cache-Control: no-cache

name=shepherd

数据的第一行为请求行,它包括请求方法POST,请求的Request-URI地址/index.jsp,协议的版本HTTP/1.1。

POST /index.jsp HTTP/1.1

HTTP中有多种请求方法可供选择。HTTP/1.1支持7种请求方法,分别是GET、POST、HEAD、OPTIONS、PUT、DELETE和TRACE。其中,GET和POST是最常用的两种请求方法。

Request-URI表示请求访问的地址。一般都为相对路径,大都以“/”开始,从这段数据的请求行可以看出,请求访问的地址是/index.jsp。

HTTP/Version表示请求使用的是HTTP版本,这段数据使用的是HTTP/1.1。

请求行下面的就是消息头,消息头种包含很多客户端的运行环境信息,以及消息体信息。例如浏览器使用的语言、消息体的长度、链接状态等。消息头下面是个换行符,该换行符将消息头和消息体却分开来。

2. 响应消息格式

当服务器处理完客户请求后,就会以响应消息的形式,向客户端发送处理结果。响应消息也分为三个部分:响应行、消息头、消息体。

效应消息的响应行与请求行略有变化,首先是HTTP/Version即使用HTTP版本,接着是空格,空格后是响应的状态码Status,接着再是一个空格,空格后是相应描述Description。

HTTP/1.1 200 OK
Server: Apache-Coyote/1.1
Content-Type: text/html;charset=ISO-8859-1
Content-Length: 482
Data: Wed, 05 Sep 2012 01:53:16 GMT

...

响应的状态码显示了请求的处理结果,这个结果可以响应描述Description来进行简单的描述。常用的响应状态码分为5组,它们分别以1~5作为开头。因为与本章知识偏离,这里不多阐述。

8.4.5 简单介绍HTTP服务器

我们知道,HTTP基于TCP之上的传输协议,因此可以通过建立TCP连接,然后解析客户端的HTTP请求,并将处理结果以HTTP的格式发往客户端,即可已完成一次HTTP传输。HTTP服务器就是解析HTTP的请求信息,并得到解析信息;然后根据信息做后续事情。浏览器可以在网址输入栏上输入对应的IP地址(或服务器所占用的地址)加上端口也可以进行HTTP请求。在Java中HTTP传输形式为字符串,需要通过字符串解析来完成响应的识别,同时发送响应也利用字符串形式发送。值得一提的是,通常并不需要Java代码手把手来创建服务器,而是直接使用Tomcat以及类似的工具就能够创建一个本地服务器。下面会讲述Tomcat的基本原理和Web编程。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值