针对java基础整理了一些面试题,希望对大家有所帮助。
面向对象(封装,继承,多态); 可移植性(留空,后边补充); 健壮性, 安全性; 支持网络编程; 编译和解释并存;
Java的语法更“纯净”,没有了头文件,指针运算,结构,联合,操作符重载,虚基类等,类库相对更小; 都支持面向对象程序设计,C++ 支持多继承,而java不支持多继承,但允许一个类实现多个接口; Java 内存可自动回收,而 C++ 中必须由程序释放内存; Java可以更方便的为运行中的程序增加代码,更加具有动态性; 不同之处还有很多,这里就不一一列举了。
基本数据类型: 引用类型:建立在基本数据类型的基础上,包括数组、类和接口。
Java5 以前 switch(expr)中,expr 只能是 byte、short、char、int。 从 Java 5 开始,Java 中引入了枚举类型, expr 也可以是 enum 类型。 从 Java 7 开始,expr还可以是字符串(String),但是长整型(long)在目前所有的版本中都是不可以的。
Java 支持 4 种不同的访问权限。 default (即默认,什么也不写): 在同一包内可见,不使用任何修饰符。使用对象:类、接口、变量、方法。 private : 在同一类内可见。使用对象:变量、方法。注意:不能修饰类(外部类) public : 对所有类可见。使用对象:类、接口、变量、方法 protected : 对同一包内的类和所有子类可见。使用对象:变量、方法。注意:不能修饰类(外部类)。
break 跳出上一层循环,不再执行循环(结束当前的循环体) continue 跳出本次循环,继续执行下次循环(结束正在执行的循环 进入下一个循环条件) return 程序返回,不再执行下面的代码(结束当前的方法 直接返回);在foreach方法中同continue的作用相同;
final 用于修饰变量、方法和类。 final 变量:被修饰的变量即为常量 final 方法:被修饰的方法不允许任何子类重写,但可以使用该方法。 final 类:被修饰的类不能被继承,所有方法不能被重写。
finally 作为异常处理的一部分,它只能在 语句中,并且附带一个语句块表示这段语句最终 一定被执行(无论是否抛出异常),经常被用在需要释放资源的情况下, System.exit (0) 可以阻断finally 执行。
finalize 是Object的方法;对象被回收的时候被调用,一个对象的 finalize 方法只会被调用一次,finalize 被调用不一定会立即回收该对象,所以有可能调用finalize 后,该对象又不需要被回收了,然后到了真正要被回收的时候,因为前面调用过一次,所以不会再次调用 finalize 了,进而产生问题,因此不推荐使用 finalize 方法。
通常来说,用new创建类的对象时,数据存储空间才被分配,方法才供外界调用。但有时我们只想为特 定域分配单一存储空间,不考虑要创建多少对象或者说根本就不创建任何对象,再就是我们想在没有创 建对象的情况下也想调用方法。在这两种情况下,static关键字,满足了我们的需求
static关键字表明一个成员属于所有类对象的共有,相比于实例的成员,可以通过类名直接调用,当然也可以通过实例变量去调用; Java中static方法不能被覆盖,因为方法覆盖是基于运行时动态绑定的,而static方法是编译时静态绑定的。子类虽然可以重新定义父类的静态方法,但这是隐藏父类方法的概念。在多态调用的情况下会总是调用父类的方法,两者在概念上不是一回事。 private方法不能被继承,显然也是不能重写的。
static变量在Java中是属于类的,它在所有的实例中的值是一样的。当类被Java虚拟机载入的时候,会对static变量进行初始化。如果你的代码尝试不用实例来访问非static的变量,编译器会报错,因为这些变量还没有被创建出来,还没有跟任何实例关联上。
静态方法的初始化时机要早于非静态资源,所以是不能引用
面向过程: 优点:性能比面向对象高,因为类调用时需要实例化,开销比较大,比较消耗资源;比如单片机、嵌入式开发、Linux/Unix等一般采用面向过程开发,性能是最重要的因素。 缺点:没有面向对象易维护、易复用、易扩展。
面向对象: 优点:易维护、易复用、易扩展,由于面向对象有封装、继承、多态性的特性,可以设计出低耦合的系统,使系统更加灵活、更加易于维护。 缺点:性能比面向过程低。
基本上代码块分为三种:Static静态代码块、构造代码块、普通代码块 代码块执行顺序: 静态代码块——> 构造代码块 ——> 构造函数——> 普通代码块 继承中代码块执行顺序:父类静态块——>子类静态块——>父类代码块——>父类构造器——>子类代码块——>子类构造器 想要深入了解,可以参考这篇文章 :juejin.cn/post/…
封装,就是把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏; 继承。继承是指这样一种能力:它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。通过继承创建的新类称为“子类”或“派生类”,被继承的类称为“基类”、“父类”或“超类”; 多态性。它是指在父类中定义的属性和方法被子类继承之后,可以具有不同的数据类型或表现出不同的行为,这使得同一个属性或方法在父类及其各个子类中具有不同的含义。
本质上多态分两种: 1、编译时多态(又称静态多态) 2、运行时多态(又称动态多态) 重载(overload)就是编译时多态的一个例子,编译时多态在编译时就已经确定,运行的时候调用的是确定的方法。我们通常所说的多态指的都是运行时多态,也就是编译时不确定究竟调用哪个具体方法,一直延迟到运行时才能确定。这也是为什么有时候多态方法又被称为延迟方法的原因。 Java实现多态有 3 个必要条件:继承、重写和向上转型。只有满足这 3 个条件,开发人员才能够在同一个继承结构中使用统一的逻辑实现代码处理不同的对象,从而执行不同的行为。
继承:在多态中必须存在有继承关系的子类和父类。 重写:子类对父类中某些方法进行重新定义,在调用这些方法时就会调用子类的方法。 向上转型:在多态中需要将子类的引用赋给父类对象,只有这样该引用才既能可以调用父类的方法,又能调用子类的方法。
1.方法的重载和重写都是实现多态的方式,区别在于前者实现的是编译时的多态性,而后者实现的是运行时的多态性。
2.重写发生在子类与父类之间, 重写方法返回值和形参都不能改变;
3.重载(overloading) 是在一个类里面,方法名字相同,而参数不同。与方法返回值和访问修饰符无关。每个重载的方法(或者构造函数)都必须有一个独一无二的参数类型列表。最常用的地方就是构造器的重载。
构造器不能被继承,因此不能被重写,但可以被重载。每一个类必须有自己的构造函数,负责构造自己这部分的构造。子类不会覆盖父类的构造函数,相反必须一开始调用父类的构造函数。
语法层面上的区别: 抽象类可以提供成员方法的实现细节,而接口中只能存在抽象方法; 抽象类中的成员变量可以是各种类型的,而接口中的成员变量只能是静态常量; 接口中不能含有静态代码块以及静态方法,而抽象类可以有静态代码块和静态方法; 一个类只能继承一个抽象类,而一个类却可以实现多个接口。
设计层面上的区别: 抽象类是对一种事物的抽象,即对类抽象,而接口是对行为的抽象。抽象类是对整个类整体进行抽象,包括属性、行为,但是接口却是对类局部(行为)进行抽象。设计层面不同,抽象类作为很多子类的父类,它是一种模板式设计。而接口是一种行为规范,它是一种辐射式设计。 想要深入了解,可以参考这篇文章 :www.cnblogs.com/dolphin0520…
由final的特性可知,被修饰的类不能被继承,而抽象类设计初衷就是被继承。
java中提供了以下四种创建对象的方式:
new创建新对象 通过反射机制 采用clone机制 通过序列化机制
前两者都需要显式地调用构造方法。对于clone机制,需要注意浅拷贝和深拷贝的区别,对于序列化机制需要明确其实现原理,在java中序列化可以通过实现Externalizable或者Serializable来实现。
不可变对象指对象一旦被创建,状态就不能再改变,任何修改都会创建一个新的对象,如 String、Integer及其它包装类.不可变对象最大的好处是线程安全
当然可以,比如 final Person[] persons = new Persion[]{} . persons 是不可变对象的引用,但其数组中的Person实例却是可变的.这种情况下需要特别谨慎,不要共享可变对象的引用.这种情况下,如果数据需要变化时,就返回原对象的一个拷贝.
值传递:指的是在方法调用时,传递的参数是按值的拷贝传递,传递的是值的拷贝. 引用传递:指的是在方法调用时,传递的参数是按引用进行传递,其实传递的是引用的地址,也就是变量所对应的内存空间的地址。
基本类型作为参数被传递时肯定是值传递;引用类型作为参数被传递时也是值传递,只不过“值”为对应的引用。想要深入了解,可以参考这篇文章 :www.itwanger.com/java/2019/1…
== 用于比较变量的值,当然这个值可以是引用+的值,即两个变量指向同一块内存地址; equals方法主要用于两个对象之间,检测一个对象是否等于另一个对象
java语言规范要求equals方法具有以下特性: 自反性。对于任意不为null的引用值x,x.equals(x)一定是true。 对称性。对于任意不为null的引用值x和y,当且仅当x.equals(y)是true时,y.equals(x)也是 true。 传递性。对于任意不为null的引用值x、y和z,如果x.equals(y)是true,同时y.equals(z)是true,那么x.equals(z)一定是true。 一致性。对于任意不为null的引用值x和y,如果用于equals比较的对象信息没有被修改的话,多次调用时x.equals(y)要么一致地返回true要么一致地返回false。 对于任意不为null的引用值x,x.equals(null)返回false。
hashCode() 是Object的方法,作用是获取哈希码,也称为散列码。它实际上是返回一个int整数。这个哈希码的作用是确定该对象在哈希表中的索引位置。比如在集合中利用哈希值尽可能把元素均匀映射到集合的各个位置,也易于寻找集合中相同的元素。
Java 对于 eqauls() 方法和 hashCode() 方法是这样规定的: 如果两个对象 equals,Java 运行时环境会认为他们的 hashCode 一定相等。 如果两个对象不 equals,他们的 hashCode 有可能相等。 如果两个对象 hashCode 相等,他们不一定 equals。 如果两个对象 hashCode 不相等,他们一定不 equals 补充:关于 equals() 和 hashCode() 的重要规范 规范1:若重写 equals() 方法,有必要重写 hashcode()方法,确保通过 equals()方法判断结果为true 的两个对象具备相等的 hashcode() 方法返回值。说得简单点就是:“如果两个对象相同,那么他们的 hashCode 应该相等”。不过请注意:这个只是规范,如果非要写一个类让 equals() 方法返回 true 而 hashCode() 方法返回两个不相等的值,编译和运行都是不会报错的。不过这样违反了Java 规范,程序也就埋下了 BUG。
规范2:如果 equals() 方法返回 false,即两个对象“不相同”,并不要求对这两个对象调用 hashCode() 方法得到两个不相同的数。说的简单点就是:“如果两个对象不相同,他们的 hashCode 可能相同”。
基于java规范,相同的对象hashcode值应该相同,不然就有埋下bug的风险。举个例子,在Java中的一些容器中,不允许有两个完全相同的对象,插入的时候,如果判断相同则会进行覆盖。这时候如果只重写了equals()的方法,而不重写hashcode的方法,Object中hashcode是根据对象的存储地址转换而形成的一个哈希值。这时候就有可能因为没有重写hashcode方法,造成相同的对象散列到不同的位置而造成对象的不能覆盖的问题。
1.String类中使用字符数组保存字符串,因为有“final”修饰符,所以string对象是不可变的。对于已经存在的String对象的修改都是重新创建一个新的对象,然后把新的值保存进去. StringBuilder与StringBuffer都继承自AbstractStringBuilder类,在AbstractStringBuilder中也是使用字符数组保存字符串,这两种对象都是可变的。
2.是否线程安全。String中的对象是不可变的,也就可以理解为常量,显然线程安全。 StringBuilder是非线程安全的。StringBuffer对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。
3.如果你只是在单线程中使用字符串缓冲区,那么StringBuilder的效率会更高些。值得注意的是StringBuilder是在JDK1.5版本中增加的。以前版本的JDK不能使用该类。
1.实现字符串池(String pool)的需要 Java提出了String pool的概念,在堆中开辟一块存储空间String pool,当初始化一个String变量时,如果该字符串已经存在了,就不会去创建一个新的字符串变量,而是会返回已经存在了的字符串的引用。如果字符串是可变的,某一个字符串变量改变了其值,那么其他指向的变量的值也会改变,String pool将不能够实现!
2.使多线程安全 在并发场景下,多个线程同时读一个资源,是安全的,不会引发竞争,但对资源进行写操作时是不安全的,不可变对象不能被写,所以保证了多线程的安全。
3.避免安全问题 在网络连接和数据库连接中字符串常常作为参数,例如,网络连接地址URL,文件路径path,反射机制所需要的String参数。其不可变性可以保证连接的安全性。
4、加快字符串处理速度 由于String是不可变的,保证了hashcode的唯一性,于是在创建对象时其hashcode就可以放心的缓存了,不需要重新计算。这也就是Map喜欢将String作为Key的原因,处理速度要快过其它的键对象。所以HashMap中的键往往都使用String。
- 形式上: 字符常量是单引号引起的一个字符,字符串常量是双引号引起的若干个字符;
- 含义上: 字符常量相当于一个整型值( ASCII 值),可以参加表达式运算;字符串常量代表一个地址值(该字符串在内存中存放位置,相当于对象;
- 占内存大小:字符常量只占2个字节;字符串常量占若干个字节(至少一个字符结束标志) (注意: char在Java中占两个字节)。
java中常量池的概念主要有三个: 全局字符串常量池 , class文件常量池 , 运行时常量池 。我们现在说的常量池就是全局字符串常量池jvm为了提升性能和减少内存开销,避免字符的重复创建,其维护了一块特殊的内存空间,即字符串池,当需要使用字符串时,先去字符串池中查看该字符串是否已经存在,如果存在,则可以直接使用,如果不存在,初始化,并将该字符串放入字符串常量池中。字符串常量池的位置也是随着jdk版本的不同而位置不同。在jdk6中,常量池的位置在永久代(方法区)中,此时常量池中存储的是对象。在jdk7中,常量池的位置在堆中,此时,常量池存储的就是引用了。在jdk8中,永久代(方法区)被元空间取代了
不是。Java 中的基本数据类型只有 8 个 :byte、short、int、long、float、double、char、boolean;除了基本类型(primitive type),剩下的都是引用类型(referencetype),Java 5 以后引入的枚举类型也算是一种比较特殊的引用类型。
Java 为每一个基本数据类型都引入了对应的包装类型(wrapper class)从 Java 5 开始引入了自动装箱/拆箱机制,把基本类型转换成包装类型的过程叫做装箱(boxing);反之,把包装类型转换成基本类型的过程叫做拆箱(unboxing),使得二者可以相互转换。
Java 为每个原始类型提供了包装类型: 原始类型: boolean,char,byte,short,int,long,float,double 包装类型:Boolean,Character,Byte,Short,Integer,Long,Float,Double
基本类型和包装类型的区别主要有以下 几点: 1.包装类型可以为 null,而基本类型不可以。它使得包装类型可以应用于 POJO 中,而基本类型则不行。那为什么 POJO 的属性必须要用包装类型呢?《阿里巴巴 Java 开发手册》上有详细的说明, 数据库的查询结果可能是 null,如果使用基本类型的话,因为要自动拆箱(将包装类型转为基本类型,比如说把 Integer 对象转换成 int 值),就会抛出 NullPointerException 的异常。
2.包装类型可用于泛型,而基本类型不可以。泛型不能使用基本类型,因为使用基本类型时会编译出错。 3.基本类型比包装类型更高效。基本类型在栈中直接存储的具体数值,而包装类型则存储的是堆中的引用。 很显然,相比较于基本类型而言,包装类型需要占用更多的内存空间。
使用 String a = “aaa” ; ,程序运行时会在常量池中查找”aaa”字符串,若没有,会将”aaa”字符串放进常量池,再将其地址赋给a;若有,将找到的”aaa”字符串的地址赋给a。 用javap命令查看编译器生成的字节码如下图:
程序先在常量池中使用ldc创建了“aaa”,astore_1进行了赋值操作,invokevirtual 执行了打印的操作
使用String b = new String("aaa");`,程序会在堆内存中开辟一片新空间存放新对象,同时会 将”aaa”字符串放入常量池,相当于创建了两个对象,无论常量池中有没有”aaa”字符串,程序都会在堆内存中开辟一片新空间存放新对象。 用javap命令查看编译器生成的字节码如下图: 程序先new 了一个StringObj,然后ldc在常量池中创建了常量,最后执行打印操作
所以说,new String("aaa")创建了两个字符串对象,“aaa”只创建了一个对象
一些常见的指令助记符含义: nop, 什么都不做。 aconst_null,将 null 推送至栈顶。 iconst_i(变量数字),将 int 型 i 推送至栈顶。同理有lconst_0,fconst_0这种你应该知道什么意思了 ldc,将 int,float 或 String 型常量值从常量池中推送至栈顶。 iload,将指定的 int 型局部变量推送至栈顶。 istore,将栈顶 int 型数值存入指定局部变量。同理astore_i代表将栈顶引用型数值存入第i个局部变量。 dup,复制栈顶数值并将复制值压入栈顶。 invokevirtual,调用实例方法。 invokespecial,调用超类构造方法,实例初始化方法,私有方法。 invokestatic,调用静态方法。 invokeinterface,调用接口方法。 invokedynamic,调用动态链接方法。 new,创建一个对象,并将其引用值压入栈顶。
Integer是int的包装类;int是基本数据类型; Integer变量必须实例化后才能使用;int变量不需要; Integer实际是对象的引用,指向此new的Integer对象;int是直接存储数据值 ; Integer的默认值是null;int的默认值是0
ps:两者在运算时integer会自动拆箱; Integer会缓存-128-127
Java的反射是指程序在运行期可以拿到一个对象的所有信息,是一种强大而且复杂的机制。
优点:能够运行时动态获取类的实例,提高灵活性。 缺点:使用反射性能较低,需要解析字节码,将内存中的对象进行解析。
ps:其解决方案是:通过setAccessible(true)关闭JDK的安全检查来提升反射速度;多次创建一个类的实例时,有缓存会快很多;ReflflectASM工具类,通过字节码生成的方式加快反射速度
- Class.forName(“类的路径”);当你知道该类的全路径名时,你可以使用该方法获取 Class 类对象。
- 类名.class。这种方法只适合在编译前就知道操作的 Class。
- 对象名.getClass()。
- 如果是基本类型的包装类,可以调用包装类的Type属性来获得该包装类的Class对象
反射 API 用来生成 JVM 中的类、接口或则对象的信息。 Class 类:反射的核心类,可以获取类的属性,方法等信息。 Field 类:Java.lang.reflec 包中的类,表示类的成员变量,可以用来获取和设置类之中的属性值。 Method 类:Java.lang.reflec 包中的类,表示类的方法,它可以用来获取类中的方法信息或者执行方法。 Constructor 类:Java.lang.reflec 包中的类,表示类的构造方法。
先上一个例子:
一般步骤: 获取类的class对象实例 根据Class对象获取Constructor对象,并调用newInstance方法获取反射类对象 获取Method对象,并调用invoke方法
1.反射让开发人员可以通过外部类的全路径名创建对象,并使用这些类,实现一些扩展的功能。 2.反射让开发人员可以枚举出类的全部成员,包括构造函数、属性、方法。以帮助开发者写出正确的代码。 3.测试时可以利用反射 API 访问类的私有成员,以保证测试代码覆盖率。也就是说,Oracle 希望开发者将反射作为一个工具,用来帮助程序员实现本不可能实现的功能。
举个例子,来说明反射机制的强大之处: Spring 框架的使用,最经典的就是xml的配置模式。 Spring 通过 XML 配置模式装载 Bean 的过程:
- 将程序内所有 XML 或 Properties 配置文件加载入内存中;
- Java类里面解析xml或properties里面的内容,得到对应实体类的字节码字符串以及相关的属性信息;
- 使用反射机制,根据这个字符串获得某个类的Class实例;
- 动态配置实例的属性。 Spring这样做的好处是:不用每一次都要在代码里面去new或者做其他的事情; 以后要改的话直接改配置文件,代码维护起来就很方便了; 有时为了适应某些需求,Java类里面不一定能直接调用另外的方法,可以通过反射机制来实现。
以上边的代码为例:
- 反射获取类实例 Class.forName(),并没有将实现留给了java,而是交给了jvm去加载!主要是先获取 ClassLoader, 然后调用 native 方法,获取信息,加载类则是回调 java.lang.ClassLoader。最后,jvm又会回调 ClassLoader 进类加载!
- newInstance() 主要做了三件事: 权限检测,如果不通过直接抛出异常; 查找无参构造器,并将其缓存起来; 调用具体方法的无参构造方法,生成实例并返回。
- 获取Method对象, 上面的Class对象是在加载类时由JVM构造的,JVM为每个类管理一个独一无二的Class对象,这份Class对象里维护着该类的所有Method,Field,Constructor的cache,这份cache也可以被称作根对象。每次getMethod获取到的Method对象都持有对根对象的引用,因为一些重量级的Method的成员变量(主要是MethodAccessor),我们不希望每次创建Method对象都要重新初始化,于是所有代表同一个方法的Method对象都共享着根对象的MethodAccessor,每一次创建都会调用根对象的copy方法复制一份
- 调用invoke()方法。调用invoke方法的流程如下:
调用Method.invoke之后,会直接去调MethodAccessor.invoke。MethodAccessor就是上面提到的所有同名method共享的一个实例,由ReflectionFactory创建。创建机制采用了一种名为inflation的方式(JDK1.4之后):如果该方法的累计调用次数<=15,会创建出NativeMethodAccessorImpl,它的实现就是直接调用native方法实现反射;如果该方法的累计调用次数>15,会由java代码创建出字节码组装而成的MethodAccessorImpl。(是否采用inflation和15这个数字都可以在jvm参数中调整) 以调用MyClass.myMethod(String s)为例,生成出的MethodAccessorImpl字节码翻译成Java代码大致如下
因反射原理较复杂,下面简要描述下流程,想要详细了解的小伙伴,可以看这篇文章:反射原理
泛型是 JDK1.5 的一个新特性,泛型就是将类型参数化,其在编译时才确定具体的参数。这种参数类型可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口、泛型方法
- 类型安全 泛型的主要目标是提高 Java 程序的类型安全编译时期就可以检查出因 Java 类型不正确导致的 ClassCastException 异常符合越早出错代价越小原则
- 消除强制类型转换 泛型的一个附带好处是,使用时直接得到目标类型,消除许多强制类型转换所得即所需,这使得代码更加可读,并且减少了出错机会
- 潜在的性能收益 由于泛型的实现方式,支持泛型(几乎)不需要 JVM 或类文件更改所有工作都在编译器中完成编译器生成的代码跟不使用泛型(和强制类型转换)时所写的代码几乎一致,只是更能确保类型安全而已
限定通配符对类型进行了限制。有两种限定通配符,一种是它通过确保类型必须是T的子类来设定类型的上界,另一种是它通过确保类型必须是T的父类来设定类型的下界。 泛型类型必须用限定内的类型来进行初始化,否则会导致编译错误。 非限定通配符 ?,可以用任意类型来替代。如 List 的意思是这个集合是一个可以持有任意类型的集合,它可以是 List ,也可以是 List ,或者 List 等等
Java序列化是指把Java对象转换为字节序列的过程,而Java反序列化是指把字节序列恢复为Java对象的过程。 序列化:序列化是把对象转换成有序字节流,以便在网络上传输或者保存在本地文件中。核心作用是对象状态的保存与重建。我们都知道,Java对象是保存在JVM的堆内存中的,也就是说,如果JVM堆不存在了,那么对象也就跟着消失了。而序列化提供了一种方案,可以让你在即使JVM停机的情况下也能把对象保存下来的方案。就像我们平时用的U盘一样。把Java对象序列化成可存储或传输的形式(如二进制流),比如保存在文件中。这样,当再次需要这个对象的时候,从文件中读取出二进制流,再从二进制流中反序列化出对象。
反序列化:客户端从文件中或网络上获得序列化后的对象字节流,根据字节流中所保存的对象状态及描述信息,通过反序列化重建对象。
简要描述:对内存中的对象进行持久化或网络传输, 这个时候都需要序列化和反序列化 深入描述:
- 对象序列化可以实现分布式对象。 主要应用例如:RMI(即远程调用Remote Method Invocation)要利用对象序列化运行远程主机上的服务,就像在本地机上运行对象时一样。
- java对象序列化不仅保留一个对象的数据,而且递归保存对象引用的每个对象的数据。可以将整个对象层次写入字节流中,可以保存在文件中或在网络连接上传递。利用对象序列化可以进行对象的"深复制",即复制对象本身及引用的对象本身。序列化一个对象可能得到整个对象序列。
- 序列化可以将内存中的类写入文件或数据库中。 比如:将某个类序列化后存为文件,下次读取时只需将文件中的数据反序列化就可以将原先的类还原到内存中。也可以将类序列化为流数据进行传输。总的来说就是将一个已经实例化的类转成文件存储,下次需要实例化的时候只要反序列化即可将类实例化到内存中并保留序列化时类中的所有变量和状态。
- 对象、文件、数据,有许多不同的格式,很难统一传输和保存。序列化以后就都是字节流了,无论原来是什么东西,都能变成一样的东西,就可以进行通用的格式传输或保存,传输结束以后,要再次使用,就进行反序列化还原,这样对象还是对象,文件还是文件。
实现Serializable接口或者Externalizable接口。 Serializable接口 类通过实现 java.io.Serializable 接口以启用其序列化功能。可序列化类的所有子类型本身都是可序列化的。序列化接口没有方法或字段,仅用于标识可序列化的语义
通过下面的代码进行序列化及反序列化:
Externalizable接口 Externalizable 继承自 Serializable ,该接口中定义了两个抽象方法: writeExternal() 与readExternal() 。当使用 Externalizable 接口来进行序列化与反序列化的时候需要开发人员重写 writeExternal() 与readExternal() 方法。否则所有变量的值都会变成默认值
serialVersionUID 用来表明类的不同版本间的兼容性Java的序列化机制是通过在运行时判断类的serialVersionUID来验证版本一致性的。在进行反序列化时,JVM会把传来的字节流中的serialVersionUID与本地相应实体(类)的serialVersionUID进行比较,如果相同就认为是一致的,可以进行反序列化,否则就会出现序列化版本不一致的异常。
如果不显示指定serialVersionUID, JVM在序列化时会根据属性自动生成一个serialVersionUID, 然后与属性一起序列化, 再进行持久化或网络传输. 在反序列化时, JVM会再根据属性自动生成一个新版serialVersionUID, 然后将这个新版serialVersionUID与序列化时生成的旧版serialVersionUID进行比较,如果相同则反序列化成功, 否则报错.如果显示指定了, JVM在序列化和反序列化时仍然都会生成一个serialVersionUID, 但值为我们显示指定的值, 这样在反序列化时新旧版本的serialVersionUID就一致了。 在实际开发中, 不显示指定serialVersionUID的情况会导致什么问题? 如果我们的类写完后不再修改, 那当然不会有问题, 但这在实际开发中是不可能的, 我们的类会不断迭代, 一旦类被修改了, 那旧对象反序列化就会报错. 所以在实际开发中, 我们都会显示指定一个serialVersionUID, 值是多少无所谓, 只要不变就行
序列化类新增属性时,请不要修改serialVersionUID字段,避免反序列化失败。如果完全不兼容升级,避免反序列化混乱,那么请修改serivalVersionUID值 深入了解的小伙伴,可以看这篇文章:juejin.cn/post/…
对于不想进行序列化的变量,使用 transient 关键字修饰。transient 关键字的作用是控制变量的序列化,在变量声明前加上该关键字,可以阻止该变量被序列化到文件中,在被反序列化后, transient 变量的值被设为初始值,如 int 型的是 0,对象型的是 null。transient 只能修饰变量,不能修饰类和方法。
不会。因为序列化是针对对象而言的, 而静态变量优先于对象存在, 随着类的加载而加载, 所以不会被序列化.看到这个结论, 是不是有人会问, serialVersionUID也被static修饰, 为什么serialVersionUID会被序列化?其实serialVersionUID属性并没有被序列化, JVM在序列化对象时会自动生成一个serialVersionUID, 然后将我们显示指定的serialVersionUID属性值赋给自动生成的serialVersionUID。
按照流的方向:输入流(inputStream)和输出流(outputStream); 按照实现功能分:节点流(可以从或向一个特定的地方读写数据,如 FileReader)和处理流(是对一个已存在的流的连接和封装,通过所封装的流的功能调用实现数据读写,BufferedReader); 按照处理数据的单位: 字节流和字符流。分别由四个抽象类来表示(每种流包括输入和输出两种所以一共四个):InputStream,OutputStream,Reader,Writer。Java中其他多种多样变化的流均是由它们派生出来的。
读写的时候字节流是按字节读写,字符流按字符读写。字节流适合所有类型文件的数据传输,因为计算机字节(Byte)是电脑中表示信息含义的最小单位。字符流只能够处理纯文本数据,其他类型数据不行,但是字符流处理文本要比字节流处理文本要方便。在读写文件需要对内容按行处理,比如比较特定字符,处理某一行数据的时候一般会选择字符流。只是读写文件,和文件内容无关时,一般选择字节流。
IO操作包括:对硬盘的读写、对socket的读写以及外设的读写。 当用户线程发起一个IO请求操作(本文以读请求操作为例),内核会去查看要读取的数据是否就绪,对于阻塞IO来说,如果数据没有就绪,则会一直在那等待,直到数据就绪;对于非阻塞IO来说,如果数据没有就绪,则会返回一个标志信息告知用户线程当前要读的数据没有就绪。当数据就绪之后,便将数据拷贝到用户线程,这样才完成了一个完整的IO读请求操作,也就是说一个完整的IO读请求操作包括两个阶段: 1)查看数据是否就绪; 2)进行数据拷贝(内核将数据拷贝到用户线程)。 那么阻塞(blocking IO)和非阻塞(non-blocking IO)的区别就在于第一个阶段,如果数据没有就绪,在查看数据是否就绪的过程中是一直等待,还是直接返回一个标志信息。Java中传统的IO都是阻塞IO,比如通过socket来读数据,调用read()方法之后,如果数据没有就绪,当前线程就会一直阻塞在read方法调用那里,直到有数据才返回;而如果是非阻塞IO的话,当数据没有就绪,read()方法应该返回一个标志信息,告知当前线程数据没有就绪,而不是一直在那里等待。 深入了解可看这篇文章:mp.weixin..com/s/p5qM2UJ1u…
BIO:同步并阻塞,在服务器中实现的模式为一个连接一个线程。也就是说,客户端有连接请求的时候,服务器就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,当然这也可以通过线程池机制改善。BIO一般适用于连接数目小且固定的架构,这种方式对于服务器资源要求比较高,而且并发局限于应用中,是JDK1.4之前的唯一选择,但好在程序直观简单,易理解。 NIO:同步并非阻塞,在服务器中实现的模式为一个请求一个线程,也就是说,客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到有连接IO请求时才会启动一个线程进行处理。NIO一般适用于连接数目多且连接比较短(轻操作)的架构,并发局限于应用中,编程比较复杂,从JDK1.4开始支持。 AIO:异步并非阻塞,在服务器中实现的模式为一个有效请求一个线程,也就是说,客户端的IO请求都是通过操作系统先完成之后,再通知服务器应用去启动线程进行处理。AIO一般适用于连接数目多且连接比较长(重操作)的架构,充分调用操作系统参与并发操作,编程比较复杂,从JDK1.7开始支持
使用了适配器模式和装饰器模式 适配器模式:把一个类的接口变换成客户端所期待的另一种接口,从而使原本因接口不匹配而无法在一起工作的两个类能够在一起工作 类适配器:Adapter类(适配器)继承Adaptee类(源角色)实现Target接口(目标角色)对象适配器:Adapter类(适配器)持有Adaptee类(源角色)对象实例,实现Target接口(目标角色)
装饰器模式: 一种动态地往一个类中添加新的行为的设计模式。就功能而言,装饰器模式相比生成子类更为灵活,这样可以给某个对象而不是整个类添加一些功能。ConcreteComponent(具体对象)和Decorator(抽象装饰器)实现相同的Conponent(接口)并且Decorator(抽象装饰器)里面持有Conponent(接口)对象,可以传递请求。ConcreteComponent(具体装饰器)覆盖Decorator(抽象装饰器)的方法并用super进行调用,传递请求。
1.Java 中,所有的异常都有一个共同的祖先 java.lang 包中的 Throwable 类Throwable 类有两个重要的子类 Exception (异常)和 Error (错误)。 2.Exception 和 Error 二者都是 Java 异常处理的重要子类,各自都包含大量子类。Exception :程序本身可以处理的异常,可以通过 catch 来进行捕获,通常遇到这种错误,应对其进行处理,使应用程序可以继续正常运行。 Exception 又可以分为运行时异常(RuntimeException, 又叫非受检查异常)和非运行时异常(又叫受检查异常)。Error : Error 属于程序无法处理的错误 ,我们没办法通过 catch 来进行捕获 。例如,系统崩溃,内存不足,堆栈溢出等,编译器不会对这类错误进行检测,一旦这类错误发生,通常应用程序会被终止,仅靠应用程序本身无法恢复。
非受检查异常:包括 RuntimeException 类及其子类,表示 JVM 在运行期间可能出现的异常。 Java 编译器不会检查运行时异常。例如: NullPointException(空指针) 、NumberFormatException(字符串转换为数字) 、 IndexOutOfBoundsException(数组越界) 、 ClassCastException(类转换异常) 、ArrayStoreException(数据存储异常,操作数组时类型不一致) 等。 受检查异常:是Exception 中除 RuntimeException 及其子类之外的异常。 Java 编译器会检查受检查异常。常见的受检查异常有: IO 相关的异常、 ClassNotFoundException 、 SQLException 等。 非受检查异常和受检查异常之间的区别:是否强制要求调用者必须处理此异常,如果强制要求调用者必须进行处理,那么就使用受检查异常,否则就选择非受检查异常。
throw 关键字用在方法内部,只能用于抛出一种异常,用来抛出方法或代码块中的异常,受查异常和非受查异常都可以被抛出。 throws 关键字用在方法声明上,可以抛出多个异常,用来标识该方法可能抛出的异常列表。一个方法用 throws 标识了可能抛出的异常列表,调用该方法的方法中必须包含可处理异常的代码,否则也要在方法签名中用 throws 关键字声明相应的异常。
throw 关键字:
throws 关键字:
NoClassDefFoundError 是一个 Error 类型的异常,是由 JVM 引起的,不应该尝试捕获这个异常。引起该异常的原因是 JVM 或 ClassLoader 尝试加载某类时在内存中找不到该类的定义,该动作发生在运行期间,即编译时该类存在,但是在运行时却找不到了,可能是变异后被删除了等原因导致。 ClassNotFoundException 是一个受检查异常,需要显式地使用 try-catch 对其进行捕获和处理,或在方法签名中用 throws 关键字进行声明。当使用 Class.forName, ClassLoader.loadClass 或ClassLoader.findSystemClass 动态加载类到内存的时候,通过传入的类路径参数没有找到该类,就会抛出该异常;另一种抛出该异常的可能原因是某个类已经由一个类加载器加载至内存中,另一个加载器又尝试去加载它。
java.lang.IllegalAccessError:违法访问错误。当一个应用试图访问、修改某个类的域(Field)或者调用其方法,但是又违反域或方法的可见性声明,则抛出该异常。
java.lang.InstantiationError:实例化错误。当一个应用试图通过Java的new操作符构造一个抽象类或者接口时抛出该异常.
java.lang.OutOfMemoryError:内存不足错误。当可用内存不足以让Java虚拟机分配给一个对象时抛出该错误。
java.lang.StackOverflowError:堆栈溢出错误。当一个应用递归调用的层次太深而导致堆栈溢出或者陷入死循环时抛出该错误。
java.lang.ClassCastException:类造型异常。假设有类A和B(A不是B的父类或子类),O是A的实例,那么当强制将O构造为类B的实例时抛出该异常。该异常经常被称为强制类型转换异常。
java.lang.ClassNotFoundException:找不到类异常。当应用试图根据字符串形式的类名构造类,而在遍历CLASSPAH之后找不到对应名称的class文件时,抛出该异常。
java.lang.ArithmeticException:算术条件异常。譬如:整数除零等。
java.lang.ArrayIndexOutOfBoundsException:数组索引越界异常。当对数组的索引值为负数或大于等于数组大小时抛出。
java.lang.IndexOutOfBoundsException:索引越界异常。当访问某个序列的索引值小于0或大于等于序列大小时,抛出该异常。
java.lang.InstantiationException:实例化异常。当试图通过newInstance()方法创建某个类的实例,而该类是一个抽象类或接口时,抛出该异常。
java.lang.NoSuchFieldException:属性不存在异常。当访问某个类的不存在的属性时抛出该异常。
java.lang.NoSuchMethodException:方法不存在异常。当访问某个类的不存在的方法时抛出该异常。
java.lang.NullPointerException:空指针异常。当应用试图在要求使用对象的地方使用了null时,抛出该异常。譬如:调用null对象的实例方法、访问null对象的属性、计算null对象的长度、使用throw语句抛出null等等。
java.lang.NumberFormatException:数字格式异常。当试图将一个String转换为指定的数字类型,而该字符串确不满足数字类型要求的格式时,抛出该异常。
java.lang.StringIndexOutOfBoundsException:字符串索引越界异常。当使用索引值访问某个字符串中的字符,而该索引值小于0或大于等于序列大小时,抛出该异常。
catch 可以省略。更为严格的说法其实是:try只适合处理运行时异常,try+catch适合处理运行时异常+普通异常。也就是说,如果你只用try去处理普通异常却不加以catch处理,编译是通不过的,因为编译器硬性规定,普通异常如果选择捕获,则必须用catch显示声明以便进一步处理。而运行时异常在编译时没有如此规定,所以catch可以省略,你加上catch编译器也觉得无可厚非。理论上,编译器看任何代码都不顺眼,都觉得可能有潜在的问题,所以你即使对所有代码加上try,代码在运行期时也只不过是在正常运行的基础上加一层皮。但是你一旦对一段代码加上try,就等于显示地承诺编译器,对这段代码可能抛出的异常进行捕获而非向上抛出处理。如果是普通异常,编译器要求必须用catch捕获以便进一步处理;如果运行时异常,捕获然后丢弃并且+finally扫尾处理,或者加上catch捕获以便进一步处理。 至于加上finally,则是在不管有没捕获异常,都要进行的“扫尾”处理。
会执行,在 return 前执行。在 finally 中改变返回值的做法是不好的,因为如果存在 finally 代码块,try中的 return 语句不会立马返回调用者,而是记录下返回值待 finally 代码块执行完毕之后再向调用者返回其值,然后如果在 finally 中修改了返回值,就会返回修改后的值。显然,在 finally 中返回或者修改返回值会对程序造成很大的困扰,Java 中也可以通过提升编译器的语法检查级别来产生警告或错误。
在一个方法中如果发生异常,这个方法会创建一个异常对象,并转交给 JVM,该异常对象包含异常名称,异常描述以及异常发生时应用程序的状态。创建异常对象并转交给 JVM 的过程称为抛出异常。可能有一系列的方法调用,最终才进入抛出异常的方法,这一系列方法调用的有序列表叫做调用栈。JVM 会顺着调用栈去查找看是否有可以处理异常的代码,如果有,则调用异常处理代码。当 JVM 发现可以处理异常的代码时,会把发生的异常传递给它。如果 JVM 没有找到可以处理该异常的代码块,JVM 就会将该异常转交给默认的异常处理器(默认处理器为 JVM 的一部分),默认异常处理器打印出异常信息并终止应用程序。 想要深入了解的小伙伴可以看这篇文章:www.cnblogs.com/qdhxhz/p/10…
Java集合类主要由两个根接口Collection和Map派生出来的,Collection派生出了三个子接口:List、Set、Queue(Java5新增的队列),因此Java集合大致也可分成List、Set、Queue、Map四种接口体系。 注意:Collection是一个接口,Collections是一个工具类,Map不是Collection的子接口。 Java集合框架图如下: 图中,List代表了有序可重复集合,可直接根据元素的索引来访问; Set代表无序不可重复集合,只能根据元素本身来访问; Queue是队列集合。 Map代表的是存储key-value对的集合,可根据元素的key来访问value。 上图中淡绿色背景覆盖的是集合体系中常用的实现类,分别是ArrayList、LinkedList、ArrayQueue、HashSet、TreeSet、HashMap、TreeMap等实现类。
线程安全的: Hashtable:比HashMap多了个线程安全。 ConcurrentHashMap:是一种高效但是线程安全的集合。 Vector:比Arraylist多了个同步化机制。 Stack:栈,也是线程安全的,继承于Vector。
线性不安全的: HashMap Arraylist LinkedList HashSet TreeSet TreeMap
相同点: ArrayList 和 LinkedList 都是线程不安全的;
不同点:
- Arraylist 底层使用的是Object数组;LinkedList 底层使用的是双向循环链表数据结构; 插入和删除是否受元素位置的影响: ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行 add(E e) 方法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是O(1)。但是如果要在指定位置 i 插入和删除元素的话( add(int index, E element) )时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。 LinkedList 采用链表存储,所以插入,删除元素时间复杂度不受元素位置的影响,都是近似 O(1)而数组为近似 O(n)。
- 是否支持快速随机访问: LinkedList 不支持高效的随机元素访问,而ArrayList 实现了RandmoAccess 接口,所以有随机访问功能。快速随机访问就是通过元素的序号快速获取元素对象(对应于 get(int index) 方法)。
- 内存空间占用: ArrayList的空 间浪费主要体现在在list列表的结尾会预留一定的容量空间,而LinkedList的空间花费则体现在它的每一个元素都需要消耗比ArrayList更多的空间(因为要存放直接后继和直接前驱以及数据)。
Vector是线程安全的,ArrayList不是线程安全的。其中,Vector在关键性的方法前面都加了synchronized关键字,来保证线程的安全性。如果有多个线程会访问到集合,那最好是使用Vector,因为不需要我们自己再去考虑和编写线程安全的代码; ArrayList在底层数组不够用时在原来的基础上扩展0.5倍,Vector是扩展1倍,这样ArrayList就有利于节约内存空间.
ArrayList扩容的本质就是计算出新的扩容数组的size后实例化,并将原有数组内容复制到新数组中去。默认情况下,新的容量会是原容量的1.5倍。 以JDK1.8为例说明
Array 可以包含基本类型和对象类型,ArrayList 只能包含对象类型。 Array 大小是固定的,ArrayList 的大小是动态变化的。 ArrayList 提供了更多的方法和特性,比如:addAll(),removeAll(),iterator() 等等
在JDK1.7 和JDK1.8 中有所差别: 在JDK1.7 中,由“数组+链表”组成,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的。 在JDK1.8 中,由“数组+链表+红黑树”组成。当链表过长,则会严重影响 HashMap 的性能,红黑树搜索时间复杂度是 O(logn),而链表是糟糕的 O(n)。因此,JDK1.8 对数据结构做了进一步的优化,引入了红黑树链表和红黑树在达到一定条件会进行转换: 当链表超过 8 且数据总量超过 64 才会转红黑树。将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树,以减少搜索时间
解决Hash冲突方法有:开放定址法、再哈希法、链地址法(拉链法)、建立公共溢出区。HashMap中采用的是 链地址法 。
开放定址法也称为 再散列法 ,基本思想就是,如果 p=H(key) 出现冲突时,则以 p 为基础,再次hash, p1=H(p) ,如果p1再次出现冲突,则以p1为基础,以此类推,直到找到一个不冲突的哈希地址 pi 。 因此开放定址法所需要的hash表的长度要大于等于所需要存放的元素,而且因为存在再次hash,所以 只能在删除的节点上做标记,而不能真正删除节点。
再哈希法(双重散列,多重散列),提供多个不同的hash函数,当 R1=H1(key1) 发生冲突时,再计算 R2=H2(key1) ,直到没有冲突为止。 这样做虽然不易产生堆集,但增加了计算的时间。
链地址法(拉链法),将哈希值相同的元素构成一个同义词的单链表,并将单链表的头指针存放在哈希表的第i个单元中,查找、插入和删除主要在同义词链表中进行。链表法适用于经常进行插入和删除的情况。
建立公共溢出区,将哈希表分为公共表和溢出表,当溢出发生时,将所有溢出数据统一放到溢出区
因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要。当元素小于 8 个的时候,此时做查询操作,链表结构已经能保证查询性能。当元素大于 8 个的时候, 红黑树搜索时间复杂度是 O(logn),而链表是 O(n),此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。因此,如果一开始就用红黑树结构,元素太少,新增效率又比较慢,无疑这是浪费性能的。
table的初始化长度length(默认值是16),Load factor为负载因子(默认值是0.75),threshold是HashMap所能容纳键值对的最大值。threshold = length * Load factor。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。默认的loadFactor是0.75,0.75是对空间和时间效率的一个平衡选择,一般不要修改,除非在时间和空间比较特殊的情况下 :如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值 。相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。 我们来追溯下作者在源码中的注释(JDK1.7): 翻译过来大概的意思是:作为一般规则,默认负载因子(0.75)在时间和空间成本上提供了很好的折衷。较高的值会降低空间开销,但提高查找成本(体现在大多数的HashMap类的操作,包括get和put)。设置初始大小时,应该考虑预计的entry数在map及其负载系数,并且尽量减少rehash操作的次数。如果初始容量大于最大条目数除以负载因子,rehash操作将不会发生。
首先根据key的值计算出hashcode的值,然后根据hashcode计算出hash值,最后通过hash&(length-1)计算得到存储的位置。看看源码的实现:
这里的 Hash 算法本质上就是三步:取key的 hashCode 值、根据 hashcode 计算出hash值、通过取模计算下标。其中,JDK1.7和1.8的不同之处,就在于第二步。我们来看下详细过程,以JDK1.8为例,n为table的长度。
以JDK1.8为例,简要流程如下:
- 首先根据 key 的值计算 hash 值,找到该元素在数组中存储的下标;
- 如果数组是空的,则调用 resize 进行初始化;
- 如果没有哈希冲突直接放在对应的数组下标里;
- 如果冲突了,且 key 已经存在,就覆盖掉 value;
- 如果冲突后,发现该节点是红黑树,就将这个节点挂在树上;
- 如果冲突后是链表,判断该链表是否大于 8 ,如果大于 8 并且数组容量小于 64,就进行扩容;如果链表节点大于 8 并且数组的容量大于 64,则将这个结构转换为红黑树;否则,链表插入键值对,若 key 存在,就覆盖掉 value。
HashMap 在容量超过负载因子所定义的容量之后,就会扩容。Java 里的数组是无法自动扩容的,方法是将 HashMap 的大小扩大为原来数组的两倍,并将原来的对象放入新的数组中。 那扩容的具体步骤是什么?让我们看看源码。 先来看下JDK1.7 的代码
newTable[i] 的引用赋给了 e.next ,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到 Entry 链的尾部(如果发生了 hash 冲突的话)。
JDK1.8做了两处优化
- resize 之后,元素的位置在原来的位置,或者原来的位置 +oldCap (原来哈希表的长度)。不需要像 JDK1.7 的实现那样重新计算hash ,只需要看看原来的 hash 值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引 + oldCap ”。这个设计非常的巧妙,省去了重新计算hash 值的时间。如下图所示,n 为 table 的长度,图(a)表示扩容前的 key1 和 key2 两种 key 确定索引位置的示例,图(b)表示扩容后 key1 和key2 两种 key 确定索引位置的示例,其中 hash1 是 key1 对应的哈希与高位运算结果。
元素在重新计算 hash 之后,因为 n 变为 2倍,那么 n-1 的 mask 范围在高位多 1 bit(红色),因此新的index就会发生这样的变化:
- JDK1.7 中 rehash 的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置(头插法)。JDK1.8 不会倒置,使用尾插法
一般用Integer、String 这种不可变类当 HashMap 当 key,而且 String 最为常用。因为字符串是不可变的,所以在它创建的时候 hashcode 就被缓存了,不需要重新计算。这就是HashMap 中的键往往都使用字符串的原因。因为获取对象的时候要用到 equals() 和 hashCode() 方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的重写了 hashCode() 以及 equals() 方法。
多线程下扩容死循环。JDK1.7中的 HashMap 使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此,JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。多线程的put可能导致元素的丢失。多线程同时执行 put 操作,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。此问题在JDK 1.7和 JDK 1.8 中都存在。put和get并发时,可能导致get为null。线程1执行put时,因为元素个数超出threshold而导致rehash,线程2此时执行get,有可能导致这个问题。此问题在JDK 1.7和 JDK 1.8 中都存在。
ConcurrentHashMap 在 JDK1.7 和 JDK1.8 的实现方式是不同的。
JDK1.7: ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成,即ConcurrentHashMap 把哈希桶切分成小数组(Segment ),每个小数组有 n 个 HashEntry 组成。其中,Segment 继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色;HashEntry用于存储键值对数据。
首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问,能够实现真正的并发访问。
JDK1.8: 在数据结构上, JDK1.8 中的ConcurrentHashMap 选择了与 HashMap 相同的数组+链表+红黑树结构;在锁的实现上,抛弃了原有的 Segment 分段锁,采用 CAS + synchronized 实现更加低粒度的锁。将锁的级别控制在了更细粒度的哈希桶元素级别,也就是说只需要锁住这个链表头结点(红黑树的根节点),就不会影响其他的哈希桶元素的读写,大大提高了并发度
JDK1.7 首先,会尝试获取锁,如果获取失败,利用自旋获取锁;如果自旋重试的次数超过 64 次,则改为阻塞获取锁。获取到锁后:
- 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry。
- 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的value。
- 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。
- 释放 Segment 的锁。
JDK1.8
- 根据 key 计算出 hash值。
- 判断是否需要进行初始化。
- 定位到 Node,拿到首节点 f,判断首节点 f:如果为 null ,则通过cas的方式尝试添加。如果为 f.hash = MOVED = -1 ,说明其他线程在扩容,参与一起扩容。如果都不满足 ,synchronized 锁住 f 节点,判断是链表还是红黑树,遍历插入。
- 当在链表长度达到8的时候,数组扩容或者将链表转换为红黑树。
get 方法不需要加锁。因为 Node 的元素 val 和指针 next 是用 volatile 修饰的,在多线程环境下线程A修改结点的val或者新增节点的时候是对线程B可见的。这也是它比其他并发集合比如 Hashtable、用 Collections.synchronizedMap()包装的 HashMap 安全效率高的原因之一。
没有关系。哈希桶 table 用volatile修饰主要是保证在数组扩容的时候保证可见性。
我们先来说value 为什么不能为 null ,因为 ConcurrentHashMap 是用于多线程的 ,如果map.get(key) 得到了 null ,无法判断,是映射的value是 null ,还是没有找到对应的key而为 null ,这就有了二义性。而用于单线程状态的HashMap 却可以用 containsKey(key) 去判断到底是否包含了这个 null 。我们用反证法来推理:假设ConcurrentHashMap 允许存放值为 null 的value,这时有A、B两个线程,线程A调用ConcurrentHashMap .get(key)方法,返回为 null ,我们不知道这个 null 是没有映射的 null ,还是存的值就是 null 。假设此时,返回为 null 的真实情况是没有找到对应的key。那么,我们可以用ConcurrentHashMap.containsKey(key)来验证我们的假设是否成立,我们期望的结果是返回false。但是在我们调用ConcurrentHashMap .get(key)方法之后,containsKey方法之前,线程B执行了ConcurrentHashMap .put(key, null )的操作。那么我们调用containsKey方法返回的就是true了,这就与我们的假设的真实情况不符合了,这就有了二义性。
在JDK1.7中,并发度默认是16,这个值可以在构造函数中设置。如果自己设置了并发度,ConcurrentHashMap 会使用大于等于该值的最小的2的幂指数作为实际并发度,也就是比如你设置的值是17,那么实际并发度是32
与HashMap迭代器是强一致性不同,ConcurrentHashMap 迭代器是弱一致性。ConcurrentHashMap 的迭代器创建后,就会按照哈希表结构遍历每个元素,但在遍历过程中,内部元素可能会发生变化,如果变化发生在已遍历过的部分,迭代器就不会反映出来,而如果变化发生在未遍历过的部分,迭代器就会发现并反映出来,这就是弱一致性。这样迭代器线程可以使用原来老的数据,而写线程也可以并发的完成改变,更重要的,这保证了多个线程并发执行的连续性和扩展性,是性能提升的关键。想要深入了解的小伙伴,可以看这篇文章为什么ConcurrentHashMap 是弱一致的
数据结构:取消了Segment分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。保证线程安全机制:JDK1.7采用Segment的分段锁机制实现线程安全,其中segment继承自ReentrantLock。JDK1.8 采用CAS+Synchronized保证线程安全。 锁的粒度:原来是对需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁 (Node)。链表转化为红黑树:定位结点的hash算法简化会带来弊端,Hash冲突加剧,因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。查询时间复杂度:从原来的遍历链表O(n),变成遍历红黑树O(logN)。
ConcurrentHashMap 的效率要高于Hashtable,因为Hashtable给整个哈希表加了一把大锁从而实现线程安全。而ConcurrentHashMap 的锁粒度更低,在JDK1.7中采用分段锁实现线程安全,在JDK1.8 中采用 CAS+Synchronized 实现线程安全。
Hashtable是使用Synchronized来实现线程安全的,给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞等待需要的锁被释放,在竞争激烈的多线程场景中性能就会非常差!
还可以使用 Collections.synchronizedMap 方法,对方法进行加同步锁
如果传入的是 HashMap 对象,其实也是对 HashMap 做的方法做了一层包装,里面使用对象锁来保证多线程场景下,线程安全,本质也是对 HashMap 进行全表锁。在竞争激烈的多线程环境下性能依然也非常差,不推荐使用!
补充HashSet的实现:HashSet的底层其实就是HashMap,只不过我们HashSet是实现了Set接口并且把数据作为K值,而V值一直使用一个相同的虚值来保存。如源码所示:
由于HashMap的K值本身就不允许重复,并且在HashMap中如果K/V相同时,会用新的V覆盖掉旧的V,然后返回旧的V,那么在HashSet中执行这一句话始终会返回一个false,导致插入失败,这样就保证了数据的不可重复性
第一种,实体类实现Comparable接口,并实现 compareTo(T t) 方法,称为内部比较器。 第二种,创建一个外部比较器,这个外部比较器要实现Comparator接口的 compare(T t1, T t2)方法。
遍历。使用Iterator,可以遍历所有集合,如Map,List,Set;但只能在向前方向上遍历集合中的元素。使用ListIterator,只能遍历List实现的对象,但可以向前和向后遍历集合中的元素。 添加元素。Iterator无法向集合中添加元素;而,ListIteror可以向集合添加元素。 修改元素。Iterator无法修改集合中的元素;而,ListIterator可以使用set()修改集合中的元素。 索引。Iterator无法获取集合中元素的索引;而,使用ListIterator,可以获取集合中元素的索引
快速失败(fail—fast)
在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出Concurrent Modification Exception。 原理:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用 hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。
注意:这里异常的抛出条件是检测到 modCount!=expectedmodCount 这个条件。如果集合发生变化时修改modCount值刚好又设置为了expectedmodCount值,则异常不会抛出。因此,不能依赖于这个异常是否抛出而进行并发操作的编程,这个异常只建议用于检测并发修改的bug。
场景:java.util包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改),比如HashMap、ArrayList 这些集合类。
安全失败(fail—safe) 采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。
原理:由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发Concurrent Modification Exception。
缺点:基于拷贝内容的优点是避免了Concurrent Modification Exception,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。
场景:java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改,比如:ConcurrentHashMap。
线程具有许多传统进程所具有的特征,故又称为轻型进程(Light—Weight Process)或进程元;而把传统的进程称为重型进程(Heavy—Weight Process),它相当于只有一个线程的任务。在引入了线程的操作系统中,通常一个进程都有若干个线程,至少包含一个线程。
1.根本区别:进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位资源开销:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小。 2.包含关系:如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。 内存分配:同一进程的线程共享本进程的地址空间和资源,而进程之间的地址空间和资源是相互独立的 3.影响关系:一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。 4.执行过程:每个独立的进程有程序运行的入口. 顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制,两者均可并发执行
1)采用实现Runnable. Callable接口的方式创建多线程。 优势是:线程类只是实现了Runnable接口或Callable接口,还可以继承其他类。在这种方式下,多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU. 代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。 劣势是:编程稍微复杂,如果要访问当前线程,则必须使用Thread.currentThread()方法。
2)使用继承Thread类的方式创建多线程 优势是:编写简单,如果需要访问当前线程,则无需使用Thread.currentThread()方法,直接使用this即可获得当前线程。 劣势是:线程类已经继承了Thread类,所以不能再继承其他父类。
3)Runnable和Callable的区别 Callable规定(重写)的方法是call(),Runnable规定(重写)的方法是run()。 Callable的任务执行后可返回值,而Runnable的任务是不能返回值的。 Call方法可以抛出异常,run方法不可以。 运行Callable任务可以拿到一个Future对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。通过Future对象可以了解任务执行情况,可取消任务的执行,还可获取执行结果。
从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度 的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并 发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
单核时代: 在单核时代多线程主要是为了提高 CPU 和 IO 设备的综合利用率。举个例子:当只有一个线程的时候会导致 CPU 计算时,IO 设备空闲;进行 IO 操作时,CPU 空闲。我们可以简单地说这两者的利用率目前都是 50%左右。但是当有两个线程的时候就不一样了,当一个线程执行CPU 计算时,另外一个线程可以进行 IO 操作,这样两个的利用率就可以在理想情况下达到 100%了。 多核时代:多核时代多线程主要是为了提高 CPU 利用率。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,CPU 只会一个 CPU 核心被利用到,而创建多个线程就可以让多个CPU 核心被利用到,这样就提高了 CPU 的利用率
Java线程具有五中基本状态 1)新建状态(New):当线程对象对创建后,即进入了新建状态,如:Thread t = new MyThread(); 2)就绪状态(Runnable):当调用线程对象的start()方法(t.start();),线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执行,并不是说执行了t.start()此线程立即就会执行; 3)运行状态(Running):当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。注:就 绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中; 4)阻塞状态(Blocked):处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才 有机会再次被CPU调用以进入到运行状态。 根据阻塞产生的原因不同,阻塞状态又可以分为三种: 1.等待阻塞:运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态; 2.同步阻塞 — 线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状 态; 3.其他阻塞 — 通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时. join()等待线程终止或者超时. 或者I/O处理完毕时,线程重新转入就绪状态。 5)死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。
死锁是多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
死锁必须具备以下四个条件: 互斥条件:该资源任意一个时刻只由一个线程占用。 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。 不剥夺条件:线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
避免线程死锁?只要破坏产生死锁的四个条件中的其中一个就可以了。 1.破坏互斥条件,这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问) 2.破坏请求与保持条件一次性申请所有的资源。 3. 破坏不剥夺条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。 4.破坏循环等待条件,靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。 锁排序法:(必须回答出来的点) 指定获取锁的顺序,比如某个线程只有获得A锁和B锁,才能对某资源进行操作,在多线程条件下,通过指定锁的获取顺序,比如规定,只有获得A锁的线程才有资格获取B锁,按顺序获取锁就可以避免死锁。这通常被认为是解决死锁很好的一种方法。使用显式锁中的ReentrantLock.try(long,TimeUnit)来申请锁
1.Runnable VS Callable
Callable仅在 Java 1.5 中引入,目的就是为了来处理Runnable不支持的用例。Callable 接口可以返回结果或抛出检查异常Runnable 接口不会返回结果或抛出检查异常,如果任务不需要返回结果或抛出异常推荐使用 Runnable接口,这样代码看起来会更加简洁工具类 Executors 可以实现 Runnable 对象和 Callable 对象之间的相互转换。(Executors.callable(Runnable task)或 Executors.callable(Runnable task,Objectresule))
2.shutdown() VS shutdownNow()
shutdown() :关闭线程池,线程池的状态变为 SHUTDOWN。线程池不再接受新任务了,但是队列里的任务得执行完毕。 shutdownNow() :关闭线程池,线程的状态变为 STOP。线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List。shutdownNow的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务可能永远无法终止
3.isTerminated() VS isShutdown() isShutDown 当调用 shutdown() 方法后返回为 true。 isTerminated 当调用 shutdown() 方法后,并且所有提交的任务完成后返回为 true
不同点: sleep方法:是Thread类的静态方法,当前线程将睡眠n毫秒,线程进入阻塞状态。当睡眠时间到了,会解除阻塞,进入可运行状态,等待CPU的到来。睡眠不释放锁(如果有的话)。 wait方法:是Object的方法,必须与synchronized关键字一起使用,线程进入阻塞状态,当notify或者notifyall被调用后,会解除阻塞。但是,只有重新占用互斥锁之后才会进入可运行状态。睡眠时,会释放互斥锁。 sleep 方法没有释放锁,而 wait 方法释放了锁 。 sleep 通常被用于暂停执行,Wait 通常被用于线程间交互/通信 sleep() 方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout)超时后线程会自动苏醒。 wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法
相同: 两者都可以暂停线程的执行。
调用start() 会执行线程的相应准备工作,然后自动执行run() 方法的内容,(调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。)这是真正的多线程工作。直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
Yield方法可以暂停当前正在执行的线程对象,让其它有相同优先级的线程执行。它是一个静态方法而且只保证当前线程放弃CPU占用而不能保证使其它线程一定能占用CPU,执行yield()的线程有可能在进入到暂停状态后马上又被执行。
volatile的两层语义:
- volatile保证变量对所有线程的可见性:当volatile变量被修改,新值对所有线程会立即更新。或者理·解为多线程环境下使用volatile修饰的变量的值一定是最新的。
- jdk1.5以后volatile完全避免了指令重排优化,实现了有序性。 volatile的原理:获取JIT(即时Java编译器,把字节码解释为机器语言发送给处理器)的汇编代码,发现volatile多加了 lock addl指令,这个操作相当于一个内存屏障,使得lock指令后的指令不能重排序到内存屏障前的位置。这也是为什么JDK1.5以后可以使用双锁检测实现单例模式。lock前缀的另一层意义是使得本线程工作内存中的volatile变量值立即写入到主内存中,并且使得其他线程共享的该volatile变量无效化,这样其他线程必须重新从主内存中读取变量值。 具体原理见这篇文章:www.javazhiyin.com/61019.html
Thread 类本质上是实现 Runnable 接口的一个实例,代表一个线程的实例。创建线程实例一般有两种 方法:
- 创建 Thread 的子类并重写 run()
- 实现 Runnable 接口
在新建类时实现 Runnable 接口,然后在 Thread 类的构造函数中传入 MyRunnable 的实例对象,最 后执行 start() 方法即可;
当线程因为某种原因放弃 CPU 使用权后,即让出了 CPU 时间片,暂时就会停止运行,知道线程进入可运行状态( Runnable ),才有机会再次获得 CPU 时间片转入 RUNNING 状态。一般来讲,阻塞的情况可以分为如下三种:
- 等待阻塞(Object.wait -> 等待队列) RUNNING 状态的线程执行 Object.wait() 方法后,JVM 会将线程放入等待序列(waitting queue);
- 同步阻塞(lock -> 锁池) RUNNING 状态的线程在获取对象的同步锁时,若该 同步锁被其他线程占用,则 JVM 将该线程放入 锁池(lock pool)中;
- 其他阻塞(sleep/join) RUNNING 状态的线程执行 Thread.sleep(long ms) 或 Thread.join() 方法,或发出 I/O 请求时,JVM 会将该线程置为阻塞状态。当 sleep() 状态超时, join() 等待线程终止或超时. 或者 I/O 处理完毕时,线程重新转入可运行状态( RUNNABLE );
- 正常结束 run() 或者 call() 方法执行完成后,线程正常结束;
- 异常结束线程抛出一个未捕获的 Exception 或 Error ,导致线程异常结束;
- 调用 stop()直接调用线程的 stop() 方法来结束该线程,但是一般不推荐使用该种方式,因为该方法通常容易导 致死锁;
守护线程是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些 发生的事件。在 Java 中垃圾回收线程就是特殊的守护线程。
Fork/Join框架是Java7提供的一个用于并行执行任务的框架,是一个把大任务分割成若干个小任务,最终 汇总每个小任务结果后得到大任务结果的框架。Fork/Join框架需要理解两个点,「分而治之」和「工作窃取算法」。 「分而治之」以上Fork/Join框架的定义,就是分而治之思想的体现啦
「工作窃取算法」 把大任务拆分成小任务,放到不同队列执行,交由不同的线程分别执行时。有的线程优先把自己负责的 任务执行完了,其他线程还在慢慢悠悠处理自己的任务,这时候为了充分提高效率,就需要工作盗窃算 法啦~
工作盗窃算法就是,「某个线程从其他队列中窃取任务进行执行的过程」。一般就是指做得快的线 程(盗窃线程)抢慢的线程的任务来做,同时为了减少锁竞争,通常使用双端队列,即快线程和慢线程 各在一端。
CAS:全称 Compare and swap ,即比较并交换,它是一条 CPU 同步原语。是一种硬件对并发 的支持,针对多处理器操作而设计的一种特殊指令,用于管理对共享数据的并发访问。 CAS 是一种无锁的非阻塞算法的实现。 CAS 包含了 3 个操作数:需要读写的内存值 V,旧的预期值 A,要修改的更新值 B
当且仅当 V 的值等于 A 时,CAS 通过原子方式用新值 B 来更新 V 的 值,否则不会执行任何操作(他的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的。) CAS 并发原语体现在 Java 语言中的 sum.misc.Unsafe 类中的各个方法。调用 Unsafe 类中的 CAS 方法, JVM 会帮助我们实现出 CAS 汇编指令。这是一种完全依赖于硬件的功能,通过它实现了原子操作。再次强调,由于 CAS是一种系统原语,原语属于操作系统用于范畴,是由若干条指令组成的,用于 完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,CAS 是一条 CPU 的原子指令,不会造成数据不一致问题。
- ABA 问题 并发环境下,假设初始条件是A,去修改数据时,发现是A就会执行修改。但是看到的虽然是A,中间可能发生了A变B,B又变回A的情况。此时A已经非彼A,数据即使成功修改,也可能有问题。可以通过AtomicStampedReference解决ABA问题,它,一个带有标记的原子引用类,通过控制变量值的版本来保证CAS的正确性。
- 循环时间长开销 自旋CAS,如果一直循环执行,一直不成功,会给CPU带来非常大的执行开销。 很多时候,CAS思想体现,是有个自旋次数的,就是为了避开这个耗时问题~
- 只能保证一个变量的原子操作。 CAS 保证的是对一个变量执行操作的原子性,如果对多个变量操作时,CAS 目前无法直接保证操作的原子性的。可以通过这两个方式解决这个问题:使用互斥锁来保证原子性;将多个变量封装成对象,通过AtomicReference来保证原子性。
volatile 解决的是内存可见性问题,会使得所有对 volatile 变量的读写都直接写入主存,即 保证 了变量的可见性。 synchronized 解决的事执行控制的问题,它会阻止其他线程获取当前对象的监控锁,这样一来就让当前对象中被 synchronized 关键字保护的代码块无法被其他线程访问,也就是无法并发执行。而且,synchronized 还会创建一个 内存屏障,内存屏障指令保证了所有 CPU 操作结果都会直接刷到主存中,从而 保证操作的内存可见性,同时也使得这个锁的线程的所有操作都 happens-before 于随后获得这个锁的线程的操作。
两者的区别主要有如下:
- volatile 本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
- volatile 仅能使用在变量级别;synchronized 则可以使用在 变量. 方法. 和类级别的
- volatile 仅能实现变量的修改可见性,不能保证原子性;而synchronized 则可以 保证变量的修 改可见性和原子性
- volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。
- volatile 标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化。
synchronized 可以给类. 方法. 代码块加锁;而 lock 只能给代码块加锁。 synchronized 不需要手动获取锁和释放锁,使用简单,发生异常会自动释放锁,不会造成死锁;而 lock 需要自己加锁和释放锁,如果使用不当没有 unLock()去释放锁就会造成死锁。 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到。
1.两者都是可重入锁可。重入锁,也叫做递归锁,可重入锁指的是在一个线程中可以多次获取同一把锁,比如:一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,而无需重新获得锁,两者都是同一个线程每进入一次,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
2.synchronized 依赖于 JVM 而 ReentrantLock 依赖于 APIsynchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally语句块来完成)
3.ReentrantLock 比 synchronized 增加了一些高级功能相比synchronized,ReentrantLock增加了一些高级功能。主要来说主要有三点:①等待可中断;②可实现公平锁;③可实现选择性通知(锁可以绑定多个条件)等待可中断.通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。 ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。ReentrantLock类线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知”
4.使用选择除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized。 synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK版本都支持。并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放
1.修饰普通方法:作用于当前对象实例,进入同步代码前要获得当前对象实例的锁 2.修饰静态方法:作用于当前类,进入同步代码前要获得当前类对象的锁,synchronized 关键字加到static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁 3.修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁 特别注意: ①如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁 ②尽量不要使用 synchronized(String s) ,因为JVM中,字符串常量池具有缓冲功能
- 原子性:确保线程互斥的访问同步代码;
- 可见性:保证共享变量的修改能够及时可见,其实是通过Java内存模型中的 “对一个变量unlock操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中 此变量的值,在执行引擎使用此变量前,需要重新从主内存中load操作或assign操作初始化 变量值” 来保证的;
- 有序性:有效解决重排序问题,即 “一个unlock操作先行发生(happen-before)于后面对同一个锁的lock操作”。
synchronized 同步代码块的实现是通过 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权。 其内部包含一个计数器,当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止;
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用
synchronized 锁升级原理:在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候threadid 为空,jvm 让其持有偏向锁,并将 threadid 设置为其线程 id,再次进入的时候会先判断threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了 synchronized 锁的升级。锁的升级的目的:锁升级是为了减低了锁带来的性能消耗。在 Java 6 之后优化 synchronized 的实现方式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。
synchronized 的非公平其实在源码中应该有不少地方,因为设计者就没按公平锁来设计,核心有以下几个点: 1)当持有锁的线程释放锁时,该线程会执行以下两个重要操作:
- 先将锁的持有者 owner 属性赋值为 null
- 唤醒等待链表中的一个线程(假定继承者)。 在1和2之间,如果有其他线程刚好在尝试获取锁(例如自旋),则可以马上获取到锁。 2)当线程尝试获取锁失败,进入阻塞时,放入链表的顺序,和最终被唤醒的顺序是不一致的,也就是说你先进入链表,不代表你就会先被唤醒。
从最近几个jdk版本中可以看出,Java的开发团队一直在对synchronized优化,其中最大的一次优化就是在jdk6的时候,新增了两个锁状态,通过锁消除、锁粗化、自旋锁等方法使用各种场景,给synchronized性能带来了很大的提升。
- 锁膨胀 上面讲到锁有四种状态,并且会因实际情况进行膨胀升级,其膨胀方向是:无锁——>偏向锁——>轻 量级锁——>重量级锁,并且膨胀方向不可逆。 偏向锁,一句话总结它的作用:减少统一线程获取锁的代价。在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得,那么此时就是偏向锁。核心思想:如果一个线程获得了锁,那么锁就进入偏向模式,此时 Mark Word 的结构也就变为偏向锁结构,当该线 程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查 Mark Word 的锁标记 位为偏向锁以及当前线程ID等于 Mark Word 的ThreadID即可,这样就省去了大量有关锁申请的操作。 轻量级锁,轻量级锁是由偏向锁升级而来,当存在第二个线程申请同一个锁对象时,偏向锁就会立即升级为轻量级锁。注意这里的第二个线程只是申请锁,不存在两个线程同时竞争锁,可以是一前一后地交替执行同步块。 重量级锁,重量级锁是由轻量级锁升级而来,当同一时间有多个线程竞争锁时,锁就会被升级成重量级锁,此时其申请锁带来的开销也就变大。重量级锁一般使用场景会在追求吞吐量,同步块或者同步方法执行时间较长的场景。 2.锁消除 消除锁是虚拟机另外一种锁的优化,这种优化更彻底,在JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。比如下面代码的method1和method2的执行效率是一样的,因为object锁是私有变量,不存在所得竞争关系。
- 锁粗化 锁粗化是虚拟机对另一种极端情况的优化处理,通过扩大锁的范围,避免反复加锁和释放锁。比如下面method3经过锁粗化优化之后就和method4执行效率一样了
- 自旋锁与自适应自旋锁 轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。
自旋锁:许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得,通过让线程执行循环等待锁的释放,不让出CPU。如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式。但是它也存在缺点:如果锁被其他线程长时间占用,一直不释放CPU,会带来许多的性能开销。
自适应自旋锁:这种相当于是对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点为什么要引入偏向锁和轻量级锁?为什么重量级锁开销大? 重量级锁底层依赖于系统的同步函数来实现,在 linux 中使用 pthread_mutex_t(互斥锁)来实现。这些底层的同步函数操作会涉及到:操作系统用户态和内核态的切换、进程的上下文切换,而这些操作都是比较耗时的,因此重量级锁操作的开销比较大。而在很多情况下,可能获取锁时只有一个线程,或者是多个线程交替获取锁,在这种情况下,使用重量 级锁就不划算了,因此引入了偏向锁和轻量级锁来降低没有并发竞争时的锁开销。
可以的。 具体的触发时机:在全局安全点(safepoint)中,执行清理任务的时候会触发尝试降级锁。 当锁降级时,主要进行了以下操作: 1)恢复锁对象的 markword 对象头; 2)重置 ObjectMonitor,然后将该 ObjectMonitor 放入全局空闲列表,等待后续使用。
Thread类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals,即每个线程都有一个属于自己的ThreadLocalMap。ThreadLocalMap内部维护着Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal本身,value是ThreadLocal的泛型值。 每个线程在往ThreadLocal里设置值的时候,都是往自己的ThreadLocalMap里存,读也是以某个ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离。
ThreadLocal内存结构图:
由结构图是可以看出: Thread对象中持有一个ThreadLocal.ThreadLocalMap的成员变量。 //创建一个ThreadLocal变量 static ThreadLocal localVariable = new ThreadLocal<>(); ThreadLocalMap内部维护了Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal本身,value是ThreadLocal的泛型值
先看看一下的TreadLocal的引用示意图哈, ThreadLocalMap中使用的 key 为 ThreadLocal 的弱引用,如下:
弱引用:只要垃圾回收机制一运行,不管JVM的内存空间是否充足,都会回收该对象占用的内存。弱引用比较容易被回收。因此,如果ThreadLocal(ThreadLocalMap的Key)被垃圾回收器回收了,但是因为ThreadLocalMap生命周期和Thread是一样的,它这时候如果不被回收,就会出现这种情况:ThreadLocalMap的key没了,value还在,这就会「造成了内存泄漏问题」。如何「解决内存泄漏问题」?使用完ThreadLocal后,及时调用remove()方法释放内存空间。
ReetrantLock是一个可重入的独占锁,主要有两个特性,一个是支持公平锁和非公平锁,一个是可重入。ReetrantLock实现依赖于AQS(AbstractQueuedSynchronizer)。ReetrantLock主要依靠AQS维护一个阻塞队列,多个线程对加锁时,失败则会进入阻塞队列。等待唤 醒,重新尝试加锁。
首先ReentrantLock某些时候有局限,如果使用ReentrantLock,可能本身是为了防止线程A在写数据、线程B在读数据造成的数据不一致,但这样,如果线程C在读数据、线程D也在读数据,读数据是不会改变数据的,没有必要加锁,但是还是加锁了,降低了程序的性能。 因为这个,才诞生了读写锁ReadWriteLock。ReadWriteLock是一个读写锁接口, ReentrantReadWriteLock是ReadWriteLock接口的一个具体实现,实现了读写的分离,读锁是共享的,写锁是独占的,读和读之间不会互斥,读和写、写和读、写和写之间才会互斥,提升了读写的性能
线程池提供了一种限制和管理资源(包括执行一个任务)。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。 使用线程池的好处:降低资源消耗。 通过重复利用已创建的线程降低线程创建和销毁造成的消耗。提高响应速度。 当任务到达时,任务可以不需要的等到线程创建就能立即执行。 提高线程的可管理性。 线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
execute() 方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功 与否; submit()方法用于提交需要返回值的任务。线程池会返回一个future类型的对象,通过这个 future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit) 方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。
corePoolSize : 核心线程大小。线程池一直运行,核心线程就不会停止。 maximumPoolSize :线程池最大线程数量。非核心线程数量=maximumPoolSize-corePoolSize keepAliveTime :非核心线程的心跳时间。如果非核心线程在keepAliveTime内没有运行任务,非核心线程会消亡。 workQueue :阻塞队列。ArrayBlockingQueue,LinkedBlockingQueue等,用来存放线程任 务。 defaultHandler :饱和策略。ThreadPoolExecutor类中一共有4种饱和策略。通过实现 RejectedExecutionHandler接口。 AbortPolicy : 线程任务丢弃报错。默认饱和策略。 DiscardPolicy : 线程任务直接丢弃不报错。 DiscardOldestPolicy : 将workQueue队首任务丢弃,将最新线程任务重新加入队列执行。 CallerRunsPolicy :线程池之外的线程直接调用run方法执行。 ThreadFactory :线程工厂。新建线程工厂。
- 线程池执行execute/submit方法向线程池添加任务,当任务小于核心线程数corePoolSize,线程池中可以创建新的线程。
- 当任务大于核心线程数corePoolSize,就向阻塞队列添加任务。
- 如果阻塞队列已满,需要通过比较参数maximumPoolSize,在线程池创建新的线程,当线程数量大于maximumPoolSize,说明当前设置线程池中线程已经处理不了了,就会执行饱和策略。
1、newCachedThreadPool 创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。这种类型的线程池特点是:工作线程的创建数量几乎没有限制(其实也有限制的,数目为Interger. MAX_VALUE), 这样可灵活的往线程池中添加线程。如果长时间没有往线程池中提交任务,即如果工作线程空闲了指定的时间(默认为1分钟),则该工作线程 将自动终止。终止后,如果你又提交了新的任务,则线程池重新创建一个工作线程。在使用CachedThreadPool时,一定要注意控制任务的数量,否则,由于大量线程同时运行,很有会造成系统OOM。 2、newFixedThreadPool 创建一个指定工作线程数量的线程池。每当提交一个任务就创建一个工作线程,如果工作线程数量达到线程池初始的最大数,则将提交的任务存入到池队列中。表格左侧是线程池,右侧为它们对应的阻塞队列,可以看到 5 种线程池对应了 3 种阻塞队列FixedThreadPool是一个典型且优秀的线程池,它具有线程池提高程序效率和节省创建线程时所耗的开销的优点。但是,在线程池空闲时,即线程池中没有可运行任务时,它不会释放工作线程,还会占用一 定的系统资源。 3、newSingleThreadExecutor 创建一个单线程化的Executor,即只创建唯一的工作者线程来执行任务,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。如果这个线程异常结束,会有另一个取代它,保证顺序执行。单工作线程最大的特点是可保证顺序地执行各个任务,并且在任意给定的时间不会有多个线程是活动的。 4、newScheduleThreadPool 创建一个定长的线程池,而且支持定时的以及周期性的任务执行,支持定时及周期性任务执行。
- LinkedBlockingQueue 对于 FixedThreadPool 和 SingleThreadExector 而言,它们使用的阻塞队列是容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue,可以认为是无界队列。由于 FixedThreadPool 线程池的线程数是固定的,所以没有办法增加特别多的线程来处理任务,这时就需要LinkedBlockingQueue 这样一个没有容量限制的阻塞队列来存放任务。 这里需要注意,由于线程池的任务队列永远不会放满,所以线程池只会创建核心线程数量的线程,所以此时的最大线程数对线程池来说没有意义,因为并不会触发生成多于核心线程数的线程。
- SynchronousQueue 第二种阻塞队列是 SynchronousQueue,对应的线程池是 CachedThreadPool。线程池 CachedThreadPool 的最大线程数是 Integer 的最大值,可以理解为线程数是可以无限扩展的。CachedThreadPool 和上一种线程池 FixedThreadPool 的情况恰恰相反FixedThreadPool 的情况是阻塞队列的容量是无限的,而这里 CachedThreadPool 是线程数可以无限扩展,所以CachedThreadPool 线程池并不需要一个任务队列来存储任务,因为一旦有任务被提交就直接转发给线程或者创建新线程来执行,而不需要另外保存它们。 我们自己创建使用 SynchronousQueue 的线程池时,如果不希望任务被拒绝,那么就需要注意设置最大线程数要尽可能大一些,以免发生任务数大于最大线程数时,没办法把任务放到队列中也没有足够线程来执行任务的情况。 3、newSingleThreadExecutor 创建一个单线程化的Executor,即只创建唯一的工作者线程来执行任务,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。如果这个线程异常结束,会有另一个取代它,保证顺序执行。单工作线程最大的特点是可保证顺序地执行各个任务,并且在任意给定的时间不会有多个线程是活动的。 4、newScheduleThreadPool 创建一个定长的线程池,而且支持定时的以及周期性的任务执行,支持定时及周期性任务执行。
- LinkedBlockingQueue 对于 FixedThreadPool 和 SingleThreadExector 而言,它们使用的阻塞队列是容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue,可以认为是无界队列。由于 FixedThreadPool 线程池的线程数是固定的,所以没有办法增加特别多的线程来处理任务,这时就需要LinkedBlockingQueue 这样一个没有容量限制的阻塞队列来存放任务。 这里需要注意,由于线程池的任务队列永远不会放满,所以线程池只会创建核心线程数量的线程,所以此时的最大线程数对线程池来说没有意义,因为并不会触发生成多于核心线程数的线程。
- SynchronousQueue 第二种阻塞队列是 SynchronousQueue,对应的线程池是 CachedThreadPool。线程池 CachedThreadPool 的最大线程数是 Integer 的最大值,可以理解为线程数是可以无限扩展的。CachedThreadPool 和上一种线程池 FixedThreadPool 的情况恰恰相反,FixedThreadPool 的情况是阻塞队列的容量是无限的,而这里 CachedThreadPool 是线程数可以无限扩展,所以CachedThreadPool 线程池并不需要一个任务队列来存储任务,因为一旦有任务被提交就直接转发给线程或者创建新线程来执行,而不需要另外保存它们。 我们自己创建使用 SynchronousQueue 的线程池时,如果不希望任务被拒绝,那么就需要注意设置最大线程数要尽可能大一些,以免发生任务数大于最大线程数时,没办法把任务放到队列中也没有足够线程来执行任务的情况。
- DelayedWorkQueue 第三种阻塞队列是DelayedWorkQueue,它对应的线程池分别是 ScheduledThreadPool 和 SingleThreadScheduledExecutor,这两种线程池的最大特点就是可以延迟执行任务,比如说一定时间后执行任务或是每隔一定的时间执行一次任务。DelayedWorkQueue 的特点是内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构。之所以线程池 ScheduledThreadPool 和SingleThreadScheduledExecutor 选择 DelayedWorkQueue,是因为它们本身正是基于时间执行任务的,而延迟队列正好可以把任务按时间进行排序,方便任务的执行。
源码中ThreadPoolExecutor中有个内置对象Worker,每个worker都是一个线程,worker线程数量和参数有关,每个worker会while死循环从阻塞队列中取数据,通过置换worker中Runnable对象,运行 其run方法起到线程置换的效果,这样做的好处是避免多线程频繁线程切换,提高程序运行性能。
自定义线程池就需要我们自己配置最大线程数 maximumPoolSize ,为了高效的并发运行,这时需要看我们的业务是IO密集型还是CPU密集型。 1.CPU密集型 CPU密集的意思是该任务需要最大的运算,而没有阻塞,CPU一直全速运行。CPU密集任务只有在真正的多核CPU上才能得到加速(通过多线程)。而在单核CPU上,无论你开几个模拟的多线程该任务都不可能得到加速,因为CPU总的运算能力就那么多。 2.IO密集型 IO密集型,即该任务需要大量的IO,即大量的阻塞。在单线程上运行IO密集型的任务会导致大量的CPU运算能力浪费在等待。所以在IO密集型任务中使用多线程可以大大的加速程序运行,即使在单核CPU上这种加速主要就是利用了被浪费掉的阻塞时间。 IO 密集型时,大部分线程都阻塞,故需要多配制线程数。公式为:
CPU核数*2 CPU核数/(1-阻塞系数) 阻塞系数在0.8~0.9之间 查看CPU核数: System.out.println(Runtime.getRuntime().availableProcessors());
当以上都不适用时,选用动态化线程池,看美团技术团队的实践:tech.meituan.com/2020/04/02/…
Executors 工具类的不同方法按照我们的需求创建了不同的线程池,来满足业务的需求。 Executor 接口对象能执行我们的线程任务。ExecutorService接口继承了Executor接口并进行了扩展,提供了更多的方法我们能获得任务执行的状态并且可以获取任务的返回值。 使用ThreadPoolExecutor 可以创建自定义线程池。Future 表示异步计算的结果,他提供了检查计算是否完成的方法,以等待计算的完成,并可以使用get()方法获取计算的结果。
- AQS 是一个锁框架,它定义了锁的实现机制,并开放出扩展的地方,让子类去实现,比如我们在lock 的时候,AQS 开放出 state 字段,让子类可以根据 state 字段来决定是否能够获得锁,对于获取不到锁的线程 AQS 会自动进行管理,无需子类锁关心,这就是 lock 时锁的内部机制,封装的很好,又暴露出子类锁需要扩展的地方;
- AQS 底层是由同步队列 + 条件队列联手组成,同步队列管理着获取不到锁的线程的排队和释放,条件队列是在一定场景下,对同步队列的补充,比如获得锁的线程从空队列中拿数据,肯定是拿不到数据的,这时候条件队列就会管理该线程,使该线程阻塞;
- AQS 围绕两个队列,提供了四大场景,分别是:获得锁、释放锁、条件队列的阻塞,条件队列的唤醒,分别对应着 AQS 架构图中的四种颜色的线的走向。
AQS同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):
- 使用者继承AbstractQueuedSynchronizer并重写指定的方法。(这些重写方法很简单,无非是对于共享资源state的获取和释放)
- 将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。 这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用。 AQS使用了模板方法模式,自定义同步器时需要重写下面几个AQS提供的模板方法:
isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。 tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。 tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。 tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源; 正数表示成功,且有剩余资源。 tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。
当前线程获取同步状态失败,同步器将当前线程机等待状态等信息构造成一个Node节点加入队列,放在队尾,同步器重新设置尾节点加入队列后,会阻塞当前线程同步状态被释放并且同步器重新设置首节点,同步器唤醒等待队列中第一个节点,让其再次获取同步状态
AQS定义两种资源共享方式 1.Exclusive(独占):只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁: 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
2.Share(共享):多个线程可同时执行,如Semaphore/CountDownLatch。Semaphore、 CountDownLatCh、 CyclicBarrier、ReadWriteLock 我们都会在后面讲到。ReentrantReadWriteLock 可以看成是组合式,因为ReentrantReadWriteLock也就是读写锁允许多个线程同时对某一资源进行读。
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。
1.Semaphore(信号量)-允许多个线程同时访问: synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。
2.CountDownLatch (倒计时器): CountDownLatch是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。
3.CyclicBarrier(循环栅栏): CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。CyclicBarrier默认的构造方法是CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用await方法告诉CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。
Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。所以,所谓原子类说简单点就是具有原子 / 原子操作特征的类。并发包 java.util.concurrent 的原子类都存放在 java.util.concurrent.atomic 下:
1.基本类型 使用原子的方式更新基本类型: AtomicInteger : 整型原子类 AtomicLong: 长整型原子类 AtomicBoolean: 布尔型原子类 2.数组类型 使用原子的方式更新数组里的某个元素: AtomicIntegerArray: 整型数组原子类 AtomicLongArray: 长整型数组原子类 AtomicReferenceArray: 引用类型数组原子类 3。引用类型 使用原子的方式更新引用类型: AtomicReference: 引用类型原子类 AtomicStampedReference: 原子更新带有版本号的引用类型。该类将整型数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的ABA 问题。 AtomicMarkableReference: 原子更新带有标记位的引用类型。 4.对象属性修改类型 AtomicIntegerFieldUpdater: 原子更新整型字段的更新器 AtomicLongFieldUpdater: 原子更新长整型字段的更新器 AtomicMarkableReference: 原子更新带有标记位的引用类型
AtomicInteger 类主要利用 CAS和 volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。AtomicInteger 类的部分源码:
到此这篇环形队列的优缺点(环形队列的优缺点是什么)的文章就介绍到这了,更多相关内容请继续浏览下面的相关推荐文章,希望大家都能在编程的领域有一番成就!版权声明:
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若内容造成侵权、违法违规、事实不符,请将相关资料发送至xkadmin@xkablog.com进行投诉反馈,一经查实,立即处理!
转载请注明出处,原文链接:https://www.xkablog.com/bcyy/51241.html