Java面试知识点总结(持续更新)

面试知识点总结

JDK代理和CGLib代理

代理模式

代理模式是23种设计模式的一种,他是指一个对象A通过持有另一个对象B,可以具有B同样的行为的模式。为了对外开放协议,B往往实现了一个接口,A也会去实现接口。但是B是“真正”实现类,A则比较“虚”,他借用了B的方法去实现接口的方法。A虽然是“伪军”,但它可以增强B,在调用B的方法前后都做些其他的事情。Spring AOP就是使用了动态代理完成了代码的动态“织入”。

使用代理好处还不止这些,一个工程如果依赖另一个工程给的接口,但是另一个工程的接口不稳定,经常变更协议,就可以使用一个代理,接口变更时,只需要修改代理,不需要一一修改业务代码。从这个意义上说,所有调外界的接口,我们都可以这么做,不让外界的代码对我们的代码有侵入,这叫防御式编程。代理其他的应用可能还有很多。

上述例子中,类A写死持有B,就是B的静态代理。如果A代理的对象是不确定的,就是动态代理。动态代理目前有两种常见的实现,jdk动态代理和cglib动态代理。

  • 静态:由程序员创建代理类或特定工具自动生成源代码再对其编译,在程序运行前代理类的 .class 文件就已经存在了。
  • 动态:在程序运行时,运用反射机制动态创建而成

JDK动态代理

具体实现原理:

1、通过实现InvocationHandlet接口创建自己的调用处理器

2、通过为Proxy类指定ClassLoader对象和一组interface来创建动态代理

3、通过反射机制获取动态代理类的构造函数,其唯一参数类型就是调用处理器接口类型

4、通过构造函数创建动态代理类实例,构造时调用处理器对象作为参数参入

JDK动态代理是面向接口的代理模式,如果被代理目标没有接口那么Spring也无能为力,

Spring通过java的反射机制生产被代理接口的新的匿名实现类,重写了其中AOP的增强方法。

jdk动态代理的例子 :mybatis

CGLib动态代理

CGLib是一个强大、高性能的Code生产类库,可以实现运行期动态扩展java类,Spring在运行期间通过 CGlib继承要被动态代理的类,重写父类的方法,实现AOP面向切面编程呢。

CGLib代理例子:springboot中的aop

两者对比

JDK动态代理是面向接口,在创建代理实现类时比CGLib要快,创建代理速度快。

CGLib动态代理是通过字节码底层继承要代理类来实现(如果被代理类被final关键字所修饰,那么抱歉会失败),在创建代理这一块没有JDK动态代理快,但是运行速度比JDK动态代理要快。

String和StringBuffer的区别

String

String对象更可以被看做成一个常量

String是类不是基本数据类型。

在String类中没有用来改变已有字符串中的某个字符的方法,由于不能改变一个java字符串中的某个单独字符,所以在JDK文档中称String类的对象是不可改变的。然而,不可改变的字符串具有一个很大的优点:编译器可以把字符串设为共享的。

StringBuffer

StringBuffer的内存可以被看做成一个由许多字符组成的字符串数组,可以对其中的任意一个单个字符进行增删改查的操作,而String不能做到这个。

StringBuffer类属于一种辅助类,可预先分配指定长度的内存块建立一个字符串缓冲区。这样使用StringBuffer类的append方法追加字符比String使用 + 操作符添加字符 到 一个已经存在的字符串后面有效率得多。因为使用+操作符每一次将字符添加到一个字符串中去时,字符串对象都需要寻找一个新的内存空间来容纳更大的字符串,这无凝是一个非常消耗时间的操作。添加多个字符也
就意味着要一次又一次的对字符串重新分配内存。使用StringBuffer类就避免了这个问题。
StringBuffer是线程安全的,在多线程程序中也可以很方便的进行使用,但是程序的执行效率相对来说就要稍微慢一些。

两者的区别

简单地说,就是一个变量和常量的关系。StringBuffer对象的内容可以修改;而String对象一旦产生后就不可以被修改,重新赋值其实是两个对象。

StringBuffer的内存可以被看做成一个由许多字符组成的字符串数组,可以对其中的任意一个单个字符进行增删改查的操作,而String不能做到这个。

StringBuffer预先分配了内存空间,不必在字符串连接的时候反复计算和分配内存,效率更高

String对象是不可变对象,每次操作String都会建立新的对象来保存新的值。
StringBuffer对象实例化后,只对这一个对象操作。

int和integer的区别

1、Integer是int的包装类,int则是java的一种基本数据类型

2、Integer变量必须实例化后才能使用,而int变量不需要

3、Integer实际是对象的引用,当new一个Integer时,实际上是生成一个指针指向此对象;而int则是直接存储数据值 。

4、Integer的默认值是null,int的默认值是0

int与Integer的深入对比

1、由于Integer变量实际上是对一个Integer对象的引用,所以两个通过new生成的Integer变量永远是不相等的(因为new生成的是两个对象,其内存地址不同)。

Integer i = new Integer(100);
Integer j = new Integer(100);
System.out.print(i == j); //false

2、Integer变量和int变量比较时,只要两个变量的值是向等的,则结果为true(因为包装类Integer和基本数据类型int比较时,java会自动拆包装为int,然后进行比较,实际上就变为两个int变量的比较)

Integer i = new Integer(100);
int j = 100;
System.out.print(i == j); //true

3、非new生成的Integer变量和new Integer()生成的变量比较时,结果为false。因为非new生成的Integer变量指向的是静态常量池中cache数组中存储的指向了堆中的Integer对象,而new Integer()生成的变量指向堆中新建的对象,两者在内存中的对象引用(地址)不同。

(3)非new生成的Integer变量和new Integer()生成的变量比较时,结果为false。因为非new生成的Integer变量指向的是静态常量池中cache数组中存储的指向了堆中的Integer对象,而new Integer()生成的变量指向堆中新建的对象,两者在内存中的对象引用(地址)不同。

Integer i = new Integer(100);
Integer j = 100;
System.out.print(i == j); //false

4、对于两个非new生成的Integer对象,进行比较时,如果两个变量的值在区间-128到127之间,则比较结果为true,如果两个变量的值不在此区间,则比较结果为false

Integer i = 100;
Integer j = 100;
System.out.print(i == j); //true

Integer i = 128;
Integer j = 128;
System.out.print(i == j); //false

对于第4条的原因: java在编译Integer i = 100 ;时,会翻译成为Integer i = Integer.valueOf(100)。而java API中对Integer类型的valueOf的定义如下,对于-128到127之间的数,会进行缓存,Integer i = 127时,会将127这个Integer对象进行缓存,下次再写Integer j = 127时,就会直接从缓存中取,就不会new了。

public static Integer valueOf(int i){
    assert IntegerCache.high >= 127;
    if (i >= IntegerCache.low && i <= IntegerCache.high){
        return IntegerCache.cache[i + (-IntegerCache.low)];
    }
    return new Integer(i);
}

(1)加大对简单数字的重利用,Java定义在自动装箱时对于在-128~127之内的数值,它们被装箱为Integer对象后,会存在内存中被重用,始终只存在一个对象。
(2)而如果在-128~127之外的数,被装箱后的Integer对象并不会被重用,即相当于每次装箱时都新建一个 Integer对象。

享元模式

享元模式是一种软件设计模式。它使用共享物件,用来尽可能减少内存使用量以及分享资讯给尽可能多的相似物件;它适合用于只是因重复而导致使用无法令人接受的大量内存的大量物件

在享元模式中通常会出现工厂模式,需要创建一个享元工厂来负责维护一个享元池(Flyweight Pool)(用于存储具有相同内部状态的享元对象)。在享元模式中,共享的是享元对象的内部状态,外部状态需要通过环境来设置。在实际使用中,能够共享的内部状态是有限的,因此享元对象一般都设计为较小的对象,它所包含的内部状态较少,这种对象也称为 细粒度对象。

享元模式的目的就是使用共享技术来实现大量细粒度对象的复用。

享元模式还出现在bean pool中 的单例 实现了对bean对象的重复使用

工厂模式

工厂模式是我们最常用的实例化对象模式了,是用工厂方法代替new操作的一种模式。

BeanFactory就是一种工厂模式的体现,通过这个工厂来实现new 一个bean对象然后存储到bean pool中去

https://www.jb51.net/article/85699.htm

Array和ArrayList之间的区别

  1. Array类型的变量在声明的同时必须进行实例化(至少得初始化数组的大小),而ArrayList可以只是先声明。
    如:
    int[] array = new array[3];
    或 int[] array = {1,2,3};
    或 ArrayList myList = new ArrayList();
    这些都是合法的,而直接使用 int[] array;是不行的。

  2. Array只能存储同构的对象,而ArrayList可以存储异构的对象。
    同构的对象是指类型相同的对象,若声明为int[]的数组就只能存放整形数据,string[]只能存放字符型数据,但声明为object[]的数组除外。
    而ArrayList可以存放任何不同类型的数据(因为它里面存放的都是被装箱了的Object型对象,实际上ArrayList内部就是使用"object[] _items;"这样一个私有字段来封装对象的)

  3. Array是始终是连续存放的,而ArrayList的存放不一定连续。

  4. 初始化大小
    Array对象的初始化必须只定指定大小,且创建后的数组大小是固定的,而ArrayList的大小可以动态指定,其大小可以在初始化时指定,也可以不指定,也就是说该对象的空间可以任意增加。ArrayList底层是用数组实现的,但是ArrayList的长度是可变的,在每次添加时,如果发现空间不足的话,会创建一个长度大概是原来1.5倍的新数组(java8源码),然后把原来的数组元素复制过去。

  5. Array不能够随意添加和删除其中的项,而ArrayList可以在任意位置插入和删除项。

  6. 存放数据的区别

    Array可以除了可以存放对象类型的数据之外,还可以存放基本数据类型的数据。而ArrayList只能存放对象数据类型的数据,因为它的类在定义时已经是针对Object的子类做了泛型的约束。ArrayList如果确实要存放基本数据类型的数据,那只能存放基本数据类型对应的包装类的数据。在数据的存取时可能会涉及到java基本数据类型的自动装箱、自动拆箱。

值传递和引用传递

值传递

按值传递:值传递是指在调用函数时将实际参数复制一份传递到函数中,这样在函数中如果对参数进行修改,将不会影响到实际参数。简单来说就是直接复制了一份数据过去,因为是直接复制,所以这种方式在传递时如果数据量非常大的话,运行效率自然就变低了,所以java在传递数据量很小的数据是值传递,比如java中的各种基本类型:int,float,double,boolean等类型的,具体可以自己测试。

引用传递

引用传递其实就弥补了上面说的不足,如果每次传参数的时候都复制一份的话,如果这个参数占用的内存空间太大的话,运行效率会很底下,所以引用传递就是直接把内存地址传过去,也就是说引用传递时,操作的其实都是源数据,这样的话修改有时候会冲突,记得用逻辑弥补下就好了,具体的数据类型就比较多了,比如Object,二维数组,List,Map等除了基本类型的参数都是引用传递。

Java的数据类型

基本数据类型

1)四种整数类型(byte、short、int、long): byte:8 位,用于表示最小数据单位,如文件中数据,-128~127 short:16 位,很少用,-32768 ~ 32767 int:32 位、最常用,-231-1~231 (21 亿) long:64 位、次常用 注意事项: int i=5; // 5 叫直接量(或字面量),即 直接写出的常数。 整数字面量默认都为 int 类型,所以在定义的 long 型数据后面加 L或 l。 小于 32 位数的变量,都按 int 结果计算。 强转符比数学运算符优先级高。见常量与变量中的例子。

2)两种浮点数类型(float、double): float:32 位,后缀 F 或 f,1 位符号位,8 位指数,23 位有效尾数。 double:64 位,最常用,后缀 D 或 d,1 位符号位,11 位指数,52 位有效尾 注意事项: 二 进 制 浮 点 数 : 1010100010=101010001.02=10101000.10210(2次方)=1010100.010*211(3次方)= . 1010100010*2^1010(10次方) 尾数: . 1010100010 指数:1010 基数:2 浮点数字面量默认都为 double 类型,所以在定义的 float 型数据后面加F 或 f;double 类型可不写后缀,但在小数计算中一定要写 D 或 X.X float 的精度没有 long 高,有效位数(尾数)短。 float 的范围大于 long 指数可以很大。 浮点数是不精确的,不能对浮点数进行精确比较。

3)一种字符类型(char): char:16 位,是整数类型,用单引号括起来的 1 个字符(可以是一个中文字符),使用 Unicode 码代表字符,0~2^16-1(65535) 。 注意事项: 不能为 0个字符。 转义字符:\n 换行 \r 回车 \t Tab 字符 " 双引号 \ 表示一个\ 两字符 char 中间用“+”连接,内部先把字符转成 int 类型,再进行加法运算,char 本质就是个数!二进制的,显示的时候,经过“处理”显示为字符。

4)一种布尔类型(boolean):true 真 和 false 假。

5)类型转换: byte,short,char—> int —> long—> float —> double 强制转换:①会损失精度,产生误差,小数点以后的数字全部舍弃。②容易超过取值范围。

包装数据类型

基本类型:byte 二进制位数:8
包装类:java.lang.Byte
最小值:Byte.MIN_VALUE=-128
最大值:Byte.MAX_VALUE=127

基本类型:short 二进制位数:16
包装类:java.lang.Short
最小值:Short.MIN_VALUE=-32768
最大值:Short.MAX_VALUE=32767

基本类型:int 二进制位数:32
包装类:java.lang.Integer
最小值:Integer.MIN_VALUE=-2147483648
最大值:Integer.MAX_VALUE=2147483647

基本类型:long 二进制位数:64
包装类:java.lang.Long
最小值:Long.MIN_VALUE=-9223372036854775808
最大值:Long.MAX_VALUE=9223372036854775807

基本类型:float 二进制位数:32
包装类:java.lang.Float
最小值:Float.MIN_VALUE=1.4E-45
最大值:Float.MAX_VALUE=3.4028235E38

基本类型:double 二进制位数:64
包装类:java.lang.Double
最小值:Double.MIN_VALUE=4.9E-324
最大值:Double.MAX_VALUE=1.7976931348623157E308

基本类型:char 二进制位数:16
包装类:java.lang.Character
最小值:Character.MIN_VALUE=0
最大值:Character.MAX_VALUE=65535

什么是Java中的自动拆装箱

自动拆箱与自动装箱

在Java SE5中,为了减少开发人员的工作,Java提供了自动拆箱与自动装箱功能。

自动装箱: 就是将基本数据类型自动转换成对应的包装类。

自动拆箱:就是将包装类自动转换成对应的基本数据类型。

Integer i =10; //自动装箱
int b= i; //自动拆箱
Integer i=10 可以替代 Integer i = new Integer(10);,这就是因为Java帮我们提供了自动装箱的功能,不需要开发者手动去new一个Integer对象。

自动装箱与自动拆箱的实现原理

既然Java提供了自动拆装箱的能力,那么,我们就来看一下,到底是什么原理,Java是如何实现的自动拆装箱功能。

我们有以下自动拆装箱的代码:

public static  void main(String[]args){
    Integer integer=1; //装箱
    int i=integer; //拆箱
}

对以上代码进行反编译后可以得到以下代码:

public static  void main(String[]args){
    Integer integer=Integer.valueOf(1); 
    int i=integer.intValue(); 
}

从上面反编译后的代码可以看出,int的自动装箱都是通过Integer.valueOf()方法来实现的,Integer的自动拆箱都是通过integer.intValue来实现的。如果读者感兴趣,可以试着将八种类型都反编译一遍 ,你会发现以下规律:

自动装箱都是通过包装类的valueOf()方法来实现的.自动拆箱都是通过包装类对象的xxxValue()来实现的。
https://blog.csdn.net/wufaliang003/article/details/82347077

ArrayList和LinkedList的区别

1、ArrayList和LinkedList可想从名字分析,它们一个是Array(动态数组)的数据结构,一个是Link(链表)的数据结构,此外,它们两个都是对List接口的实现。

前者是数组队列,相当于动态数组;后者为双向链表结构,也可当作堆栈、队列、双端队列

2、当随机访问List时(get和set操作),ArrayList比LinkedList的效率更高,因为LinkedList是线性的数据存储方式,所以需要移动指针从前往后依次查找。

3、当对数据进行增加和删除的操作时(add和remove操作),LinkedList比ArrayList的效率更高,因为ArrayList是数组,所以在其中进行增删操作时,会对操作点之后所有数据的下标索引造成影响,需要进行数据的移动。

4、从利用效率来看,ArrayList自由性较低,因为它需要手动的设置固定大小的容量,但是它的使用比较方便,只需要创建,然后添加数据,通过调用下标进行使用;而LinkedList自由性较高,能够动态的随数据量的变化而变化,但是它不便于使用。

5、ArrayList主要控件开销在于需要在lList列表预留一定空间;而LinkList主要控件开销在于需要存储结点信息以及结点指针信息。

另外,由于ArrayList的所有方法都是默认在单一线程下进行的,因此ArrayList不具有线程安全性。若想在多线程下使用,应该使用Colletions类中的静态方法synchronizedList()对ArrayList进行调用即可。

LinkedList可以看做为一个双向链表,所有的操作都可以认为是一个双向链表的操作,因为它实现了Deque接口和List接口。同样,LinkedList也是线程不安全的,如果在并发环境下使用它,同样用Colletions类中的静态方法synchronizedList()对LinkedList进行调用即可。

为什么会出现4.0-3.6=0.40000001这种现象?

原因

这种舍入误差的主要原因是:
浮点数值采用二进制系统表示, 而在二进制系统中无法精确地表示分数 1/10。
这 就好像十进制无法精确地表示分数 1/3—样。
如果在数值计算中不允许有任何舍入误差, 就应该使用 BigDecimal类。

浮点数值不适用于无法接受舍入误差的金融计算中。
例如,命令 System.out.println ( 2.0-1.1 ) 将打印出 0.8999999999999999, 而不是人们想象的 0.9。

为什么二进制无法精确表示1/10
其实跟数位表示法有关,比如十进制的情况下:
123,相当于(值*进制^位置,个位0,十位1,百位2)
同理:1/10由二进制表示小数的时候只能够表示能够用1/(2^n)

例如:
0.5能够表示,因为它可以表示成为
0.75也能够表示,因为它可以表示成为
0.875也能够表示,因为它可以表示成为
0.9375也能够表示,因为它可以表示成为
但是0.1不能够精确表示,因为它不能表示成为1/(2^n)的和的形式

扩展:

大数值

如果基本的整数和浮点数精度不能够满足需求, 那么可以使用java.math 包中的两个 很有用的类:Biglnteger 和 BigDecimal 这两个类可以处理包含任意长度数字序列的数值。 Biglnteger类实现了任意精度的整数运算, BigDecimal 实现了任意精度的浮点数运算。

使用静态的 valueOf方法可以将普通的数值转换为大数值:

Biglnteger a = Biglnteger.valueOf(100);
遗憾的是,不能使用人们熟悉的算术运算符(如:+ 和 *) 处理大数值。
而需要使用大数 值类中的 add 和 multiply 方法。

Biglnteger c = a.add(b); // c = a + b
Biglnteger d = c.nultipiy(b.add(Biglnteger.valueOf(2))); // d = c * (b + 2)
注释:

与 C++ 不同, Java 没有提供运算符重载功能。程序员无法重定义 + 和 * 运算 符, 使其应用于
BigInteger 类的 add 和 multiply 运算。Java 语言的设计者确实为字符串的连接重载了 + 运算符,但没有重载其他的运算符,也没有给 Java 程序员在自己的类中重载运算符的机会

Java8的新特性

  • Lambda 表达式 − Lambda 允许把函数作为一个方法的参数(函数作为参数传递到方法中)。
  • 方法引用 − 方法引用提供了非常有用的语法,可以直接引用已有Java类或对象(实例)的方法或构造器。与lambda联合使用,方法引用可以使语言的构造更紧凑简洁,减少冗余代码。
  • 默认方法 − 默认方法就是一个在接口里面有了一个实现的方法。
  • 新工具 − 新的编译工具,如:Nashorn引擎 jjs、 类依赖分析器jdeps。
  • Stream API −新添加的Stream API(java.util.stream) 把真正的函数式编程风格引入到Java中。
  • Date Time API − 加强对日期与时间的处理。
  • Optional 类 − Optional 类已经成为 Java 8 类库的一部分,用来解决空指针异常。
  • Nashorn, JavaScript 引擎 − Java 8提供了一个新的Nashorn javascript引擎,它允许我们在JVM上运行特定的javascript应用。

请你说明符号“==”比较的是什么?

”对比两个对象基于内存引用,如果两个对象的引用完全相同(指向同一个对象)时,“”操作将返回true,否则返回false。“==”如果两边是基本类型,就是比较数值是否相等。

hashcode()方法和equals()方法

hashcode是什么?

hash和hash表是什么?

想要知道这个hashcode,首先得知道hash,通过百度百科看一下:

640?wx_fmt=png

hash是一个函数,该函数中的实现就是一种算法,就是通过一系列的算法来得到一个hash值。这个时候,我们就需要知道另一个东西,hash表,通过hash算法得到的hash值就在这张hash表中,也就是说,hash表就是所有的hash值组成的,有很多种hash函数,也就代表着有很多种算法得到hash值,如上面截图的三种,等会我们就拿第一种来说。

hashcode

有了前面的基础,这里讲解就简单了,hashcode就是通过hash函数得来的,通俗的说,就是通过某一种算法得到的,hashcode就是在hash表中有对应的位置。

每个对象都有hashcode,对象的hashcode怎么得来的呢?

首先一个对象肯定有物理地址,在别的博文中会hashcode说成是代表对象的地址,这里肯定会让读者形成误区,对象的物理地址跟这个hashcode地址不一样,hashcode代表对象的地址说的是对象在hash表中的位置,物理地址说的对象存放在内存中的地址,那么对象如何得到hashcode呢?

在object类中,hashcode方法是通过object对象的内存地址计算出来的

重写后的hashcode方法是根据对象内容进行计算的

所以,hashcode是什么呢?就是在hash表中对应的位置。

这里如果还不是很清楚的话,举个例子,hash表中有 hashcode为1、hashcode为2、(…)3、4、5、6、7、8这样八个位置,有一个对象A,A的物理地址转换为一个整数17(这是假如),就通过直接取余算法,17%8=1,那么A的hashcode就为1,且A就在hash表中1的位置。

肯定会有其他疑问,接着看下面,这里只是举个例子来让你们知道什么是hashcode的意义。

hashcode有什么作用呢?

前面说了这么多关于hash函数,和hashcode是怎么得来的,还有hashcode对应的是hash表中的位置,可能大家就有疑问,为什么hashcode不直接写物理地址呢,还要另外用一张hash表来代表对象的地址?接下来就告诉你hashcode的作用,

1、HashCode的存在主要是为了查找的快捷性,HashCode是用来在散列存储结构中确定对象的存储地址的(后半句说的用hashcode来代表对象就是在hash表中的位置)

为什么hashcode就查找的更快,比如:我们有一个能存放1000个数这样大的内存中,在其中要存放1000个不一样的数字,用最笨的方法,就是存一个数字,就遍历一遍,看有没有相同得数,当存了900个数字,开始存901个数字的时候,就需要跟900个数字进行对比,这样就很麻烦,很是消耗时间,用hashcode来记录对象的位置,来看一下。

hash表中有1、2、3、4、5、6、7、8个位置,存第一个数,hashcode为1,该数就放在hash表中1的位置,存到100个数字,hash表中8个位置会有很多数字了,1中可能有20个数字,存101个数字时,他先查hashcode值对应的位置,假设为1,那么就有20个数字和他的hashcode相同,他只需要跟这20个数字相比较(equals),如果没有一个相同,那么就放在1这个位置,这样比较的次数就少了很多,实际上hash表中有很多位置,这里只是举例只有8个,所以比较的次数会让你觉得也挺多的,实际上,如果hash表很大,那么比较的次数就很少很少了。

通过对原始方法和使用hashcode方法进行对比,我们就知道了hashcode的作用,并且为什么要使用hashcode了

equals方法和hashcode的关系?

通过前面这个例子,大概可以知道,先通过hashcode来比较,如果hashcode相等,那么就用equals方法来比较两个对象是否相等。

用个例子说明:上面说的hash表中的8个位置,就好比8个桶,每个桶里能装很多的对象,对象A通过hash函数算法得到将它放到1号桶中,当然肯定有别的对象也会放到1号桶中,如果对象B也通过算法分到了1号桶,那么它如何识别桶中其他对象是否和它一样呢,这时候就需要equals方法来进行筛选了。

1、如果两个对象equals相等,那么这两个对象的HashCode一定也相同

2、如果两个对象的HashCode相同,不代表两个对象就相同,只能说明这两个对象在散列存储结构中,存放于同一个位置

这两条你们就能够理解了。

为什么equals方法重写的话,建议也一起重写hashcode方法?

如果对象的equals方法被重写,那么对象的HashCode方法也尽量重写

举个例子,其实就明白了这个道理,

比如:有个A类重写了equals方法,但是没有重写hashCode方法,看输出结果,对象a1和对象a2使用equals方法相等,按照上面的hashcode的用法,那么他们两个的hashcode肯定相等,但是这里由于没重写hashcode方法,所使用的hashcode来自于object类,hashcode来自内存地址的计算,所以他们两个hashcode并不一样,所以,我们在重写了equals方法后,尽量也重写了hashcode方法,通过一定的算法,使他们在equals相等时,也会有相同的hashcode值。

**equals方法:**其实跟我上面写的那个例子是一样的原理,所以通过源码又知道了String的equals方法验证的是两个字符串的值是否一样。还有Double类也重写了这些方法。很多类有比较这类的,都重写了这两个方法,因为在所有类的父类Object中。equals的功能就是 “”号的功能。你们还可以比较String对象的equals和的区别啦。这里不再说明。

Object若不重写hashCode()的话,hashCode()是如何计算得到的

Object的hashCode方法是本地方法,

也就是说是使用C或者C++语言实现的,

该方法直接返回对象的内存地址。

img

(java.lang.Object 源码中的hashCode方法,可以看到 native 修饰)

equals()与‘==’的区别

起equals方法,我们都知道是超类Object中的一个基本方法,用于检测一个对象是否与另外一个对象相等。而在Object类中这个方法实际上是判断两个对象是否具有相同的引用,如果有,它们就一定相等。其源码如下:

public boolean equals(Object obj) {   return (this == obj);     }

实际上我们知道所有的对象都拥有标识(内存地址)和状态(数据),同时“==”比较两个对象的的内存地址,所以说 Object 的 equals() 方法是比较两个对象的内存地址是否相等,即若 object1.equals(object2) 为 true,则表示 equals1 和 equals2 实际上是引用同一个对象。

或许这是我们面试时更容易碰到的问题”equals方法与‘’运算符有什么区别?“,并且常常我们都会胸有成竹地回答:“equals比较的是对象的内容,而‘’比较的是对象的地址。”。但是从前面我们可以知道equals方法在Object中的实现也是间接使用了‘==’运算符进行比较的,所以从严格意义上来说,我们前面的回答并不完全正确。

默认情况下也就是从超类Object继承而来的equals方法与‘==’是完全等价的,比较的都是对象的内存地址,但我们可以重写equals方法,使其按照我们的需求的方式进行比较,如String类重写了equals方法,使其比较的是字符的序列,而不再是内存地址。

java中POJO类为什么要实现序列化

序列化就是一种用来处理对象流的机制,所谓对象流也就是将对象的内容进行流化。可以对流化后的对象进行读写操作,也可将流化后的对象传输于网络之间。序列化是为了解决在对对象流进行读写操作时所引发的问题。序列化的实现:将需要被序列化的类实现Serializable接口,该接口没有需要实现的方法,implements Serializable只是为了标注该对象是可被序列化的,然后使用一个输出流(如:FileOutputStream)来构造一个ObjectOutputStream(对象流)对象,接着,使用ObjectOutputStream对象的writeObject(Object obj)方法就可以将参数为obj的对象写出(即保存其状态),要恢复的话则用输入流。

序列化:序列化是将对象转换为容易传输的格式的过程。例如,可以序列化一个对象,然后使用 HTTP 通过 Internet 在客户端和服务器之间传输该对象。在另一端,反序列化将从该流重新构造对象。

是对象永久化的一种机制。

确切的说应该是对象的序列化,一般程序在运行时,产生对象,这些对象随着程序的停止运行而消失,但如果我们想把某些对象(因为是对象,所以有各自不同的特性)保存下来,在程序终止运行后,这些对象仍然存在,可以在程序再次运行时读取这些对象的值,或者在其他程序中利用这些保存下来的对象。这种情况下就要用到对象的序列化。

只有序列化的对象才可以存储在存储设备上。为了对象的序列化而需要继承的接口也只是一个象征性的接口而已,也就是说继承这个接口说明这个对象可以被序列化了,没有其他的目的。之所以需要对象序列化,是因为有时候对象需要在网络上传输,传输的时候需要这种序列化处理,从服务器硬盘上把序列化的对象取出,然后通过网络传到客户端,再由客户端把序列化的对象读入内存,执行相应的处理。

对象序列化是java的一个特征,通过该特征可以将对象写作一组字节码,当在其他位置读到这些字节码时,可以依此创建一个新的对象,而且新对象的状态与原对象完全相同。为了实现对象序列化,要求必须能够访问类的私有变量,从而保证对象状态能够正确的得以保存和恢复。相应的,对象序列化API能够在对象重建时,将这些值还原给私有的数据成员。这是对java语言访问权限的挑战。通常用在服务器客户端的对象交换上面,另外就是在本机的存储。

对象序列化的最主要的用处就是在传递,和保存对象(object)的时候,保证对象的完整性和可传递性。譬如通过网络传输,或者把一个对象保存成一个文件的时候,要实现序列化接口

Java中final关键字的用法总结

1、final修饰类

**被final修饰的类不能被继承,因此final类的成员方法也不能被覆写,被final关键字修饰的类没有子类,因此类的实现细节也无法改变,无法被扩展。final类中的所有成员方法都会被隐式地指定为final方法,final类中的成员变量可以根据需要设为final。

2、final修饰方法

一个类中的方法如果被final关键字修饰,则其子类无法覆写该方法,只能被子类继承。如果父类中的某个方法不想被其子类所覆写,可将该方法定义为final类型,另外,父类中的私有方法(即被private关键字修饰的方法)也不允许被子类所覆写,所以父类中被private关键字修饰的方法默认为final类型,被final关键字修饰的方法有如下特质:

该方法被锁定,可以防止任何继承类修改它的意义和实现;
高效。编译器在遇到调用final方法时候会转入内嵌机制,大大提高执行效率;
另外,使用final修饰的静态方法在其所属类的子类中不能被隐藏(hide);

3、final修饰变量

final这个关键字的含义是“这是无法改变的”或者“终态的”;
那么为什么要阻止改变呢?
java语言的发明者可能由于两个目的而阻止改变:
A.效率问题:
jdk中的某些类的某些方法,是不允许被用户覆盖的,设计者可能认为,所用方法已经是最好的方法,
用户私自覆盖,或是由于疏忽而覆盖,就会影响JVM或是系统的系能;
B.设计所需:
众所周知,有些情况必须使用final关键字,比如方法中的匿名内部类的参数传递。
有人说final变量在赋值后就不可变,
那么这个不变到底指的是什么呢?
这个不变指的是引用,是地址,而所引用的对象的内容仍然是可变的。
就是说,这个final变量永远指向某个对象,是一个常量指针,而不是指向常量的指针。
(1)被final关键字修饰的基本数据类型,则其数值一旦在初始化之后便不能更改;
(2)如果是引用类型的变量,则在对其初始化之后便不能再让其指向另一个对象,但该引用所指向的对象的内容是可以发生变化的。原因为:引用数据类型存储的引用对象在堆内存中的地址,final修饰引用类型之后,要求该引用指向的堆内存空间(或者说该引用存储的堆内存地址)不能改变。
当用final修饰类的非静态成员变量时,成员变量的初始化有两种方式:
(3)在声明时进行初始化
(4)在声明变量时可以不进行初始化,该变量被称为blank final,即final空白,但是要在这个变量所在的类的所有的构造方法中对这个变量赋初值。
当用final修饰类的静态成员变量时,静态成员变量的初始化方式也有两种:
(5)在声明时进行初始化
(6)在静态初始化块中进行初始化
当用final修饰接口的静态变量时,其初始化方式只有一种:
(7)在声明时进行初始化
当用final修饰局部成员变量时,只需要保证在使用之前被初始化赋值即可。
总之,被final关键字修饰的变量在声明时可以不进行初始化,但必须保证该变量在使用之前被初始化,一旦被初始化赋值之后,就不能再被重新赋值了。

4、final修饰参数

当方法参数为final类型时,你可以读取使用该参数,但是无法改变该参数的值。该变量在方法调用时被创建,同时初始化为对应实参值,在方法体(body)执行结束前,其值不能改变。用final关键字修饰的基本数据类型的参数,其参数值无法改变,用final关键字修饰的引用数据类型,无法是该引用指向其他对象或再次赋值为null,但可以使用该引用改变引用对象的内容。final用于修饰参数的目的并非防止在调用的方法内部对参数的操作改变方法外部对应变量的值,只是防止在该方法内对该参数进行重新赋值操作,影响该参数传递时的初始值。而且将方法中的参数用final关键字修饰并不能防止从方法外部所传递的参数的值的内容不被改变。具体可参考final修饰引用数据类型的解释

synchronized 和Lock区别

什么是线程同步?

即当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作, 其他线程才能对该内存地址进行操作,而其他线程又处于等待状态.

Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:

  1. 普通同步方法,锁是当前实例对象
  2. 静态同步方法,锁是当前类的class对象
  3. 同步方法块,锁是括号里面的对象

synchronized和lock的区别

这里写图片描述
区别如下:

  1. 来源:
    lock是一个接口,而synchronized是java的一个关键字,synchronized是内置的语言实现;

  2. 异常是否释放锁:
    synchronized在发生异常时候会自动释放占有的锁,因此不会出现死锁;而lock发生异常时候,不会主动释放占有的锁,必须手动unlock来释放锁,可能引起死锁的发生。(所以最好将同步代码块用try catch包起来,finally中写入unlock,避免死锁的发生。)

  3. 是否响应中断
    lock等待锁过程中可以用interrupt来中断等待,而synchronized只能等待锁的释放,不能响应中断;

  4. 是否知道获取锁
    Lock可以通过trylock来知道有没有获取锁,而synchronized不能;

  5. Lock可以提高多个线程进行读操作的效率。(可以通过readwritelock实现读写分离)

  6. 在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时Lock的性能要远远优于synchronized。所以说,在具体使用时要根据适当情况选择。

  7. synchronized使用Object对象本身的wait 、notify、notifyAll调度机制,而Lock可以使用Condition进行线程之间的调度,

    synchronized和lock的用法区别

    synchronized:在需要同步的对象中加入此控制,synchronized可以加在方法上,也可以加在特定代码块中,括号中表示需要锁的对象。

    lock:一般使用ReentrantLock类做为锁。在加锁和解锁处需要通过lock()和unlock()显示指出。所以一般会在finally块中写unlock()以防死锁。

    synchronized和lock性能区别

    synchronized是托管给JVM执行的,
    而lock是java写的控制锁的代码。

    在Java1.5中,synchronize是性能低效的。因为这是一个重量级操作,需要调用操作接口,导致有可能加锁消耗的系统时间比加锁以外的操作还多。相比之下使用Java提供的Lock对象,性能更高一些。

    但是到了Java1.6,发生了变化。synchronize在语义上很清晰,可以进行很多优化,有适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等等。导致在Java1.6上synchronize的性能并不比Lock差。官方也表示,他们也更支持synchronize,在未来的版本中还有优化余地。

    2种机制的具体区别:
    **synchronized原始采用的是CPU悲观锁机制,即线程获得的是独占锁。**独占锁意味着其他线程只能依靠阻塞来等待线程释放锁。而在CPU转换线程阻塞时会引起线程上下文切换,当有很多线程竞争锁的时候,会引起CPU频繁的上下文切换导致效率很低。

    而Lock用的是乐观锁方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。乐观锁实现的机制就是CAS操作(Compare and Swap)。我们可以进一步研究ReentrantLock的源代码,会发现其中比较重要的获得锁的一个方法是compareAndSetState。这里其实就是调用的CPU提供的特殊指令。

    现代的CPU提供了指令,可以自动更新共享数据,而且能够检测到其他线程的干扰,而 compareAndSet() 就用这些代替了锁定。这个算法称作非阻塞算法,意思是一个线程的失败或者挂起不应该影响其他线程的失败或挂起的算法。

    synchronized和lock用途区别

    synchronized原语和ReentrantLock在一般情况下没有什么区别,但是在非常复杂的同步应用中,请考虑使用ReentrantLock,特别是遇到下面2种需求的时候。

    1.某个线程在等待一个锁的控制权的这段时间需要中断
    2.需要分开处理一些wait-notify,ReentrantLock里面的Condition应用,能够控制notify哪个线程
    3.具有公平锁功能,每个到来的线程都将排队等候

    下面细细道来……

    先说第一种情况,ReentrantLock的lock机制有2种,忽略中断锁和响应中断锁,这给我们带来了很大的灵活性。比如:如果A、B 2个线程去竞争锁,A线程得到了锁,B线程等待,但是A线程这个时候实在有太多事情要处理,就是一直不返回,B线程可能就会等不及了,想中断自己,不再等待这个锁了,转而处理其他事情。这个时候ReentrantLock就提供了2种机制:可中断/可不中断
    第一,B线程中断自己(或者别的线程中断它),但是ReentrantLock不去响应,继续让B线程等待,你再怎么中断,我全当耳边风(synchronized原语就是如此);
    第二,B线程中断自己(或者别的线程中断它),ReentrantLock处理了这个中断,并且不再等待这个锁的到来,完全放弃。

乐观锁和悲观锁

并发控制

当程序中可能出现并发的情况时,就需要保证在并发情况下数据的准确性,以此确保当前用户和其他用户一起操作时,所得到的结果和他单独操作时的结果是一样的。这种手段就叫做并发控制。并发控制的目的是保证一个用户的工作不会对另一个用户的工作产生不合理的影响。

没有做好并发控制,就可能导致脏读、幻读和不可重复读等问题。

img

常说的并发控制,一般都和数据库管理系统(DBMS)有关。在 DBMS 中的并发控制的任务,是确保在多个事务同时存取数据库中同一数据时,不破坏事务的隔离性、一致性和数据库的统一性。

实现并发控制的主要手段大致可以分为乐观并发控制和悲观并发控制两种。
首先要明确:无论是悲观锁还是乐观锁,都是人们定义出来的概念,可以认为是一种思想。其实不仅仅是关系型数据库系统中有乐观锁和悲观锁的概念,像 hibernate、tair、memcache 等都有类似的概念。所以,不应该拿乐观锁、悲观锁和其他的数据库锁等进行对比。乐观锁比较适用于读多写少的情况(多读场景),悲观锁比较适用于写多读少的情况(多写场景)。

悲观锁(Pessimistic Lock)

1️⃣理解
当要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。这种借助数据库锁机制,在修改数据之前先锁定,再修改的方式被称之为悲观并发控制【Pessimistic Concurrency Control,缩写“PCC”,又名“悲观锁”】。

悲观锁,正如其名,具有强烈的独占和排他特性。它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度。因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。

img

之所以叫做悲观锁,是因为这是一种对数据的修改持有悲观态度的并发控制方式。总是假设最坏的情况,每次读取数据的时候都默认其他线程会更改数据,因此需要进行加锁操作,当其他线程想要访问数据时,都需要阻塞挂起。悲观锁的实现:

  1. 传统的关系型数据库使用这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
  2. Java 里面的同步 synchronized 关键字的实现。
  3. ReentrantLock的底层是CAS但独占线程用的是悲观锁方式。

2️⃣悲观锁主要分为共享锁和排他锁

  • 共享锁【shared locks】又称为读锁,简称S锁。顾名思义,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。
  • 排他锁【exclusive locks】又称为写锁,简称X锁。顾名思义,排他锁就是不能与其他锁并存,如果一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁,但是获取排他锁的事务是可以对数据行读取和修改。

3️⃣说明
悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会。另外还会降低并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数据。

乐观锁(Optimistic Locking)

1️⃣理解
乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则返回给用户错误的信息,让用户决定如何去做。乐观锁适用于读操作多的场景,这样可以提高程序的吞吐量。

img

乐观锁机制采取了更加宽松的加锁机制。乐观锁是相对悲观锁而言,也是为了避免数据库幻读、业务处理时间过长等原因引起数据处理错误的一种机制,但乐观锁不会刻意使用数据库本身的锁机制,而是依据数据本身来保证数据的正确性。乐观锁的实现:

  1. CAS 实现:Java 中java.util.concurrent.atomic包下面的原子变量使用了乐观锁的一种 CAS 实现方式。
  2. 版本号控制:一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会+1。当线程A要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值与当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

2️⃣说明
乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁。

具体实现

1️⃣悲观锁实现方式
悲观锁的实现,往往依靠数据库提供的锁机制。在数据库中,悲观锁的流程如下:

  1. 在对记录进行修改前,先尝试为该记录加上排他锁(exclusive locks)。
  2. 如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。具体响应方式由开发者根据实际需要决定。
  3. 如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。
  4. 期间如果有其他对该记录做修改或加排他锁的操作,都会等待解锁或直接抛出异常。

拿比较常用的 MySql Innodb 引擎举例,来说明一下在 SQL 中如何使用悲观锁。

要使用悲观锁,必须关闭 MySQL 数据库的自动提交属性。因为 MySQL 默认使用 autocommit 模式,也就是说,当执行一个更新操作后,MySQL 会立刻将结果进行提交。(sql语句:set autocommit=0)

以电商下单扣减库存的过程说明一下悲观锁的使用:

img

以上,在对id = 1的记录修改前,先通过 for update 的方式进行加锁,然后再进行修改。这就是比较典型的悲观锁策略。

如果以上修改库存的代码发生并发,同一时间只有一个线程可以开启事务并获得id=1的锁,其它的事务必须等本次事务提交之后才能执行。这样可以保证当前的数据不会被其它事务修改。

上面提到,使用 select…for update 会把数据给锁住,不过需要注意一些锁的级别,MySQL InnoDB 默认行级锁。行级锁都是基于索引的,如果一条 SQL 语句用不到索引是不会使用行级锁的,会使用表级锁把整张表锁住,这点需要注意。

2️⃣乐观锁实现方式乐观锁不需要借助数据库的锁机制。

主要就是两个步骤:冲突检测和数据更新。比较典型的就是 CAS (Compare and Swap)。

CAS 即比较并交换。是解决多线程并行情况下使用锁造成性能损耗的一种机制,CAS 操作包含三个操作数——内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值(V)与预期原值(A)相匹配,那么处理器会自动将该位置值更新为新值(B)。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。CAS 有效地说明了“我认为位置(V)应该包含值(A)。如果包含该值,则将新值(B)放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可”。Java 中,sun.misc.Unsafe 类提供了硬件级别的原子操作来实现这个 CAS。java.util.concurrent包下大量的类都使用了这个 Unsafe.java 类的 CAS 操作。

当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。比如前面的扣减库存问题,通过乐观锁可以实现如下:

img

乐观锁使用

在更新之前,先查询一下库存表中当前库存数(quantity),然后在做 update 的时候,以库存数作为一个修改条件。当提交更新的时候,判断数据库表对应记录的当前库存数与第一次取出来的库存数进行比对,如果数据库表当前库存数与第一次取出来的库存数相等,则予以更新,否则认为是过期数据。

以上更新语句存在一个比较严重的问题,即ABA问题img

  1. 比如说线程一从数据库中取出库存数 3,这时候线程二也从数据库中取出库存数 3,并且线程二进行了一些操作变成了 2。
  2. 然后线程二又将库存数变成 3,这时候线程一进行 CAS 操作发现数据库中仍然是 3,然后线程一操作成功。
  3. 尽管线程一的 CAS 操作成功,但是不代表这个过程就是没有问题的。

一个比较好的解决办法,就是通过一个单独的可以顺序递增的 version 字段。优化如下: img

乐观锁每次在执行数据修改操作时,都会带上一个版本号,一旦版本号和数据的版本号一致就可以执行修改操作并对版本号执行 +1 操作,否则就执行失败。因为每次操作的版本号都会随之增加,所以不会出现 ABA 问题。除了 version 以外,还可以使用时间戳,因为时间戳天然具有顺序递增性。

以上 SQL 其实还是有一定的问题的,就是一旦遇上高并发的时候,就只有一个线程可以修改成功,那么就会存在大量的失败。对于像淘宝这样的电商网站,高并发是常有的事,总让用户感知到失败显然是不合理的。所以,还是要想办法减少乐观锁的粒度。一个比较好的建议,就是减小乐观锁力度,最大程度的提升吞吐率,提高并发能力!如下:

img

以上 SQL 语句中,如果用户下单数为 1,则通过quantity - 1 > 0的方式进行乐观锁控制。在执行过程中,会在一次原子操作中查询一遍 quantity 的值,并将其扣减掉 1。

高并发环境下锁粒度把控是一门重要的学问。选择一个好的锁,在保证数据安全的情况下,可以大大提升吞吐率,进而提升性能。

理解 CAS 底层

img

流程图

假如说有 3 个线程并发的要修改一个 AtomicInteger 的值,底层机制如下:

  1. 首先,每个线程都会先获取当前的值,接着走一个原子的 CAS 操作。原子的意思就是这个 CAS 操作一定是自己完整执行完的,不会被别人打断。
  2. 然后 CAS 操作里,会比较一下,现在的值是不是刚才获取到的那个值。如果是,说明没人改过这个值,然后设置成累加 1 之后的一个值。
  3. 同理,如果有人在执行 CAS 的时候,发现之前获取的值跟当前的值不一样,会导致 CAS 失败。失败之后,进入一个无限循环,再次获取值,接着执行 CAS 操作。

CAS 典型应用

java.util.concurrent.atomic包下的类大多是使用 CAS 操作来实现的,比如 AtomicInteger、AtomicBoolean、AtomicLong。一般在竞争不是特别激烈的时候,使用该包下的原子操作性能比使用 synchronized 关键字的方式高效的多(查看 getAndSet(),可知如果资源竞争十分激烈的话,这个 for 循环可能会持续很久都不能成功跳出。不过这种情况可能需要考虑降低资源竞争才是)。
在较多的场景都可能会使用到这些原子类操作。一个典型应用就是计数了,在多线程的情况下需要考虑线程安全问题。

1️⃣支持计数功能 Demo 实现

public class Increment {
    private int count = 0;
    public void add() {
        count++;
    }
}

在并发环境下对 count 进行自增运算是不安全的,为什么不安全以及如何解决这个问题呢?

2️⃣为什么并发环境下的 count 自增操作不安全?因为 count++ 不是原子操作,而是三个原子操作的组合:

  1. 读取内存中的 count 值赋值给局部变量 temp;
  2. 执行 temp+1 操作;
  3. 将 temp 赋值给 count。

所以如果两个线程同时执行 count++ 的话,不能保证线程一按顺序执行完上述三步后线程二才开始执行。

3️⃣并发环境下 count++ 不安全问题的解决方案

方案①:synchronized 加锁。同一时间只有一个线程能加锁,其他线程需要等待锁,这样就不会出现 count 计数不准确的问题了:

public class Increment {
    private int count = 0;
    public synchronized void add() {
        count++;
    }
}

但是引入 synchronized 会造成多个线程排队的问题,相当于让各个线程串行化了,一个接一个的排队、加锁、处理数据、释放锁,下一个再进来。同一时间只有一个线程执行,这样的锁有点“重量级”了。这类似于悲观锁的实现,需要获取这个资源,就给它加锁,别的线程都无法访问该资源,直到操作完后释放对该资源的锁。虽然随着 Java 版本更新,也对 synchronized 做了很多优化,但是处理这种简单的累加操作,仍然显得“太重了”。

方案②:Atomic 原子类。对于 count++ 的操作,完全可以换一种做法,Java 并发包下面提供了一系列的 Atomic 原子类,比如说 AtomicInteger:

//import java.util.concurrent.atomic.AtomicInteger;
public static void main(String[] args) {
    public static AtomicInteger count = new AtomicInteger(0);
    public static void increase() {
        count.incrementAndGet();
    }
}

多个线程可以并发的执行 AtomicInteger 的 incrementAndGet(),意思就是把 count 的值累加 1,接着返回累加后最新的值。实际上,Atomic 原子类底层用的不是传统意义的锁机制,而是无锁化的 CAS 机制,通过 CAS 机制保证多线程修改一个数值的安全性。

CAS 性能优化

从流程图可以看出来,大量的线程同时并发修改一个 AtomicInteger,可能有很多线程会不停的自旋,进入一个无限重复的循环中。这些线程不停地获取值,然后发起 CAS 操作,但是发现这个值被别人改过了,于是再次进入下一个循环,获取值,发起 CAS 操作又失败了,再次进入下一个循环。在大量线程高并发更新 AtomicInteger 的时候,这种问题可能会比较明显,导致大量线程空循环,自旋转,性能和效率都不是特别好。那么如何优化呢?

Java8 有一个新的类,LongAdder,它就是尝试使用分段 CAS 以及自动分段迁移的方式来大幅度提升多线程高并发执行 CAS 操作的性能,这个类具体是如何优化性能的呢?如图:

img

LongAdder

LongAdder 核心思想就是热点分离,这一点和 ConcurrentHashMap 的设计思想相似。就是将 value 值分离成一个数组,当多线程访问时,通过 hash 算法映射到其中的一个数字进行计数。而最终的结果,就是这些数组的求和累加。这样一来,就减小了锁的粒度。

LongAddr 的兄弟类如下:

img

LongAdder兄弟类

如何选择

在乐观锁与悲观锁的选择上面,主要看下两者的区别以及适用场景就可以了。
1️⃣响应效率:如果需要非常高的响应速度,建议采用乐观锁方案,成功就执行,不成功就失败,不需要等待其他并发去释放锁。乐观锁并未真正加锁,效率高。一旦锁的粒度掌握不好,更新失败的概率就会比较高,容易发生业务失败。
2️⃣冲突频率:如果冲突频率非常高,建议采用悲观锁,保证成功率。冲突频率大,选择乐观锁会需要多次重试才能成功,代价比较大。
3️⃣重试代价:如果重试代价大,建议采用悲观锁。悲观锁依赖数据库锁,效率低。更新失败的概率比较低。
4️⃣乐观锁如果有人在你之前更新了,你的更新应当是被拒绝的,可以让用户从新操作。悲观锁则会等待前一个更新完成。这也是区别。

volatile关键字

volatile关键字的两层语义

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

2)禁止进行指令重排序。

java虚拟机会在处理程序时将没有依赖关系的几条语句进行重排序以提高执行效率,执行过程重排序,但执行结果与原程序相同。

volatile不能保证语句的原子性操作

使用volatile关键字的场景

synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:

1)对变量的写操作不依赖于当前值

2)该变量没有包含在具有其他变量的不变式中

实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。

事实上,我的理解就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。

volatile的原理和实现机制

前面讲述了源于volatile关键字的一些使用,下面我们来探讨一下volatile到底如何保证可见性和禁止指令重排序的。

下面这段话摘自《深入理解Java虚拟机》:

“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”

lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

2)它会强制将对缓存的修改操作立即写入主存;

3)如果是写操作,它会导致其他CPU中对应的缓存行无效

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值