一.JAVA关键字及标识符
1.标识符定义
-
包名、类名、方法名、参数名、变量名等,这些符号被称为标识符
-
标识符可以由字母、数字、下划线、和美元符号$组成
-
标识符不能以数字开头、不能是java的关键字
-
首字符之后可以是字母、下划线、美元符号或数字的任何字符
-
java区分大小写,因此myvar和MyVar是两个不同的标识符
-
不可以使用关键字和保留字作为标识符,但是标识符中能包含关键字和保留字
-
标识符不能包含空格
2.Java标识符规则
1、包名所有字母必须小写。例如:cn.com.test
2、类名和接口名每个单词的首字母都要大写。例如:ArrayList
3、常量名所有的字母都大写,单词之间用下划线连接。例如:DAY_OF_MONTH
变量名和方法名的第一个单词首字母小写,从第二个单词开始,每个单词首字母大写。例如:lineName、getLingNumber
4、在程序中,应该尽量使用有意义的英文单词来定义标识符,使得程序便于阅读。例如:使用userName表示用户名,password表示密码。
3.Java 关键字
Java 语言目前定义了 51 个关键字,这些关键字不能作为变量名、类名和方法名来使用。以下对这些关键字进行了分类。
关键字
数据类型 boolean、int、long、short、byte、float、double、char、class、interface、enum、void
流程控制 if、else、do、while、for、switch、case、default、break、continue、return
异常处理 try、catch、finally、throw、throws
修饰符 public、protected、private、final、void、static、strict、abstract、transient、synchronized、volatile、native
类与类之间关系 extends、implements
建立实例及引用实例 this、supper、instanceof、new
导包 package、import
保留字
保留字:Java版本尚未使用,但以后版本可能会作为关键字使用。
在命名标记符时要避免使用这些保留字:
goto、const、byValue、cast、future、 generic、 inner、 operator、 outer、 rest、 var
二.Java变量与常量
变量就是指代在内存中开辟的存储空间,用于存放运算过程中需要用到的数据。
变量的声明
-
变量必须先声明后使用。
-
相同的变量只能声明一次。
-
格式:变量类型 变量名 = 变量值
变量的命名
-
满足标识符的命名规则;
-
符合驼峰法命名规范;
-
尽量简单,做到见名知意;
-
变量名的长度没有限制,但尽量做到第三条;
自动类型转换(向上转型)
红实线:无数据丢失的自动类型转换 黑虚线:在转换时可能会有精度的损失
-
将较小范围的数据类型赋值给较大范围的数据类型(例如:
int到long,float到double)。 -
在不同类型的数据进行运算时,较小的数据类型会自动提升为较大的数据类型。
强制类型转换(向下转型)
当较大范围的数据类型赋值给较小范围的数据类型时,必须使用强制类型转换
数据类型
基本数据类型是CPU可以直接进行运算的类型,包括:
-
整数类型:byte(1字节)、short(2字节)、int(4字节)、long(8字节);
-
浮点数类型:float(4字节)、double(8字节);
-
字符类型:char(2字节);
-
布尔类型:boolean;
包装类
由于基本数据类型不是继承自Object,没有对象的特征,比如:基本数据类型不能调用方法
包装类属于引用数据类型,8种基本数据类型都对应了一个包装类:
Byte、Short、Integer、Long、Float、Double、Boolean、Charater
手动拆箱/装箱
//拆箱
Integer j = new Integer(8); // 定义一个 Integer 包装类对象,值为8
int m = j.intValue(); // 手动拆箱 int 类型
int n = j; // 自动拆箱为 int 类型
//装箱
int i = 10; // 定义一个 int 基本类型值
Integer x = new Integer(i); //手动装箱
Integer y = i; // 自动装箱
-
自动装箱调用了ValueOf()将原始类转换为包装类
-
自动拆箱调用了对应数据类型的intValue()之类的方法将对象转换成基本数据类型
-
只有double和float的自动装箱代码没有使用缓存,每次都是new 新的对象,其它的6种基本类型都使用了缓存策略,在缓存范围内的赋值会直接调用缓存中的对象
JAVA常量
所谓常量可以理解成一种特殊的变量,它的值被设定后,在程序运行过程中不允许被改变。 在这里要注意常量和常量值是不同的概念,常量值是常量的具体和直观的表现形式,常量是形式化的表现
常量值:
常量值又称为字面常量,它是通过数据直接表示的。
整型常量值
十进制数:如 11、45等;
二进制数:二进制常数的表示以 0b 开头,如 0b00001010 表示十进制 10。
八进制数:八进制常数的表示以 0 开头,如 0125 表示十进制数 85。
十六进制数:十六进制常数的表示以 0x 或者 0X 开头,如0x100 表示十进制数 256。
浮点型常量值
十进制数:由数字和小数点组成,且必须有小数点,如 12.34、98.0。
科学计数法:如 1.75e5、32E3,其中 e 或者 E 之前必须有数字,且 e 或 E 之后的数字必须为整数。
布尔型常量值:布尔型常量只有两个值,即 false(假)和 true(真)。
字符型常量
单引号字符:使用单引号引起来的一个字符或者一串转义字符,如 ‘a’,’\u62’ 都表示字符a。
数值:使用数值来表示,如 65 表示字符 A,97 表示字符 a。
常量:
常量不同于常量值,它可以在程序中用标识符来代替常量值使用,因此使用前必须先定义。
-
使用 final 关键字来定义常量。
-
声明常量的同时要赋予一个初始值。
-
常量一旦初始化就不可以被修改。
-
为了与变量区别,常量声明一般都用大写字符。
三.JAVA常用类
-
Object
Object类是所有类的父类,也就是说任何一个类在定义时如果没有明确地继承一个父类,那它就是Object类的子类
Object类提供无参构造方法 ,之所以提供这样的无参构造,是因为在子类对象实例化时都会默认调用父类中的无参构造方法,这样在定义类时即使没有明确定义父类为Object,读者也不会感觉代码的强制性要求
| 常用方法 | 说明 |
| Object clone() | 创建与该对象的类相同的新对象 |
| boolean equals(Object) | 比较两个对象是否相等。默认比较的是地址值,与==相同 |
| void finalize() | 当垃圾回收器确定不存在对该对象的更多引用时,对象的垃圾回收器调用该方法 |
| Class getClass() | 返回一个对象运行时的实例类(.class文件) |
| int hashCode() | 返回该对象的散列码值,真实的地址值 |
| void notify() | 激活等待在该对象的监视器上的一个线程 |
| void notifyAll() | 激活等待在该对象的监视器上的全部线程 |
| String toString() | 返回该对象的字符串表示,默认返回运行时类名+@+对象的hashCode的16进制数 |
| void wait() | 在其他线程调用此对象的 notify() 方法或 notifyAll() 方法前,导致当前线程等待 |
注意:
-
如果两个对象的哈希码值不同,那这两个对象一定不等;
-
如果两个对象的哈希码值相同,不能确保这两个对象一定相等。
-
克隆对象对应的类需要实现Cloneable接口,否则会报错:java.lang.CloneNotSupportedException
-
常覆写Object类的3个方法:toString(),equals(Object obj),hashCode()
如何获取类的字节码文件对象?
-
类名.class 说明: JVM将使用类装载器, 将类装入内存(前提是:类还没有装入内存),不做类的初始化工作.返回Class的对象
-
Class.forName("类名字符串") (注:类名字符串是包名+类名) 说明:装入类,并做类的静态初始化,返回Class的对象
-
实例对象.getClass() 说明:对类进行静态初始化、非静态初始化; 返回引用o运行时真正所指的对象(因为:子对象的引用可能会赋给父对象的引用变量中) 所属的类的Class的对象
为什么notify(), wait()等函数定义在Object中,而不是Thread中?
因为wait和notify函数都是通过操作对象的同步锁,来进行关联的,而且每个对象有且只有一个对象锁,所以方法定义在Object中
-
String、StringBuffer/Builder
1.String
重写后的equals:比较两个字符串的值内容是否相等
重写后的hashcode:获取变量的内容值
2.StringBuilder类
线程不安全的可变字符序列
1)构造方法
StringBuilder():以默认容量创建空的StringBuilder对象
StringBuilder(int capacity):以指定容量创建空的StringBuilder对象
StringBuilder(String str):以指定的字符串创建StringBuilder对象
2)成员方法
-
获取功能
-
int capacity():获取容量
-
int length():获取长度
-
-
添加功能
-
append(int value):追加。可以追加多种类型
-
insert(int offset,String s):在指定的位置插入指定数据
-
-
删除功能
-
deleteCharAt(int index):删除指定索引处的元素
-
delete(int start,int end):删除[start,start-1]范围内的元素
-
-
替换功能
- replace(int start,int end,String s):将[start,end-1]范围内的元素替换成指定字符串
-
反转功能
- reverse():元素反转
-
截取功能
-
String subString(int start):截取指定位置一直到末尾
-
String subString(int start,int end):截取[start,end-1]范围
-
String、StringBuilder和StringBuffer的区别?
-
String内容不可改变
-
StringBuilder和StringBuffer内容可变
-
StringBuilder是线程不安全的,不同步,效率高
-
StringBuffer是线程安全的,同步,效率低
-
时间类
-
DateFormat
它是一个抽象类,用来格式化或者解析日期
格式化:Date————》String
解析:String————》Date
getDateInstance(int style,Locale loc):获取日期格式器,该格式器具有给定的语言环境和给定的格式化风格
String format(Date date):格式化
Date parse(String time):解析
-
SimpleDateFormat
它是DateFormat的子类
- 构造方法
SimpleDateFormat():以默认模式创建对象
SimpleDateFormat(String pattern):以指定模式创建对象
常用模式
yyyy:年 MM:月 dd:日
HH:时 mm:分 ss:秒
- 常成员方法
String format(Date date):格式化
Date parse(String time):解析
-
Calendar类
日历类,用于获取或者操作日历字段(年月日)
该类是一个抽象类,不可以实例化
成员方法
-
static Calender getInstance():以默认时区和语言创建日历
-
int get(int field):获取指定字段的日历值
-
set(int field,int value):给指定的日历字段设置指定的值
-
set(int year,int month,int date):设置年月日
-
Date getTime():获取日历对象的日期
-
setTime(Date d):设置日历对象的日期
-
add(int field,int amount):对指定的日历字段添加指定的值
-
比较器
-
实现Comparator类
public interface Comparator<T> { int compare(T o1, T o2); boolean equals(Object obj); }
int compare(T o1, T o2) 是“比较o1和o2的大小”。当o1<o2时return -1, o1=o2时return 0, o1 > o2时return 1。
-
自定义比较器
这个比较简单,点进Comparable接口中发现只有一个方法compareTo,只要在需要排序的类上实现comparable接口,并重写compareTo方法即可:
四.面向对象
三大核心特性:
-
可重用性:代码重复使用,减少代码量,提高开发效率。面向对象的三大基本特征(继承、封装和多态)都围绕这个核心。
-
可扩展性:指新的功能可以很容易地加入到系统中来,便于软件的修改。
-
可管理性:能够将功能与数据结合,方便管理
1.继承
使子类具有父类的属性和方法,还可以在子类中重新定义(重载或重写),追加属性和方法
优点: 提高代码的复用性、提高维护性、和类和类之间的关系,是多态的前提
父类:基类、超类
子类 :派生类
继承只允许使用父类公共的东西(变量/方法),不允许使用私有的
(子类不能继承父类的私有属性)
但如果子类中公有的方法影响到了父类的私有属性,那么私有属性是能够被子类使用的
只允许单继承,一个儿子只能有一个爹
允许多层继承,一个儿子可以有一个爹,也可以有一个爷爷,也就是父亲可以有一个父亲
格式:public class 子类名 extends 父类名{ }
/*
//父类中的成员变量不能用private私有修饰,否则子类无法访问
1.(方法中的变量)局部变量直接访问
2.本类成员变量访问用this.成员变量
3.父类中的成员变量访问用super.成员变量
*/
继承在使用中的注意细节
/*
1、Java语言只支持单一继承(不能多继承) , 允许多层次继承
2、子类不能继承父类中的私有成员
3、当子类和父类中存在相同的成员时,访问原则:就近原则
*/
super.成员变量
super.成员方法()
super()
super(参数, ...)
4、子类对象在实例化完成之前,必须先初始化父类对象(只有父类初始化完成后子类才可以访问父类成员)
//子类构造方法在执行时,有隐藏代码:super() //默认调用父类中的无参构造方法
方法重载
指同一个类中定义的多个方法之间的关系,满足下列条件的多个方法相互构成重载
-
多个方法在同一个类中
-
多个方法具有相同的方法名
-
多个方法的参数不相同,类型不同或者数量不同
知道了什么是方法重载后,我们来说一下方法重载的特点:
-
类型不同或者数量不同重载仅对应方法的定义,与方法的调用无关,调用方式参照标准格式
-
重载仅针对同一个类中方法的名称与参数进行识别,与返回值无关,换句话说不能通过返回值来判定两个方法是否相互构成重载
方法重写
@Override //注解,(作用:检查父类中是否要重写的方法存在【检查语法】)
当父子类中存在一模一样的非私有成员方法时,就可以称为:子类重写父类的方法(方法重写)
解决的问题:
当父类中的方法,子类在调用时无法满足子类的要求,就可以在子类中重写父类的方法
2.封装
封装是将代码及其处理的数据绑定在一起的一种编程机制,该机制保证了程序和数据都不受外部干扰且不被误用。
封装的目的在于保护信息,保证数据的安全性
封装包括两个方面:属性的封装和方法的封装
Java 提供了私有和公有的访问模式,类的公有接口代表外部的用户应该知道或可以知道的每件东西,私有的方法数据只能通过该类的成员代码来访问, 封装不是绝对的封装,如果想要访问封装起来的数据,可以通过指定的入口即可
属性和方法的封装两者是相辅相成、密不可分的
private 私有成员,只能在本类中被访问,
this 代表对象,可以调用本类中的成员,指代成员变量,防止同名形参就近赋值
方法: 提高代码复用性
private: 提高代码的安全性
构造方法
本质上:作用就是创建对象,借助构造方法的加载时机,完成赋值
new 一次开辟一个空间 ,new一次加载一次
3.多态
多态就是同一函数在不同类中有不同的实现;
-
面向对象的多态性,即“一个接口,多个方法”。
-
多态性体现在父类中定义的属性和方法被子类继承后,可以具有不同的属性或表现方式。
-
多态性允许一个接口被多个同类使用,弥补了单继承的不足。
多态的使用
//多态的代码体现格式:
父类型 对象 = new 子类(); //大类型包含小类型 int a=10; long b = a;
泛型
为什么使用泛型
-
与使用 Object 对象代替一切引用数据类型对象这样简单粗暴方式相比,泛型使得数据类型的类别可以像参数一样由外部传递进来。它提供了一种扩展能力,更符合面向对象开发的软件编程宗旨。
-
当具体的数据类型确定后,泛型又提供了一种类型安全检测机制,只有数据类型相匹配的变量才能正常的赋值,否则编译器就不通过。所以说,泛型一定程度上提高了软件的安全性,防止出现低级的失误。
-
泛型提高了程序代码的可读性。在定义泛型阶段(类、接口、方法)或者对象实例化阶段,由于 < 类型参数 > 需要在代码中显式地编写,所以程序员能够快速猜测出代码所要操作的数据类型,提高了代码可读性。
1.泛型类
(1)类型参数用于类的定义中,则该类被称为泛型类。通过泛型可以完成对一组类的操作对外开放相同的接口。最典型的就是各种容器类,如:List、Set、Map等。
尖括号 <> 中的 泛型标识被称作是类型参数,用于指代任何数据类型。
泛型标识是任意设置的(如果你想可以设置为 Hello都行),Java 常见的泛型标识以及其代表含义如下:
T :代表一般的任何类。
E :代表 Element 元素的意思,或者 Exception 异常的意思。
K :代表 Key 的意思。
V :代表 Value 的意思,通常与 K 一起配合使用。
S :代表 Subtype 的意思,文章后面部分会讲解示意。
(2)泛型类中的静态方法和静态变量不可以使用泛型类所声明的类型参数
-
泛型类中的类型参数的确定是在创建泛型类对象的时候(例如 ArrayList< Integer >)。
-
而静态变量和静态方法在类加载时已经初始化,直接使用类名调用;在泛型类的类型参数未确定时,静态成员有可能被调用,因此泛型类的类型参数是不能在静态成员中使用的。
(3)泛型类不只接受一个类型参数,它还可以接受多个类型参数。
在创建泛型类的对象时,必须指定类型参数 T 的具体数据类型,即尖括号 <> 中传入的什么数据类型,T 便会被替换成对应的类型。如果 <> 中什么都不传入,则默认是 < Object >。
2.泛型接口
泛型接口中的类型参数,在该接口被继承或者被实现时确定。解释如下:
-
继承泛型接口时需要确定泛型接口的类型参数
-
如果没确定则默认是Object
-
也可以将继承类也定义为泛型类,但是泛型参数要与接口一致
3.泛型方法
泛型方法的定义
当在一个方法签名中的返回值前面声明了一个 < T > 时,该方法就被声明为一个泛型方法。< T >表明该方法声明了一个类型参数 T,并且这个类型参数 T 只能在该方法中使用。当然,泛型方法中也可以使用泛型类中定义的泛型参数。
基本语法如下:
public <类型参数> 返回类型 方法名(类型参数 变量名) {
……
}
-
泛型方法中可以同时声明多个类型参数。
-
泛型方法中也可以使用泛型类中定义的泛型参数
泛型类,在创建类的对象的时候确定类型参数的具体类型; 泛型方法,在调用方法的时候再确定类型参数的具体类型。
在调用泛型方法的时候,可以显式地指定类型参数,也可以不指定。
当泛型方法的形参列表中有多个类型参数时,在不指定类型参数的情况下,方法中声明的的类型参数为泛型方法中的几种类型参数的共同父类的最小级,直到 Object。
在指定了类型参数的时候,传入泛型方法中的实参的数据类型必须为指定数据类型或者其子类。
类型擦除
- 什么是类型擦除
泛型的本质是将数据类型参数化,它通过擦除的方式来实现,即编译器会在编译期间擦除代码中的所有泛型语法并相应的做出一些类型转换动作。
换而言之,泛型信息只存在于代码编译阶段,在代码编译结束后,与泛型相关的信息会被擦除掉,专业术语叫做类型擦除。也就是说,成功编译过后的 class 文件中不包含任何泛型信息,泛型信息不会进入到运行时阶段。
泛型信息(包括泛型类、接口、方法)只在代码编译阶段存在,在代码成功编译后,其内的所有泛型信息都会被擦除,并且类型参数 T 会被统一替换为其原始类型(默认是 Object 类,若有 extends 或者 super 则另外分析);
在泛型信息被擦除后,若还需要使用到对象相关的泛型信息,编译器底层会自动进行类型转换(从原始类型转换为未擦除前的数据类型)。
擦除 ArrayList< Integer > 的泛型信息后,get() 方法的返回值将返回 Object 类型,但编译器会自动插入 Integer 的强制类型转换。也就是说,编译器把 get() 方法调用翻译为两条字节码指令:
-
对原始方法 get() 的调用,返回的是 Object 类型;
-
将返回的 Object 类型强制转换为 Integer 类型;
通配符
上界:用 extends 关键字声明,表示参数化的类型可能是所指定的类型,或者是此类型的子类。
-
如果传入的类型不是 E 或者 E 的子类,编译不成功
-
泛型中可以使用 E 的方法,要不然还得强转成 E 才能使用
用 super 进行声明,表示参数化的类型可能是所指定的类型,或者是此类型的父类型,直至 Object。
上界通配符(extends)主要用于读数据,下界通配符(super)主要用于写数据
4.拷贝
引用拷贝
引用拷贝会生成一个新的对象引用地址,但是两个最终指向依然是同一个对象。如何更好的理解引用拷贝呢?很简单,就拿我们人来说,通常有个姓名,但是不同场合、人物对我们的叫法可能不同,但我们很清楚哪些名称都是属于"我"的!
浅拷贝
如何创建一个对象,将目标对象的内容复制过来而不是直接拷贝引用呢?
这里先讲一下浅拷贝,浅拷贝会创建一个新对象,新对象和原对象本身没有任何关系,新对象和原对象不等,但是新对象的属性和老对象相同。具体可以看如下区别:
-
如果属性是基本类型(int,double,long,boolean等),拷贝的就是基本类型的值;
-
如果属性是引用类型,拷贝的就是内存地址(即复制引用但不复制引用的对象) ,因此如果其中一个对象改变了这个地址,就会影响到另一个对象。
如果用一张图来描述一下浅拷贝,它应该是这样的:
深拷贝
对于上述的问题虽然拷贝的两个对象不同,但其内部的一些引用还是相同的,怎么样绝对的拷贝这个对象,使这个对象完全独立于原对象呢?就使用我们的深拷贝了。深拷贝:在对引用数据类型进行拷贝的时候,创建了一个新的对象,并且复制其内的成员变量。
在具体实现深拷贝上,这里提供两个方式,重写clone()方法和序列法。
(1).重写clone()方法
如果使用重写clone()方法实现深拷贝,那么要将类中所有自定义引用变量的类也去实现Cloneable接口实现clone()方法。对于字符类可以创建一个新的字符串实现拷贝。
对于上述代码,Father类实现Cloneable接口并重写clone()方法。son的clone()方法需要对各个引用都拷贝一遍。
(2).序列化
可以发现这种方式实现了深拷贝。但是这种情况有个问题,如果引用数量或者层数太多了怎么办呢?
不可能去每个对象挨个写clone()吧?那怎么办呢?借助序列化啊。
因为序列化后:将二进制字节流内容写到一个媒介(文本或字节数组),然后是从这个媒介读取数据,原对象写入这个媒介后拷贝给clone对象,原对象的修改不会影响clone对象,因为clone对象是从这个媒介读取。
熟悉对象缓存的知道我们经常将Java对象缓存到Redis中,然后还可能从Redis中读取生成Java对象,这就用到序列化和反序列化。一般可以将Java对象存储为字节流或者json串然后反序列化成Java对象。因为序列化会储存对象的属性但是不会也无法存储对象在内存中地址相关信息。所以在反序列化成Java对象时候会重新创建所有的引用对象。
在具体实现上,自定义的类需要实现Serializable接口。在需要深拷贝的类(Son)中定义一个函数返回该类对象:
五.集合类
集合就是相关数据结构加上算法,所以要先了解算法复杂度
-
算法复杂度
-
指导你编写出性能更优的代码
-
评判别人写的代码的好坏
1.1 时间复杂度
上图代码执行总耗时T(n) = (3n+3)*1ms
用大O表示就是O(n)
常见复杂度表示形式:常对幂指阶
1.2 空间复杂度
空间复杂度全称是渐进空间复杂度,表示算法占用的额外存储空间与数据规模之间的增长关系
常见的有O(1),O(n),O(n ^2)
-
数组Array
数组(Array)是一种用连续的内存空间存储相同数据类型数据的线性数据结构。
在数组在内存中查找元素的时候,是有一个寻址公式的,如下:
arr[i] = baseAddress + i * dataTypeSize
baseAddress:数组的首地址,目前是10
dataTypeSize:代表数组中元素类型的大小,目前数组重存储的是int型的数据,dataTypeSize=4个字节
arr:指的是数组
i:指的是数组的下标
有了寻址公式以后,我们再来获取一下下标为1的元素,这个是原来的数组
int[] array = {22,33,88,66,55,25};
套入公式:
array[1] =10 + i * 4 = 14
获取到14这个地址,就能获取到下标为1的这个元素了。
操作数组根据随机下标查找的时间复杂度是O(1)
-
ArrayList
3.1 成员变量
DEFAULT_CAPACITY = 10; 默认初始的容量**(CAPACITY)
EMPTY_ELEMENTDATA = {}; 用于空实例的共享空数组实例
DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};用于默认大小的空实例的共享空数组实例
Object[] elementData; 存储元素的数组缓冲区
int size; ArrayList的大小(它包含的元素数量)
3.2 构造方法
第一个构造是带初始化容量的构造函数,可以按照指定的容量初始化数组
第二个是无参构造函数,默认创建一个空集合{}
将collection对象转换成数组,然后将数组的地址的赋给elementData
3.3 添加数据流程
结论:
- 底层数据结构
ArrayList底层是用动态的数组实现的
- 初始容量
ArrayList初始容量为0,当第一次添加数据的时候才会初始化默认容量为10
- 扩容逻辑
ArrayList在进行扩容的时候是原来容量的1.5倍(原来容量/2+原来容量),每次扩容都需要拷贝数组
-
添加逻辑
-
确保数组已使用长度(size)加1之后足够存下下一个数据
-
计算数组的容量,如果当前数组已使用长度+1后的大于当前的数组长度,则调用grow方法扩容(原来的1.5倍)
-
确保新增的数据有地方存储之后,则将新元素添加到位于size的位置上。
-
返回添加成功布尔值。
-
-
如果是有参构造,那么构造方法只是声明和实例了一个ArrayList,并指定了一个容量,并没有进行扩容。
如何实现数组和List之间的转换?
-
数组转List,使用JDK的Java.util.Arrays工具类的asLists方法即可
-
List转数组,使用List的toArray方法。无参toArray方法返回 Object数组,传入初始化长度的数组对象,返回该对象数组
面试官再问:
1,用Arrays.asList转List后,如果修改了数组内容,list受影响吗
2,List用toArray转数组后,如果修改了List内容,数组受影响吗
数组转List受影响
List转数组不受影响
再答:
1,用Arrays.asList转List后,如果修改了数组内容,list受影响吗
Arrays.asList转换list之后,如果修改了数组的内容,list会受影响,因为它的底层使用的Arrays类中的一个内部类ArrayList来构造的集合,在这个集合的构造器中,把我们传入的这个集合进行了包装而已,最终指向的都是同一个内存地址
2,List用toArray转数组后,如果修改了List内容,数组受影响吗
list用了toArray转数组后,如果修改了list内容,数组不会影响,当调用了toArray以后,在底层是它是进行了数组的拷贝,跟原来的元素就没啥关系了,所以即使list修改了以后,数组也不受影响
4.LinkList
1 单向链表
-
链表中的每一个元素称之为结点(Node)
-
物理存储单元上,非连续、非顺序的存储结构
-
单向链表:每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。记录下个结点地址的指针叫作后继指针 next
(1)查询操作
-
只有在查询头节点的时候不需要遍历链表,时间复杂度是O(1)
-
查询其他结点需要遍历链表,时间复杂度是O(n)
(2)插入和删除操作
-
只有在添加和删除头节点的时候不需要遍历链表,时间复杂度是O(1)
-
添加或删除其他节点需要遍历链表找到对应节点后,才能完成新增或删除节点,时间复杂度是O(n)
2 双向链表
而双向链表,顾名思义,它支持两个方向
-
每个结点不止有一个后继指针 next 指向后面的结点
-
有一个前驱指针 prev 指向前面的结点
对比单链表:
-
双向链表需要额外的两个空间来存储后继结点和前驱结点的地址
-
支持双向遍历,这样也带来了双向链表操作的灵活性
双向链表时间复杂度分析
(1)查询操作
-
查询头尾结点的时间复杂度是O(1)
-
平均的查询时间复杂度是O(n)
-
给定节点找前驱节点的时间复杂度为O(1)
(2)增删操作
-
头尾结点增删的时间复杂度为O(1)
-
其他部分结点增删的时间复杂度是 O(n)
-
给定节点增删的时间复杂度为O(1)
ArrayList和LinkedList的区别是什么?
-
底层数据结构
-
ArrayList 是动态数组的数据结构实现
-
LinkedList 是双向链表的数据结构实现
-
-
操作数据效率
-
ArrayList按照下标查询的时间复杂度O(1)【内存是连续的,根据寻址公式】, LinkedList不支持下标查询
-
查找(未知索引): ArrayList需要遍历,链表也需要链表,时间复杂度都是O(n)
-
新增和删除
-
ArrayList尾部插入和删除,时间复杂度是O(1);其他部分增删需要挪动数组,时间复杂度是O(n)
-
LinkedList头尾节点增删时间复杂度是O(1),其他都需要遍历链表,时间复杂度是O(n)
-
-
-
内存空间占用
-
ArrayList底层是数组,内存连续,节省内存
-
LinkedList 是双向链表需要存储数据,和两个指针,更占用内存
-
-
线程安全
-
ArrayList和LinkedList都不是线程安全的
-
如果需要保证线程安全,有两种方案:
-
在方法内使用,局部变量则是线程安全的
-
使用线程安全的ArrayList和LinkedList
-
-
5.二叉树
Java中有两个方式实现二叉树:数组存储,链式存储。
基于链式存储的树的节点可定义如下:
(1)二叉搜索树概述
二叉搜索树(Binary Search Tree,BST)又名二叉查找树,有序二叉树或者排序二叉树,是二叉树中比较常用的一种类型
二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值
(2)二叉搜索树-时间复杂度分析
实际上由于二叉查找树的形态各异,时间复杂度也不尽相同,我画了几棵树我们来看一下插入,查找,删除的时间复杂度
插入,查找,删除的时间复杂度O(logn)
极端情况下二叉搜索的时间复杂度
对于图中这种情况属于最坏的情况,二叉查找树已经退化成了链表,左右子树极度不平衡,此时查找的时间复杂度肯定是O(n)。
(2)红黑树
(1)概述
红黑树(Red Black Tree):也是一种自平衡的二叉搜索树(BST),之前叫做平衡二叉B树(Symmetric Binary B-Tree)
(2)红黑树的特质
性质1:节点要么是红色,要么是黑色
性质2:根节点是黑色
性质3:叶子节点都是黑色的空节点
性质4:红黑树中红色节点的子节点都是黑色
性质5:从任一节点到叶子节点的所有路径都包含相同数目的黑色节点
在添加或删除节点的时候,如果不符合这些性质会发生旋转,以达到所有的性质,保证红黑树的平衡
(3)红黑树的复杂度
-
查找:
- 红黑树也是一棵BST(二叉搜索树)树,查找操作的时间复杂度为:O(log n)
-
添加:
-
添加先要从根节点开始找到元素添加的位置,时间复杂度O(log n)
-
添加完成后涉及到复杂度为O(1)的旋转调整操作
-
故整体复杂度为:O(log n)
-
-
删除:
-
首先从根节点开始找到被删除元素的位置,时间复杂度O(log n)
-
删除完成后涉及到复杂度为O(1)的旋转调整操作
-
故整体复杂度为:O(log n)
-
从JDK1.8开始,哈希表底层进行优化,使用:数组+链表/红黑树
- 从链表 => 红黑树时机: 当链表的长度>8,数组长度>64,自动 把链表转换为红黑树(左小右大),扩容时,红黑树节点数量<6时会退化为链表
(3)散列表
散列表(Hash Table)又名哈希表/Hash表,是根据键(Key)直接访问在内存存储位置值(Value)的数据结构,它是由数组演化而来的,利用了数组支持按照下标进行随机访问数据的特性
散列函数和散列冲突
将键(key)映射为数组下标的函数叫做散列函数。可以表示为:hashValue = hash(key)
散列函数的基本要求:
-
散列函数计算得到的散列值必须是大于等于0的正整数,因为hashValue需要作为数组的下标。
-
如果key1==key2,那么经过hash后得到的哈希值也必相同即:hash(key1) == hash(key2)
-
如果key1 != key2,那么经过hash后得到的哈希值也必不相同即:hash(key1) != hash(key2)
实际的情况下想找一个散列函数能够做到对于不同的key计算得到的散列值都不同几乎是不可能的,即便像著名的MD5,SHA等哈希算法也无法避免这一情况,这就是散列冲突(或者哈希冲突,哈希碰撞,就是指多个key映射到同一个数组下标位置)
散列冲突解决方法-链表法(拉链)
在散列表中,数组的每个下标位置我们可以称之为桶(bucket)或者槽(slot),每个桶(槽)会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。
简单就是,如果有多个key最终的hash值是一样的,就会存入数组的同一个下标中,下标中挂一个链表存入多个数据,如果链表过长会转化为红黑树
时间复杂度
1,插入操作,通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,插入的时间复杂度是 O(1)
通过计算就可以找到元素
2,当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除
-
平均情况下基于链表法解决冲突时查询的时间复杂度是O(1)
-
散列表可能会退化为链表,查询的时间复杂度就从 O(1) 退化为 O(n)
-
将链表法中的链表改造为其他高效的动态数据结构,比如红黑树,查询的时间复杂度是 O(logn)
将链表法中的链表改造红黑树还有一个非常重要的原因,可以防止DDos攻击
DDos 攻击:
分布式拒绝服务攻击(英文意思是Distributed Denial of Service,简称DDoS)
指处于不同位置的多个攻击者同时向一个或数个目标发动攻击,或者一个攻击者控制了位于不同位置的多台机器并利用这些机器对受害者同时实施攻击。由于攻击的发出点是分布在不同地方的,这类攻击称为分布式拒绝服务攻击,其中的攻击者可以有多个
HashMap的put方法的具体流程
hashMap的常见属性
-
-
HashMap是懒惰加载,在创建对象时并没有初始化数组
-
在无参的构造函数中,设置了默认的加载因子是0.75
put添加数据的流程图
-
判断键值对数组table是否为空或为null,否则执行resize()进行扩容(初始化)
-
根据键值key计算hash值得到数组索引
-
判断table[i]==null,条件成立,直接新建节点添加
-
如果table[i]==null ,不成立
-
4.1 判断table[i]的首个元素是否和key一样,如果相同直接覆盖value
-
4.2 判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对
-
4.3 遍历table[i],链表的尾部插入数据,然后判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操 作,遍历过程中若发现key已经存在直接覆盖value
-
插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold(数组长度*0.75),如果超过,进行扩容。
HashMap的扩容机制
-
在添加元素或初始化的时候需要调用resize方法进行扩容,第一次添加数据初始化数组长度为16,以后每次每次扩容都是达到了扩容阈值(数组长度 * 0.75)
-
每次扩容的时候,都是扩容之前容量的2倍(>>1);
-
扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中
-
没有hash冲突的节点,则直接使用 e.hash & (newCap - 1) 计算新数组的索引位置
-
如果是红黑树,走红黑树的添加
-
如果是链表,则需要遍历链表,可能需要拆分链表,判断(e.hash & oldCap)是否为0,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上
-
hashMap的寻址算法
在putVal方法中,有一个hash(key)方法,这个方法就是来去计算key的hash值的,看下面的代码
首先获取key的hashCode值,然后右移16位 异或运算 原来的hashCode值,主要作用就是使原来的hash值更加均匀,减少hash冲突
有了hash值之后,就很方便的去计算当前key的在数组中存储的下标,看下面的代码:
(n-1)&hash : 得到数组中的索引,代替取模,性能更好,数组长度必须是2的n次幂
关于hash值的其他面试题:为何HashMap的数组长度一定是2的次幂?
-
计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
-
扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap
HashSet与HashMap的区别
(1)HashSet实现了Set接口, 仅存储对象; HashMap实现了 Map接口, 存储的是键值对.
(2)HashSet底层其实是用HashMap实现存储的, HashSet封装了一系列HashMap的方法. 依靠HashMap来存储元素值,(利用hashMap的key键进行存储), 而value值默认为Object对象. 所以HashSet也不允许出现重复值, 判断标准和HashMap判断标准相同, 两个元素的hashCode相等并且通过equals()方法返回true.
| 区别 | HashTable | HashMap |
| 数据结构 | 数组+链表 | 数组+链表+红黑树 |
| 是否可以为null | Key和value都不能为null | 可以为null |
| hash算法 | key的hashCode() | 二次hash |
| 扩容方式 | 当前容量翻倍 +1 | 当前容量翻倍 |
| 线程安全 | 同步(synchronized)的,线程安全 | 非线程安全 |
在实际开中不建议使用HashTable,在多线程环境下可以使用ConcurrentHashMap类
线程安全的集合类
Vector:
Vector是一个动态数组,通过在所有需要保证线程安全的方法上添加synchronized关键字来实现线程安全。它类似于ArrayList,但长度可变,是ArrayList的线程安全版本。
Hashtable:
Hashtable是一个同步的散列表,实现了Map接口。它通过在所有方法上添加synchronized关键字来确保线程安全,避免了线程冲突和数据不一致等问题,提高了系统的可靠性和安全性。
Stack:
Stack类继承自Vector,因此也是线程安全的。它具有先进后出的特点,插入或删除元素时不会破坏原来的数据结构,保证了程序的稳定性和可靠性。
ArrayBlockingQueue:
这是一个由数组支持的线程安全的有界阻塞队列,按FIFO原则对元素进行排序。它是一个典型的“有界缓存区”,固定大小的数组在其中保持生产者插入的元素和使用者提取的元素。
ConcurrentHashMap:
这是一种高效的线程安全的散列表,使用锁分段技术确保线性安全。它广泛应用于分布式系统,具有高并发、高吞吐量、低延迟等优点。
ConcurrentLinkedQueue:
这是一个非阻塞线程安全的队列,适用于“高并发”的场景。它是一个基于链接节点的无界线程安全队列,按照FIFO原则对元素进行排序。
CopyOnWriteArrayList:
这个类通过写时复制的方式保证线程安全。在多线程环境下,当多个线程修改同一个ArrayList时,CopyOnWriteArrayList会创建一个新的数组副本进行修改,从而避免了并发修改的问题。它适用于读多写少的场景。
这些线程安全的集合类在不同的场景下提供了安全的数据访问和操作,确保了程序的正确性和可靠性。
六.异常体系
异常发生的原因有很多,通常包含以下几大类:
-
用户输入了非法数据。
-
要打开的文件不存在。
-
网络通信时连接中断,或者JVM内存溢出。
- 检查性异常:最具代表的检查性异常是用户错误或问题引起的异常,这些异常在编译时强制要求程序员处理。例如要打开一个不存在文件时,一个异常就发生了,这些异常在编译时不能被简单地忽略。
这类异常通常使用 try-catch 块来捕获并处理异常,或者在方法声明中使用 throws 子句声明方法可能抛出的异常。
-
运行时异常: 这些异常在编译时不强制要求处理,通常是由程序中的错误引起的,例如 NullPointerException、ArrayIndexOutOfBoundsException 等,这类异常可以选择处理,但并非强制要求。
-
错误: 错误不是异常,而是脱离程序员控制的问题,错误在代码中通常被忽略。例如,当栈溢出时,一个错误就发生了,它们在编译也检查不到的。
Java 提供了以下关键字和类来支持异常处理:
-
try:用于包裹可能会抛出异常的代码块。
-
catch:用于捕获异常并处理异常的代码块。
-
finally:用于包含无论是否发生异常都需要执行的代码块。
-
throw:用于手动抛出异常。
-
throws:用于在方法声明中指定方法可能抛出的异常。
-
Exception类:是所有异常类的父类,它提供了一些方法来获取异常信息,如 getMessage()、printStackTrace() 等。
1.Exception 类的层次
所有的异常类是从 java.lang.Exception 类继承的子类。
Exception 类是 Throwable 类的子类。除了Exception类外,Throwable还有一个子类Error 。
Java 程序通常不捕获错误。错误一般发生在严重故障时,它们在Java程序处理的范畴之外。
Error 用来指示运行时环境发生的错误。
例如,JVM 内存溢出。一般地,程序不会从错误中恢复。
异常类有两个主要的子类:IOException 类和 RuntimeException 类。
七.线程的基础
Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。在 windows 中进程是不活动的,只是作为线程的容器
-
进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
-
不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
-
线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)
1.并行和并发有什么区别?
多核CPU
每个核(core)都可以调度运行线程,这时候线程可以是并行的。
并发(concurrent)是同一时间应对(dealing with)多件事情的能力
并行(parallel)是同一时间动手做(doing)多件事情的能力
举例:
-
家庭主妇做饭、打扫卫生、给孩子喂奶,她一个人轮流交替做这多件事,这时就是并发
-
家庭主妇雇了个保姆,她们一起这些事,这时既有并发,也有并行(这时会产生竞争,例如锅只有一口,一个人用锅时,另一个人就得等待)
-
雇了3个保姆,一个专做饭、一个专打扫卫生、一个专喂奶,互不干扰,这时是并行
2.创建线程的四种方式
-
继承Thread类
public class MyThread extends Thread { @Override public void run() { System.out.println("MyThread...run..."); } public static void main(String[] args) { // 创建MyThread对象 MyThread t1 = new MyThread() ; MyThread t2 = new MyThread() ; // 调用start方法启动线程 t1.start(); t2.start(); } } -
实现runnable接口
public class MyRunnable implements Runnable{
@Override
public void run() {
System.out.println("MyRunnable...run...");
}
public static void main(String[] args) {
// 创建MyRunnable对象
MyRunnable mr = new MyRunnable() ;
// 创建Thread对象
Thread t1 = new Thread(mr) ;
Thread t2 = new Thread(mr) ;
// 调用start方法启动线程
t1.start();
t2.start();
}
}
- 实现Callable接口
线程执行完需要拿到结果就可以Callable接口
public class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
System.out.println("MyCallable...call...");
return "OK";
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 创建MyCallable对象
MyCallable mc = new MyCallable() ;
// 创建Future Task对象,包装Callable对象
FutureTask<String> ft = new FutureTask<String>(mc) ;
// 创建Thread对象
Thread t1 = new Thread(ft) ;
Thread t2 = new Thread(ft) ;
// 调用start方法启动线程
t1.start();
// 调用ft的get方法获取执行结果
String result = ft.get();
// 输出
System.out.println(result);
}
}
- 线程池创建线程
2.1.Runnable和Callable的区别?
-
Runnable接口run方法没有返回值
-
Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以获取异步执行的结果
-
Callable接口的call()方法允许抛出异常,而Runable接口的run()方法异常只能内存消化,不能向上抛出
2.2.run()和start()有什么区别?
start():用来启动线程,通过该线程来调用run方法中所定义的逻辑代码,start方法只能被执行一次
run():封装了要被线程执行的逻辑代码,可以被调用多次
3.线程的生命周期
线程是一个动态执行的过程,它也有一个从产生到死亡的过程。
-
新建(NEW)
-
当一个线程对象被创建,但还未调用 start 方法时处于新建状态
-
此时未与操作系统底层线程关联
-
-
可运行(RUNNABLE)
-
调用了 start 方法,就会由新建进入可运行
-
此时与底层线程关联,由操作系统调度执行
-
-
阻塞(BLOCKED)
-
当获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,此时不占用 cpu 时间
-
当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态
-
-
等待(WAITING)
-
当获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态释放锁进入 Monitor 等待集合等待,同样不占用 cpu 时间
-
当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的等待线程,恢复为可运行状态
-
-
有时限等待(TIMED_WAITING)
-
当获取锁成功后,但由于条件不满足,调用了 wait(long) 方法,此时从可运行状态释放锁进入 Monitor 等待集合进行有时限等待,同样不占用 cpu 时间
-
当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的有时限等待线程,恢复为可运行状态,并重新去竞争锁
-
如果等待超时,也会从有时限等待状态恢复为可运行状态,并重新去竞争锁
-
还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,但与 Monitor 无关,不需要主动唤醒,超时时间到自然恢复为可运行状态
-
-
终结(TERMINATED)
-
线程内代码已经执行完毕,由可运行进入终结
-
此时会取消与底层线程关联
-
4.操作线程
4.1.如何让线程顺序执行
可以用线程类的join()方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行:t1.join();
4.2.notify()和 notifyAll()有什么区别?
notifyAll:唤醒所有wait的线程
notify:只随机唤醒一个wait线程
4.3.wait 和 sleep 方法的不同?
- 共同点:
wait(),wait(long)和sleep(long)的效果都会让当前线程暂时放弃 CPU 的使用权,进入阻塞状态。
-
不同点:
-
方法归属不同
-
sleep(long) 是 Thread 的静态方法
-
而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有
-
-
醒来时机不同
-
执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
-
wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
-
它们都可以被打断唤醒
-
-
锁特性不同(重点)
-
wait 方法的调用必须先获取 wait 对象的锁(和synchronized配合使用),而 sleep 则无此限制
-
wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
-
而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)
-
-
4.4.如何暂停运行中的线程?
有三种方式可以暂停线程
-
使用退出标志,使线程正常退出,也就是run()执行完后终止
-
使用过stop()方法强行终止(已作废)
-
使用过interrupt()方法中断线程
-
打断阻塞的线程(sleep、wait、join)线程,线程会抛出InterruptedException异常
-
打断正常的线程,可以根据打断状态来标记是否退出线程
-
八.线程中并发锁
1.synchronized基础
synchronized[对象锁]采用互斥的方式让同一时刻至多只有一个线程持有[对象锁]
底层通过Monitor实现,Monitor是JVM级别的对象(C++实现),线程获得锁需要使用对象锁关联Monitor
Monitor内部有三个属性:
-
Owner
- 获取关联锁的线程,并且只能关联一个线程
-
entryList
- 关联处于阻塞(Blocked)状态的线程
-
waitSet
- 关联处于waiting状态的线程
具体流程:
-
代码进入synchronized代码块,先让lock(对象锁)关联的Monitor,然后判断Owner是否有线程持有
-
如果没有,则让当前线程持有,表示该线程获取锁成功
-
如果有,则让当前线程进入entryList进行阻塞,如果Owner释放了锁,则让EntryList中的线程去竞争锁的持有权
-
如果代码块调用了wait()方法,则会进入waitSet中进行等待
2.synchronized进阶
每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针,每个对象的对象头都可以设置monitor的指针,让对象与monitor产生关联。
2.1 轻量级锁
加锁的流程:
-
在线程栈中创建一个Lock Record,将其obj字段指向锁对象。
-
通过CAS指令将Lock Record的地址存储在对象头的markword中(数据进行交换),如果对象处于无锁状态则表示修改成功,代表该线程获得了轻量级锁
-
如果当前线程已经持有该锁了,代表这是一次锁重入,设置Lock Record第一部分为null,起到了一个重入计数器的作用
-
如果CAS修改失败,则说明发生了竞争,需要升级为重量级锁
解锁过程:
-
遍历线程栈,找到所有obj字段等于当前锁对象的Lock Record
-
如果Lock Record的Mark Word为null,代表一次重入,将obj设置为null后continue
-
如果Lock Record的 Mark Word不为null,则利用CAS指令将对象头的mark word恢复成为无锁状态。如果失败则膨胀为重量级锁。
2.2 偏向锁
轻量级锁在没有竞争时,每次重入都要进行CAS操作
java6引入了偏向锁来做进一步的优化:只有在第一次使用CAS的时候将线程ID设置到Mark Word对象头中,之后发现这个线程ID时自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有
加锁的流程:
-
在线程栈中创建一个Lock Record,将其obj字段指向锁对象
-
通过CAS指令将Lock Record的线程id存储在对象头中的Mark Word中,同时设置偏向锁的标识为101,如果对象处于无锁状态则修改成功,代表该线程获得了偏向锁
-
如果当前线程已经持有该锁了,代表这是一次重入,设置Lock Record第一部分为null,起到一个计数器的作用,与轻量级锁不同的是,这里不会再进行CAS操作,只是判断对象头中的线程id是否是自己,因为缺少了CAS操作,性能比轻量级锁好一些
锁升级
Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。
| 锁等级 | 描述 |
| 重量级锁 | 底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。 |
| 轻量级锁 | 线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性 |
| 偏向锁 | 一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令 |
JMM(JAVA内存模型)
JMM(Java Memory Model)Java内存模型,是java虚拟机规范中所定义的一种内存模型,java内存模型是解决java中多个线程共享数据的问题
Java内存模型(Java Memory Model)描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节。
特点:
-
所有的共享变量都存储于主内存(计算机的RAM)这里所说的变量指的是实例变量和类变量。不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
-
每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
-
线程对变量的所有的操作(读,写)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存完成。
3.CAS
CAS(Compare And Swap 比较再交换),体现了一种乐观锁的概念,在无锁的情况下保证线程共享数据的原子性。
举例:线程1与线程2都从主内存中获取变量int a = 100,同时放到各个线程的工作内存中,
一个当前内存值V、旧的预期值A、即将更新的值B,当且仅当旧的预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。如果CAS操作失败,通过自旋的方式等待并再次尝试,直到成功
线程A的A值与主内存进行比较,同为100相等,则把主内存修改为B的值101
此时线程B的A值与主内存比较,100≠101,所以修改失败,开始自旋尝试修改直到成功。
CAS底层
CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令,都是native修饰的方法,由系统提供的接口执行,并非java代码实现,一般的思路也都是自旋锁实现
在java中比较常见使用有很多,比如ReentrantLock和Atomic开头的线程安全类,都调用了Unsafe中的方法
-
ReentrantLock中的一段CAS代码
4.volatile
一旦一个共享变量(类的成员变量‘类的静态成员变量)被volatile修饰之后,就具备了两层语义:
- 保证线程间的可见性
保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存
JIT即时编译器会对热点代码进行一个优化,使用volatile可以禁止JIT优化
- 禁止进行指令重排序
用volatile修饰的共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果
重排序:即线程先执行了下面的y=1,就被读取了,得到1,0
y变量上加入volatile:
-
写操作加的屏障是阻止上方其它写操作越过屏障排到volatile变量写之下
-
读操作加的屏障是阻止下方其它读操作越过屏障排到volatile变量读之上
volatile使用的小妙招:
-
写变量时让volatile修饰的变量的在代码最后位置
-
读变量时让volatile修饰的变量的在代码最开始位置
5.AQS
AQS(AbstractQueuedSynchronizer),是阻塞式锁和相关同步工具的框架
AQS与Synchronized的区别
td {white-space:nowrap;border:0.5pt solid #dee0e3;font-size:10pt;font-style:normal;font-weight:normal;vertical-align:middle;word-break:normal;word-wrap:normal;}| synchronized | AQS |
| 关键字,c++ 语言实现 | java 语言实现 |
| 悲观锁,自动释放锁 | 悲观锁,手动开启和关闭 |
| 锁竞争激烈都是重量级锁,性能差 | 锁竞争激烈的情况下,提供了多种解决方案 |
AQS常见的实现类
-
ReentrantLock 阻塞式锁
-
Semaphore 信号量
-
CountDownLatch 倒计时锁
工作机制
-
在AQS中维护了一个使用了volatile修饰的state属性来表示资源的状态,0表示无锁,1表示有锁
-
提供了基于 FIFO(先进先出) 的双向等待队列,类似于 Monitor 的 EntryList
-
条件变量来实现等待、唤醒机制,支持多个条件变量,类似于 Monitor 的 WaitSet
线程0来了以后,去尝试修改state属性,如果发现state属性是0,就修改state状态为1,表示线程0抢锁成功
线程1和线程2也会先尝试修改state属性,发现state的值已经是1了,有其他线程持有锁,它们都会到FIFO队列中进行等待,
FIFO是一个双向队列,head属性表示头结点,tail表示尾结点
多个线程共同去抢这个资源是如何保证原子性的呢?
在去修改state状态的时候,使用的CAS自旋锁来保证原子性,确保只能有一个线程修改成功,修改失败的线程将会进入FIFO队列中等待
AQS是公平锁吗,还是非公平锁?
比较典型的AQS实现类ReentrantLock,它默认就是非公平锁,新的线程与队列中的线程共同来抢资源
6.ReentrantLock
ReentrantLock是可重入锁,与synchronized相比具有以下特点
-
可中断
-
可设置超时时间
-
可设置公平锁
-
支持多个条件变量
-
与synchronized一样,都支持重入
实现原理
ReentrantLock底层主要利用CAS+AQS队列来实现,它支持公平锁和非公平锁,两者的实现基于两种构造方法,以及一个公平参数,当为true表示公平锁,为false表示非公平锁,公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。
工作流程
-
线程来抢锁后使用CAS的方式修改state状态属性,修改状态成功为1,让exclusiveOwnerThread指向当前线程,获取锁成功
-
如果状态已经为1了,则修改状态失败,线程会进入双向等待队列中等待,head指向队列头,tail指向队列尾
-
当exclusiveOwnerThread的线程释放锁,为null时,则会唤醒双向队列中等待的队列
-
公平锁体现在会按照队列的先后顺序获得锁,非公平体现在不排队的线程也可以竞争锁。
7.synchronized和Lock有什么区别?
-
语法层面
-
synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现
-
Lock 是接口,源码由 jdk 提供,用 java 语言实现
-
使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁
-
-
功能层面
-
二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
-
Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量
-
Lock 有适合不同场景的实现,如 ReentrantLock, ReentrantReadWriteLock
-
-
性能层面
-
在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
-
在竞争激烈时,Lock 的实现通常会提供更好的性能
-
8.死锁
一个线程同时持有多把锁,这时就可能发生死锁
死锁的诊断
jps查看进程信息,使用jstack查看线程问题
9.ConcurrentHashMap
ConcurrentHashMap 是一种线程安全的高效Map集合
底层数据结构:
-
JDK1.7底层采用分段的数组+链表实现
-
提供了一个segment数组,在初始化ConcurrentHashMap 的时候可以指定数组的长度,默认是16,一旦初始化之后中间不可扩容
-
在每个segment中都可以挂一个HashEntry数组,数组里面可以存储具体的元素,HashEntry数组是可以扩容的
-
在HashEntry存储的数组中存储的元素,如果发生冲突,则可以挂单向链表
存储流程:
-
先去计算key的hash值,然后确定segment数组下标
-
再通过hash值确定hashEntry数组中的下标存储数据
-
在进行操作数据的之前,会先判断当前segment对应下标位置是否有线程进行操作,为了线程安全使用的是ReentrantLock进行加锁,如果获取锁是被会使用cas自旋锁进行尝试
-
-
JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树,采用 CAS + Synchronized来保证并发安全进行实现
-
CAS控制数组节点的添加
-
synchronized只锁定当前链表或红黑二叉树的首节点,只要hash不冲突,就不会产生并发的问题 , 效率得到提升
-
遍历ConcurrentHashMap的四种方式
-
在for-each中使用entries来遍历
-
在for-each中遍历keys或者values
-
使用lterator遍历
-
通过键查找值遍历
10.导致并发线程出现问题的根本原因?
java并发编程的三大特性:
-
- 一个线程在CPU中操作不可暂停,也不可中断,要不执行完成,要不不执行
-
- 内存可见性:让一个线程对共享变量的修改对另一个线程可见
-
有序性
- 指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的
九.线程池
1. 说一下线程池的核心参数(线程池的执行原理知道嘛)
线程池核心参数主要参考ThreadPoolExecutor这个类的7个参数的构造函数
-
corePoolSize 核心线程数目
-
maximumPoolSize 最大线程数目 = (核心线程+救急线程的最大数目)
-
keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
-
unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
-
workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
-
threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
-
handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略
工作流程:
1,任务在提交的时候,首先判断核心线程数是否已满,如果没有满则直接添加到工作线程执行
2,如果核心线程数满了,则判断阻塞队列是否已满,如果没有满,当前任务存入阻塞队列
3,如果阻塞队列也满了,则判断线程数是否小于最大线程数,如果满足条件,则使用临时线程执行任务
如果核心或临时线程执行完成任务后会检查阻塞队列中是否有需要执行的线程,如果有,则使用非核心线程执行任务
4,如果所有线程都在忙着(核心线程+临时线程),则走拒绝策略
拒绝策略:
1.AbortPolicy:直接抛出异常,默认策略;
2.CallerRunsPolicy:用调用者所在的线程来执行任务;
3.DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
4.DiscardPolicy:直接丢弃任务;
2.常见的阻塞队列
workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
比较常见的有4个,用的最多是ArrayBlockingQueue和LinkedBlockingQueue
1.ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO。
2.LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO。
3.DelayedWorkQueue :是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的
4.SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。
ArrayBlockingQueue的LinkedBlockingQueue区别
td {white-space:nowrap;border:0.5pt solid #dee0e3;font-size:10pt;font-style:normal;font-weight:normal;vertical-align:middle;word-break:normal;word-wrap:normal;}| LinkedBlockingQueue | ArrayBlockingQueue |
| 默认无界,支持有界 | 强制有界 |
| 底层是链表 | 底层是数组 |
| 是懒惰的,创建节点的时候添加数据 | 提前初始化 Node 数组 |
| 入队会生成新 Node | Node需要是提前创建好的 |
| 两把锁(头尾) | 一把锁 |
左边是LinkedBlockingQueue加锁的方式,右边是ArrayBlockingQueue加锁的方式
-
LinkedBlockingQueue读和写各有一把锁,性能相对较好
-
ArrayBlockingQueue只有一把锁,读和写共用,性能相对于LinkedBlockingQueue差一些
3.如何确定核心线程数
在设置核心线程数之前,需要先熟悉一些执行线程池执行任务的类型
- IO密集型任务
一般来说:文件读写、DB读写、网络请求等
推荐:核心线程数大小设置为2N+1 (N为计算机的CPU核数)
- CPU密集型任务
一般来说:计算型代码、Bitmap转换、Gson转换等
推荐:核心线程数大小设置为N+1 (N为计算机的CPU核数)
java代码查看CPU核数:
//查看机器cpu的核数
System.out.println(Runtime.getRuntime().availableProcessors());
4.线程池的种类
常见的有四种线程池
-
创建使用固定线程数的线程池 FixedThreadPoolCase
-
核心线程数与最大线程数一样,没有救急线程
-
阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE
-
适用场景:适用于任务量已知,相对耗时的任务
-
-
单线程化的线程池(NewSingleThreadCase),它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO)执行
-
核心线程数和最大线程数都是1
-
阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE
-
适用场景:适用于按照顺序执行的任务
-
-
可缓存线程池CachedThreadPoolCase
-
核心线程数为0
-
最大线程数是Integer.MAX_VALUE
-
阻塞队列为SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。
-
适用场景:适合任务数比较密集,但每个任务执行时间较短的情况
-
-
提供了“延迟”和“周期执行”功能的ThreadPoolExecutor。
- 适用场景:有定时和延迟执行的任务
如何控制某个方法允许并发访问线程的数量
Semaphore [ˈsɛməˌfɔr] 信号量,是JUC包下的一个工具类,我们可以通过其限制执行的线程数量,达到限流的效果
当一个线程执行时先通过其方法进行获取许可操作,获取到许可的线程继续执行业务逻辑,当线程执行完成后进行释放许可操作,未获取达到许可的线程进行等待或者直接结束。
Semaphore两个重要的方法
-
lsemaphore.acquire(): 请求一个信号量,这时候的信号量个数-1(一旦没有可使用的信号量,也即信号量个数变为负数时,再次请求的时候就会阻塞,直到其他线程释放了信号量)
-
lsemaphore.release():释放一个信号量,此时信号量个数+1
5.ThreadLocal的理解
ThreadLocal是多线程中对于解决线程安全的一个操作类,它会为每个线程都分配一个独立的线程副本从而解决了变量并发访问冲突的问题。ThreadLocal 同时实现了线程内的资源共享
案例:使用JDBC操作数据库时,会将每一个线程的Connection放入各自的ThreadLocal中,从而保证每个线程都在各自的 Connection 上进行数据库的操作,避免A线程关闭了B线程的连接。
ThreadLocal的基本使用
主要三个方法:
-
set(value)设置值
-
get()获取值
-
remove()清除值
public class ThreadLocalTest {
static ThreadLocal<String> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
new Thread(() -> {
String name = Thread.currentThread().getName();
//设置值
threadLocal.set("itcast");
print(name);
//获取值
System.out.println(name +
"-after remove : " + threadLocal.get());
}, "t1").start();
static void print(String str) {
//打印当前线程中本地内存中本地变量的值
System.out.println(str + " :" + threadLocal.get());
//清除本地内存中的本地变量
threadLocal.remove();
}
}
底层原理:
ThreadLocal本质来说就是一个线程内部存储类,从而让多个线程只操作自己内部的值,从而实现线程数据隔离
在ThreadLocal中有一个内部类叫做ThreadLocalMap,类似于HashMap
ThreadLocalMap中有一个属性table数组,这个是真正存储数据的位置
set方法
get方法/remove方法
直接手动remove,避免内存泄露问题
十.反射
JAVA反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为java语言的反射机制。
反射就是截取class二进制文件包装成class对象,反射就是在运行时才知道要操作的类是什么,并且可以在运行时获取类的完整构造,并调用对应的方法
反射获取对象
- 获取类的 Class 对象实例
Class clz = Class.forName("com.zhenai.api.Apple");
- 根据 Class 对象实例获取 Constructor 对象
Constructor appleConstructor = clz.getConstructor();
- 使用 Constructor 对象的 newInstance 方法获取反射类对象
Object appleObj = appleConstructor.newInstance();
而如果要调用某一个方法,则需要经过下面的步骤:
- 获取方法的Method对象
Method setPriceMethod = clz.getMethod("setPrice", int.class);
- 利用 invoke 方法调用方法
setPriceMethod.invoke(appleObj, 14);
获取反射中的Class对象
第一种,使用 Class.forName 静态方法。当你知道该类的全路径名时,你可以使用该方法获取 Class 类对象。
Class clz = Class.forName("java.lang.String");
第二种,使用 .class 方法。
这种方法只适合在编译前就知道操作的 Class。
Class clz = String.class;
第三种,使用类对象的 getClass() 方法。
String str = new String("Hello");
Class clz = str.getClass();
通过反射创建类对象
通过反射创建类对象主要有两种方式:通过 Class 对象的 newInstance() 方法、通过 Constructor 对象的 newInstance() 方法。
第一种:通过 Class 对象的 newInstance() 方法。
Class clz = Apple.class;
Apple apple = (Apple)clz.newInstance();
第二种:通过 Constructor 对象的 newInstance() 方法
Class clz = Apple.class;
Constructor constructor = clz.getConstructor();
Apple apple = (Apple)constructor.newInstance();
通过 Constructor 对象创建类对象可以选择特定构造方法,而通过 Class 对象则只能使用默认的无参数构造方法。
浙公网安备 33010602011771号