类的定义
类的定义由访问级别、类型、类名、是否抽象、是否静态、泛型标识、继承或实现关键字、父类或接口名称等组成。类的访问级别有 public
和无访问控制符,类型分为 class
、 interface
、 enum
。
类主要由两部分组成:成员和方法。在定义类时,推荐首先定义变量,然后定义方法。公有方法是类的调用者和维护者最关心的方法,因此最好首屏展示;保护方法只被子类关心,重要性仅次于公有方法;私有方法对外部来说是一个黑盒实现,因此一般不需要被特别关注;最后是 getter/setter
方法,虽然它们也是公有方法,但是因为承载的信息价值较低,所以放在类最后。
方法顺序: 公有方法 -> 保护方法 -> 私有方法 -> getter/setter 方法 。
接口与抽象类
定义类的过程就是抽象和封装的过程,而接口与抽象类则是对实体类进行更高层次的抽象,仅定义公共行为和特征。接口与抽象类均不能被实例化,但可以定义引用变量指向实例对象。
接口与抽象类从语法上的区别如下:
抽象类在被继承时体现的是 is-a
关系,接口在被实现时体现的是 can-do
关系。抽象类是模板式设计,而接口是契约式设计。接口是顶级的 “类” ,虽然关键字是 interface
,但是编译之后的字码码扩展码仍是 .class
。抽象类是二当家,接口位于顶层,而抽象类对各个接口进行了组合,然后实现部分接口行为。
建议: 当纠结定义接口还是抽象类时,优先推荐定义为接口,遵循接口隔离原则,按某个维度划分成多个接口,然后再用抽象类去 implements
某些接口,这样做可方便后续的扩展和重构。
内部类
有如下4种类型:
- 静态内部类,如:
static class StaticInnerClass{}
; - 成员内部类,如:
private class InstanceInnerClass{}
; - 局部内部类,定义在方法或者表达式内部;
- 匿名内部类,如:
(new Thread(){}).start()
;
外部类与内部类之间使用 $ 符号分隔,匿名内部类使用数字进行编号,而方法内部类,在类名前还有一个编号来标识是哪个方法。匿名内部类和包内静态内部类是比较常用的方式,这样做的好处是:
- 作用域不会扩散到包外。
- 可以通过 “外部类.内部类”的方式直接访问。
- 内部类可以访问外部类中的所有静态属性和方法。
在 JDK 源码中,使用内部类封装某种属性和操作的方式比较常见。
访问权限控制
Java 中的访问权限包括四个等级,访问权限控制及可见范围如下表:
在定义类时,要慎重思考该方法、属性、内部类的访问权限,提倡严控访问范围:
- 如果不允许外部直接通过 new 创建对象,构造方法必须是
private
- 工具类不允许有
public
或default
构造方法 - 类非
static
成员变量并且与子类共享,必须是protected
- 类非
static
成员变量并且仅在本类使用,必须是private
- 类
static
成员变量如果仅在本类使用,必须是private
- 若是
static
成员变量,必须考虑是否为final
- 类成员方法只供类内部调用,必须是
private
- 类成员方法只对继承类公开,那么限制为
protected
this 与 super
如果 this
和 super
指代构造方法,即在一个构造方法中必须出现在方法体的第一行,只能使用 this
或者 super
之一,且只能出现一次。由于 this
和 super
都在实例化阶段调用,所以不能在静态方法和静态代码块中使用 this
和 super
关键字。
this
与 super
的异同点如下图:
类关系
有如下6种类型:
【继承】 extends (is -a)
【实现】 implements (can-do)
【组合】 类是成员变量 (contains-a)
【聚合】 类是成员变量 (has-a)
【依赖】 是除组合与聚合外的单向弱关系,比如使用另一个类的属性、方法或以其作为方法的参数输入或方法的返回值输出 (depends-a)
【关联】 是互相平等的依赖关系 (links-a)
分析:
组合:类关系中的组合是一种完全绑定的关系,所有成员共同完成一件使命,它们的生命周期是一样的。组合体现的是非常强的整体与部分的关系,同生共死,部分不能在整体之间共享。
聚合:聚合是一种可以拆分的整体与部分的关系,是非常松散的暂时组合,部分可以被拆出来给另加一个整体。
依赖:依赖往往是模块解耦的最佳点。
关联:可以在关联上进行解耦,但是难度大于依赖关系。
在业务重构过程中,往往会把原来强组合的关系拆开来,供其他模块调用。
序列化
内存中的数据对象只有转换为二进制流才可以进行数据持久化和网络传输。将数据对象转换为二进制流的过程称为对象的序列化。反之,将二进制流恢复为数据对象的过程称为反序列化。
Java 原生序列化
Java 类通过实现 Srializable
接口来实现该类对象的序列化,这个接口非常特殊,没有任何方法,只起标识作用。Java 序列化保留了对象类的元数据(如类、成员变量、继承类信息等),以及对象数据等,兼容性最好,但不支持跨_语言,而且性能一般。
实现 Serializable
接口的类建议设置 serialVersionUID
字段值,如果不设置,那么每次运行时,编译器会根据类的内部实现,包括类名、接口名、方法和属性等来自动生成 serialVersionUID
。如果类的源代码有修改,那么重新编译后 serialVersionUID
的取值可能会发生变化。因此实现 Serializable
接口的类一定要显式地定义 serialVersionUID
属性值。修改类时需要根据兼容性决定是否修改。
- 如果是兼容升级,请不要修改
serialVersionUID
字段,避免反序列化失败。 - 如果是不兼容升级,需要修改
serialVersionUID值
,避免反序列化混乱。
注意:使用 Java 原生序列化需注意, Java 反序列化时不会调用类的无参构造方法, 而是调用 native
方法将成员变量赋值为对应类型的初始值。基于性能及兼容性考虑,不推荐使用 Java 原生序列化。
Hessian 序列化
Hessian
序列化是一种支持动态类型、跨语言、基于对象传输的网络协议。 Java 对象序列化的二进制流可以被其他语言(如C+、Python)反序列化。耗时比原生的要快,但是 Hesian
会把复杂对象所有属性存储在一一个 Map
中进行序列化。所以在父类、子存在同名成员变量的情况下,Hessian
序列化时,先序列化子类,然后序列化父类,因此反序列化结果会导致子类同名成员变量被父类的值覆盖。
JSON 原生序列化
是一种轻量级的数据交换格式,将数据对象转换为 JSON
字符串,可读性比较好,方便调试。在序列化过程中抛弃了类型信息,所以反序列化时只有提供类型信息才能准确地反序列化。
注意:应用开发者对序列化要有一定的安全防范意识,对传入数据的内容进行校验或权限控制,及时更新安全漏洞,避免受到攻击。https://www.cnblogs.com/ssooking/p/5875215.html
方法
方法签名
方法签名包括方法名称和参数列表,是 JVM 标识方法的唯一索引,不包括返回值,更加不包括访问权限控制符、类型异常等。
参数
参数在方法中,属于方法签名的一部分,包括参数类型和参数个数,多个参数用逗号相隔。形参是在方法定义阶段,实参是方法调用阶段。
无论是对于基本数据类型,还是引用变量,Java 中参数传递都是值复制的传递过程。对于引用变量,复制指向对象的首地址,双方都可以通过自己的引用变量修改指向对象的相关属性。
可变参数
可变参数(JDK5):适用于不确定参数个数的场景(尽量不要使用,如果一定要用,一个方法中只能有一个可变参数,且要放在最后一个参数,建议不要用 Object
作为可变参数)。
用“参数类型...”的方式定义,如:
public PrintStream printf(String format, Object... args){
return format(format, args);
}
//调用方法示例
System.out.printf("%d", 8);
System.out.printf("%d %s", 8, "145sad");
方法体中应该对传入的参数保持理性不信任,所以方法的第一步骤不是功能实现,而是参数预处理:
- 入参保护:批处理数据时,对参数数量进行判断和控制。
- 参数校验
参数校验场景
需要进行参数校验的场景:
- 调用频度低的方法。
- 执行时间开销很大的方法。此情形中,参数校验时间几乎可以忽略不计,但如果因为参数错误导致中间执行回退或者错误,则得不信失。
- 需要极高稳定性和可用性的方法。
- 对外提供的开放接口。
- 敏感权限入口。
不需要进行参数校验的场景:
- 极有可能被循环调用的方法。但在方法说明里必须注明外部参数检查。
- 底层调用频度较高的方法。参数错误不太可能到底层才会暴露问题。
- 声明成
private
的方法,能够确定参数没有问题,可以不校验。
构造方法
方法名与类名相同的特殊方法,在对象初始化时可以通过不同的构造方法实现不同方式的对象初始化,有如下特征:
- 名称必须与类名相同。
- 是没有返回类型的,即使是
void
也不能有。它返回对象的地址并赋值给引用变量。 - 不能被继承,不能被覆写,不能被直接调用(通过
new
关键字,super
调用父类构造,反射方式)。 - 类定义时提供了默认无参构造,显式定义了有参构造,如果需要使用无参构造,也需显式定义无参构造。
- 可以设置为私有方法。
在类中,初始化顺序:静态代码块->构造方法。其中静态代码块只会被执行一次,第二次对象实例化时,不会运行。如果有继承关系,则初始化顺序为:父类静态代码块->子类静态代码块->父类构造方法->子类构造方法。
接口不能定义构造方法,抽象类可以。枚举类中可以定义,但不能加 public
修饰,默认是 private
的,是绝对的单例,不允许外部以创建对象的方式生成枚举对象。构造方法的使命是构造对象时进行传参操作,不要引入业务逻辑。可以将初始化业务逻放在某个方法中,比如 init()
,当对象确认完成初始化工作之后,再显式调用。
类内方法
除构造方法外,类中还有三类方法:实例方法、静态方法、静态代码块。
实例方法: 依附于某个实际对象,并可以通过引用变量调用其方法,只有在对象创建之后才会被分配入口地址,方法内可以调用静态变量和静态方法。
静态方法: 又称为类方法,当类加载后,即分配相应的内存空间,方法内不能使用实例成员变量和实例方法(可以调用静态变量和静态方法),不能使用 this
和 super
关键字。如果使用了可修改的对象,那么在并发时会存在线程安全问题。
静态代码块: 在类加载时就被调用,并且只执行一次,先于构造方法执行,不能存在于任何方法体内。方法内不能使用实例成员变量和实例方法(可以调用静态变量和静态方法),不能使用 this
和 super
关键字。
getter/setter
一般不包含任何业务逻辑,仅仅是为类成员属性的读取和修改,好处:
- 满足面向对象的封装特性。
- 有利于统一控制。
容易出错的定义方式:
getter/setter
中添加业务逻辑。- 同时定义
isXxx()
和getXxx() 。
- 相同的属性名容易带来歧义。
同步/异步
同步调用,是刚性调用,是阻塞式操作,必须等待调用方法体执行结束。
异步调用,是柔性调用,是非阻塞式操作,在执行过程中,如调用其他方法,自己可以继承执行而不被阻塞等待方法调用完毕。调用结果由某种机制反向通知。
覆写
如果父类定义的方法达不到子类的期望,那么子类可以重新实现方法覆盖父类的实现。因为有些子类是延迟加载的,甚至是网络加载的,所以最终的实现需要在运行期判断,这就是所谓的动态绑定。动态绑定是多态性得以实现的重要因素,元空间有一个方法表保存着每 个可以实例化类的方法信息,JVM可以通过方法表快速地激活实例方法。如果某个类覆写了父类的某个方法,则方法表中的方法指向引用会指向子类的实现处。
覆写是子类实现接口或者继承父类时,保持方法签名一致,实现不同的方法体,是垂直方向上行为的不同实现。
代码通常是用这样的方式来调用子类的方法,通常这也被称作向上转型:
Father father = new Son();
//Son覆写了此方法
Father.doSomething();
向上转型时,通过父类引用执行子类方法时需要注意以下两点:
- 无法调用到子类中存在而父类本身不存在的方法。
- 可以调用到子类中覆写了父类的方法,这是一种多态实现。
想成功地覆写父类方法,需要满足以下4个条件(一大两小两同):
- 一大: 子类的方法访问权限只能相同或者变大。
- 两小: 抛出异常和返回值只能变小,能够转型成父类对象。
- 两同: 方法名和参数必须严格一致。
注意:覆写只能针对非静态,非 final
、非构造方法。如果想在子类覆写的方法中调用父类方法,可以使用 super
关键字
重载
重载是在同一个类中,如果多个方法有相同的名字、不同的参数,是水平方向上行为的不同实现。
JVM 在重载方法中,选择合适的目标方法的顺序如下:
- 精确匹配。
- 如果是基本数据类型,自动转换成更大表示范围的基本类型。
- 通过自动拆箱与装箱。
- 通过子类向上转型继承路线依次匹配。
- 通过可变参数匹配。
泛型
在没有泛型的时候,我们通常将入参定义为 Object
类型,进行强制类型转换时存在风险,泛型则能够解决这个问题。泛型的本质是类型参数化,解决不确定具体对象类型的问题。泛型可以定义在类、接口、方法中,编译器通过识别尖括号和括号内的内容来解析泛型。泛型定义时的,约定俗成的符号包括如下:
- E(Element),用于集合中的元素。
- T(the Type of object),表示某个类。
- K(Key)、V(Value),用于键值对元素。
如下泛型方法能正常编译和运行:
public class GenericDemo<T> {
static <String, T, Alibaba> String get(String string, Alibaba alibaba) {
return string;
}
public static void main(String[] args) {
Integer first = 222;
Long second = 333L;
Integer result = get(first, second);
System.out.println(result);
}
}
加深理解:
- 尖括号里的每个元素都指代种未知类型
Srinrg
出现在尖括号里,它就不是java.lamg.sring
,而仅仅是一个代号。 类名后方定义的泛型<T>
和get()
前方定义的<T>
是两个指代,可以完全不同,互不影响。 - 尖插号的位置非常讲究,必须在类名之后或方法返回值之前。
- 泛型在定义处只具备执行
Object
方法的能力。因此想在get()
内部执行string.longValue() + albaba.intValure()
是做不到的,此时泛型只能调用Object
类中的方法,如toString()
。 - 对于编译之后的字节码指令,其实没有这些花头花脑的方法签名,充分说明了泛型只是一种编写代码时的语法检查。(类型擦除)
使用泛型的好处:
- 类型安全
- 提升可读性
- 代码重用
数据类型
基本数据类型
基本数据类型是指不可再分的原子数据类型,内存中直接存储此类型的值,通过内存地址即可直接访问到数据,并且此内存区域只能存放这种类型的值。共有9种基本数据类型,分别是:
boolean、byte、char、short、int、long、float、double、refvar(引用变量、引用句柄)
除了最后一个,其他都有包装类: char
对应的为 Character
和 int
对应的为 Integer
,其他对包装类名就是把首字母大写即可。
注意:浮点数使用后缀 f
和 d
区别标识单精度和双精度,没写则默认为双精度。字符不可以用双引号方式赋值,那是字符串的表示方式。
引用分成两种数据类型: 引用变量本身(refvar
)和引用指向的对象(refobj
)。refvar
是基本的数据类型,默认值为 null
,存储 refobj
的首地址,可以直接使用双等号 ==
进行等值判断。
不论是多么小的对象,最少占用空间 12B
(用于存储基本信息,称为对象头),但由于存储空间分配必须是 8B
的倍数,所以初始分配空间至少是 16B
。
对象分为三块存储区:
·对象头(12B)
对象标记:哈希码、[GC标记]、[GC次数]、[同步锁标记]、[偏向锁持有者]。占用8B,存储格式与JVM具体实现有关。
类元信息:存储对象指向它的类元数据即首地址,占用4B
·实例数据:存储本类的实例成员变量和所有可见的父类成员变量。
·对齐填充:对象的存储空间分配单位是8个字节,所以不足8的倍数会自动填充
包装类型
解决了基本数据类型无法做到的事情:泛型类型参数、序列化、类型转换、高频区间数据缓存,尤其是最后一项。
推荐所有包装类对象之间值的比较都用 equals()
方法。(因为超出缓存区间的值就无法用 ==
比较了,在缓存区间内时,对象是由 IntegerCache.cache
产生的,会复用已有对象,所以可以用 ==
比较),对6个包装类直接赋值时,就是调用对应包装类的静态工厂方法。以 Integer
为例:
public static Integer valueOf(int i) {
if(i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
所以,推荐使用 valueOf()
,合理的利用缓存,提升程序性能。各包装类缓存区间,在上表。其中,Integer
是唯一一个可以修改缓存范围的包装类,在VM options
加入参数 -XX:AutoBoxCacheMax=7777
,即可设置最大缓存为7777。
在选择使用包装类还是基本数据类型的时候,推荐使用如下方法:
- 所有的
POJO
(简单Java对象)类属性必须使用包装数据类型 RPC
方法返回值和参数必须使用包装数据类型- 所有的局部变量推荐使用基本数据类型
字符串
字符串不是基本数据类型,是堆上分配来的。字符串相关类型主要有三种:
String
是只读字符串,典型的 immutable
对象,对它的任何改动,其实都是创建一个新对象,再把引用指向该对象。String
对象赋值操作后,会在常量池中进行缓存,如果下次申请创建对象时,缓存已经存在,则直接返回相应引用
StringBuffer
可以在原对象上进行修改,是线程安全的。JDK5引入的 StringBuilder
与 StringBuffer
均继承自AbstractStringBuilder
,两个子类的很多方法都是通过"super
. 方法()”的方式调用抽象父类中的方法,此抽象类在内部与 String
一样,也是以字符数组的形式存储字符串的。
StringBuilder
可以在原对象上进行修改,是非线程安全的,把是否需要进行多线程加锁交给工程师决定,操作效率比 StringBuffer
高。