开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

《面试补习》- Java锁知识大梳理

发表于 2020-03-09

面试补习系列:

  • 《面试补习》- JVM知识点大梳理
  • 《面试补习》- Java锁知识大梳理

一、锁的分类

1、乐观锁和悲观锁

乐观锁就是乐观的认为不会发生冲突,用cas和版本号实现
悲观锁就是认为一定会发生冲突,对操作上锁

1.悲观锁

悲观锁,总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。

1
2
复制代码传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
再比如 Java 里面的同步原语 synchronized 关键字的实现也是悲观锁。

适用场景:

比较适合写入操作比较频繁的场景,如果出现大量的读取操作,每次读取的时候都会进行加锁,这样会增加大量的锁的开销,降低了系统的吞吐量。

实现方式: synchronized 和Lock

2.乐观锁

每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制

1
2
3
4
5
复制代码ABA问题(JDK1.5之后已有解决方案):CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。

循环时间长开销大:CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。

只能保证一个共享变量的原子操作(JDK1.5之后已有解决方案):对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。

适用场景:

比较适合读取操作比较频繁的场景,如果出现大量的写入操作,数据发生冲突的可能性就会增大,为了保证数据的一致性,应用层需要不断的重新获取数据,这样会增加大量的查询操作,降低了系统的吞吐量。

实现方式:

1、使用版本标识来确定读到的数据与提交时的数据是否一致。提交后修改版本标识,不一致时可以采取丢弃和再次尝试的策略。

2、Java 中的 Compare and Swap 即 CAS ,当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

3、在 Java 中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。

2、公平锁/非公平锁

公平锁:

1
复制代码指多个线程按照申请锁的顺序来获取锁。

非公平锁:

1
2
复制代码指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。
有可能,会造成优先级反转或者饥饿现象。

拓展线程饥饿:

1
2
3
4
5
复制代码一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行的状态
导致无法获取的原因:
线程优先级较低,没办法获取cpu时间
其他线程总是能在它之前持续地对该同步块进行访问。
线程在等待一个本身也处于永久等待完成的对象(比如调用这个对象的 wait 方法),因为其他线程总是被持续地获得唤醒。

实现方式: ReenTrantLock(公平/非公平)

对于Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。

对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS(AbstractQueuedSynchronizer)的来实现线程调度,所以并没有任何办法使其变成公平锁。

3、可重入锁

如果一个线程获得过该锁,可以再次获得,主要是用途就是在递归方面,还有就是防止死锁,比如在一个同步方法块中调用了另一个相同锁对象的同步方法块

实现方式: synchronized、ReentrantLock

4、独享锁/共享锁

1
2
复制代码独享锁是指该锁一次只能被一个线程所持有。
共享锁是指该锁可被多个线程所持有。

实现方式:
独享锁: ReentrantLock 和 synchronized
贡献锁: ReadWriteLock

拓展:

互斥锁/读写锁 就是对上面的一种具体实现:

1
2
复制代码互斥锁:在Java中的具体实现就是ReentrantLock,synchronized
读写锁:在Java中的具体实现就是ReadWriteLock

对于Java ReentrantLock而言,其是独享锁。但是对于Lock的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。对于Synchronized而言,当然是独享锁

5、偏向锁/轻量级锁/重量级锁

基于 jdk 1.6 以上

偏向锁指的是当前只有这个线程获得,没有发生争抢,此时将方法头的markword设置成0,然后每次过来都cas一下就好,不用重复的获取锁.指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价

轻量级锁:在偏向锁的基础上,有线程来争抢,此时膨胀为轻量级锁,多个线程获取锁时用cas自旋获取,而不是阻塞状态

重量级锁:轻量级锁自旋一定次数后,膨胀为重量级锁,其他线程阻塞,当获取锁线程释放锁后唤醒其他线程。(线程阻塞和唤醒比上下文切换的时间影响大的多,涉及到用户态和内核态的切换)

实现方式: synchronized

6、分段锁

在1.7的concurrenthashmap中有分段锁的实现,具体为默认16个的segement数组,其中segement继承自reentranklock,每个线程过来获取一个锁,然后操作这个锁下连着的map。

实现方式:

1
2
3
4
5
6
7
8
9
10
复制代码我们以ConcurrentHashMap来说一下分段锁的含义以及设计思想,ConcurrentHashMap中的分段锁称为Segment,
它即类似于HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;
同时又是一个ReentrantLock(Segment继承了ReentrantLock)。

当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,
然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。

但是,在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。

分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。

二、锁的底层实现

1、Synchronized

synchronized 关键字通过一对字节码指令 monitorenter/monitorexit 实现

前置知识:

1
2
3
4
5
6
7
8
9
复制代码对象头:
Hotspot 虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。其中:

Klass Point 是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

Mark Word 用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键,所以下面将重点阐述 Mark Word 。

Monitor:
每一个 Java 对象都有成为Monitor 的潜质,因为在 Java 的设计中 ,每一个 Java 对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者 Monitor 锁

对象头结构:

Monitor数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
参考: https://blog.csdn.net/javazejian/article/details/72828483

ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1.

若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)

1
复制代码这里比较复杂,但是建议仔细阅读,便于后续分析的时候理解

1.1、字节码实现

同步代码块:
1
2
3
4
5
6
7
复制代码public class SynchronizedTest {

public void test2() {
synchronized(this) {
}
}
}

synchronized关键字基于上述两个指令实现了锁的获取和释放过程:

monitorenter指令插入到同步代码块的开始位置,

monitorexit 指令插入到同步代码块的结束位置.

线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 Monitor 所有权,即尝试获取对象的锁。

1
复制代码当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。
同步方法:
1
2
3
4
5
复制代码synchronized 方法则会被翻译成普通的方法调用和返回指令如:
invokevirtual、areturn 指令,在 JVM 字节码层面并没有任何特别的指令来实现被synchronized 修饰的方法,
而是在 Class 文件的方法表中将该方法的 access_flags 字段中的 synchronized 标志位置设置为 1,
表示该方法是同步方法,并使用调用该方法的对象或该方法所属的 Class
在 JVM 的内部对象表示 Klass 作为锁对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码 //省略没必要的字节码
//==================syncTask方法======================
public synchronized void syncTask();
descriptor: ()V
//方法标识ACC_PUBLIC代表public修饰,ACC_SYNCHRONIZED指明该方法为同步方法
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #2 // Field i:I
5: iconst_1
6: iadd
7: putfield #2 // Field i:I
10: return
LineNumberTable:
line 12: 0
line 13: 10
}
SourceFile: "SyncMethod.java"

以下部分参考: JVM源码分析之synchronized实现

1.2、偏向锁获取

1
2
3
4
5
6
7
8
复制代码1、获取对象头的Mark Word;
2、判断mark是否为可偏向状态,即mark的偏向锁标志位为 1,锁标志位为 01;
3、判断mark中JavaThread的状态:如果为空,则进入步骤(4);如果指向当前线程,
则执行同步代码块;如果指向其它线程,进入步骤(5);
4、通过CAS原子指令设置mark中JavaThread为当前线程ID,
如果执行CAS成功,则执行同步代码块,否则进入步骤(5);
5、如果执行CAS失败,表示当前存在多个线程竞争锁,当达到全局安全点(safepoint),
获得偏向锁的线程被挂起,撤销偏向锁,并升级为轻量级,升级完成后被阻塞在安全点的线程继续执行同步代码块;

在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。

注意 JVM 提供了关闭偏向锁的机制, JVM 启动命令指定如下参数即可

1
复制代码-XX:-UseBiasedLocking

偏向锁的撤销:

1
2
3
4
5
6
复制代码偏向锁的 撤销(revoke) 是一个很特殊的操作, 为了执行撤销操作, 需要等待全局安全点(Safe Point), 
此时间点所有的工作线程都停止了字节码的执行。

偏向锁这个机制很特殊, 别的锁在执行完同步代码块后, 都会有释放锁的操作, 而偏向锁并没有直观意义上的“释放锁”操作。

引入一个概念 epoch, 其本质是一个时间戳 , 代表了偏向锁的有效性

1.3、轻量级锁

在多线程交替执行同步块的情况下,尽量避免重量级锁引起的性能消耗,但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁。

1
2
3
4
5
6
复制代码1、获取对象的markOop数据mark;
2、判断mark是否为无锁状态:mark的偏向锁标志位为 0,锁标志位为 01;
3、如果mark处于无锁状态,则进入步骤(4),否则执行步骤(6);
4、把mark保存到BasicLock对象的_displaced_header字段;
5、通过CAS尝试将Mark Word更新为指向BasicLock对象的指针,如果更新成功,表示竞争到锁,则执行同步代码,否则执行步骤(6);
6、如果当前mark处于加锁状态,且mark中的ptr指针指向当前线程的栈帧,则执行同步代码,否则说明有多个线程竞争轻量级锁,轻量级锁需要膨胀升级为重量级锁;

1.4、重量级锁

重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

锁膨胀过程:

1
2
3
4
5
6
7
复制代码1、整个膨胀过程在自旋下完成;
2、mark->has_monitor()方法判断当前是否为重量级锁,即Mark Word的锁标识位为 10,如果当前状态为重量级锁,执行步骤(3),否则执行步骤(4);
3、mark->monitor()方法获取指向ObjectMonitor的指针,并返回,说明膨胀过程已经完成;
4、如果当前锁处于膨胀中,说明该锁正在被其它线程执行膨胀操作,则当前线程就进行自旋等待锁膨胀完成,这里需要注意一点,
虽然是自旋操作,但不会一直占用cpu资源,每隔一段时间会通过os::NakedYield方法放弃cpu资源,或通过park方法挂起;
如果其他线程完成锁的膨胀操作,则退出自旋并返回;
5、如果当前是轻量级锁状态,即锁标识位为 00

Monitor 竞争:

1
2
3
4
5
复制代码1、通过CAS尝试把monitor的_owner字段设置为当前线程;
2、如果设置之前的_owner指向当前线程,说明当前线程再次进入monitor,即重入锁,执行_recursions ++ ,记录重入的次数;
3、如果之前的_owner指向的地址在当前线程中,这种描述有点拗口,换一种说法:之前_owner指向的BasicLock在当前线程栈上,
说明当前线程是第一次进入该monitor,设置_recursions为1,_owner为当前线程,该线程成功获得锁并返回;
4、如果获取锁失败,则等待锁的释放;

其本质就是通过CAS设置monitor的_owner字段为当前线程,如果CAS成功,则表示该线程获取了锁,跳出自旋操作,执行同步代码,否则继续被挂起;

Monitor 释放:

当某个持有锁的线程执行完同步代码块时,会进行锁的释放,给其它线程机会执行同步代码,在HotSpot中,通过退出monitor的方式实现锁的释放,并通知被阻塞的线程.

1.5、锁优化内容

锁消除:

1
2
3
4
复制代码消除锁是虚拟机另外一种锁的优化,这种优化更彻底,
Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),
通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,
通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间

锁粗化:

1
复制代码将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

自旋锁:

1
2
3
4
5
复制代码线程的阻塞和唤醒,需要 CPU 从用户态转为核心态。频繁的阻塞和唤醒对 CPU 来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。
同时,我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间。为了这一段很短的时间,频繁地阻塞和唤醒线程是非常不值得的

适应性自旋锁:
自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定

锁升级:

2、ReetrantLock

2.1、Lock

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码   //加锁
void lock();

//解锁
void unlock();

//可中断获取锁,与lock()不同之处在于可响应中断操作,即在获
//取锁的过程中可中断,注意synchronized在获取锁时是不可中断的
void lockInterruptibly() throws InterruptedException;

//尝试非阻塞获取锁,调用该方法后立即返回结果,如果能够获取则返回true,否则返回false
boolean tryLock();

//根据传入的时间段获取锁,在指定时间内没有获取锁则返回false,如果在指定时间内当前线程未被中并断获取到锁则返回true
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

//获取等待通知组件,该组件与当前锁绑定,当前线程只有获得了锁
//才能调用该组件的wait()方法,而调用后,当前线程将释放锁。
Condition newCondition();

在Java 1.5中,官方在concurrent并发包(J.U.C)中加入了Lock接口,该接口中提供了lock()方法和unLock()方法对显式加锁和显式释放锁操作进行支持.

Lock 锁提供的优势:

1
2
3
4
复制代码可以使锁更公平。
可以使线程在等待锁的时候响应中断。
可以让线程尝试获取锁,并在无法获取锁的时候立即返回或者等待一段时间。
可以在不同的范围,以不同的顺序获取和释放锁。

2.2、AQS (AbstractQueuedSynchronizer)

AQS 即队列同步器。它是构建锁或者其他同步组件的基础框架(如 ReentrantLock、ReentrantReadWriteLock、Semaphore 等),J.U.C 并发包的作者(Doug Lea)期望它能够成为实现大部分同步需求的基础。

数据结构:

1
2
3
4
5
6
7
8
复制代码    //同步队列头节点
private transient volatile Node head;

//同步队列尾节点
private transient volatile Node tail;

//同步状态
private volatile int state;

AQS 使用一个 int 类型的成员变量 state 来表示同步状态:

  • 当 state > 0 时,表示已经获取了锁。
  • 当 state = 0 时,表示释放了锁。

Node构成FIFO的同步队列来完成线程获取锁的排队工作

  • 如果当前线程获取同步状态失败(锁)时,AQS 则会将当前线程以及等待状态等信息构造成一个节点(Node)并将其加入同步队列,同时会阻塞当前线程
  • 当同步状态释放时,则会把节点中的线程唤醒,使其再次尝试获取同步状态

参考: 深入剖析基于并发AQS的(独占锁)重入锁(ReetrantLock)及其Condition实现原理

2.3、Sync

Sync:抽象类,是ReentrantLock的内部类,继承自AbstractQueuedSynchronizer,实现了释放锁的操作(tryRelease()方法),并提供了lock抽象方法,由其子类实现。

NonfairSync:是ReentrantLock的内部类,继承自Sync,非公平锁的实现类。

FairSync:是ReentrantLock的内部类,继承自Sync,公平锁的实现类。

AQS、Sync 和 ReentrantLock 的具体关系图:

2.4、ReentrantLock 实现原理

构造函数:

1
2
3
4
5
6
7
复制代码public ReentrantLock() {
sync = new NonfairSync();
}

public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}

ReentrantLock 提供两种实现方式,公平锁/非公平锁. 通过构造函数进行初始化 sync 进行判断当前锁得类型.

2.4.1、非公平锁(NonfairSync)
1
2
3
4
5
6
7
8
9
复制代码    final void lock() {
//cas 获取锁
if (compareAndSetState(0, 1))
//如果成功设置当前线程Id
setExclusiveOwnerThread(Thread.currentThread());
else
//否则再次请求同步状态
acquire(1);
}

先对同步状态执行CAS操作,尝试把state的状态从0设置为1,
如果返回true则代表获取同步状态成功,也就是当前线程获取锁成,可操作临界资源,如果返回false,则表示已有线程持有该同步状态(其值为1)
获取锁失败,注意这里存在并发的情景,也就是可能同时存在多个线程设置state变量,因此是CAS操作保证了state变量操作的原子性。返回false后,执行acquire(1)方法

#acquire(int arg)方法,为 AQS 提供的模板方法。该方法为独占式获取同步状态,但是该方法对中断不敏感。也就是说,由于线程获取同步状态失败而加入到 CLH 同步队列中,后续对该线程进行中断操作时,线程不会从 CLH 同步队列中移除。

acquire 代码:

1
2
3
4
5
6
7
复制代码   public final void acquire(int arg) {
//尝试获取同步状态
if (!tryAcquire(arg) &&
//自旋直到获得同步状态成功,添加节点到队列
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}

1、tryAcquire 尝试获取同步状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码    final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
//锁闲置
if (c == 0) {
//CAS占用
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
//如果锁state=1 && 线程为当前线程 重入锁的逻辑
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}

2、acquireQueued 加入队列中,自旋获取锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
复制代码private Node addWaiter(Node mode) {
//将请求同步状态失败的线程封装成结点
Node node = new Node(Thread.currentThread(), mode);

Node pred = tail;
//如果是第一个结点加入肯定为空,跳过。
//如果非第一个结点则直接执行CAS入队操作,尝试在尾部快速添加
if (pred != null) {
node.prev = pred;
//使用CAS执行尾部结点替换,尝试在尾部快速添加
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
//如果第一次加入或者CAS操作没有成功执行enq入队操作
enq(node);
return node;
}

final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
//获取前驱节点
final Node p = node.predecessor();
//如果前驱节点试头节点, 尝试获取同步状态
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
// 获取失败,线程等待
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}

流程图:

2.4.2、公平锁(FairSync)

与非公平锁不同的是,在获取锁的时,公平锁的获取顺序是完全遵循时间上的FIFO规则,也就是说先请求的线程一定会先获取锁,后来的线程肯定需要排队,这点与前面我们分析非公平锁的nonfairTryAcquire(int acquires)方法实现有锁不同,下面是公平锁中tryAcquire()方法的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码    protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
//判断队列中是否又线程在等待
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
//重入锁逻辑
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}

2.4.3、解锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
复制代码//ReentrantLock类的unlock
public void unlock() {
sync.release(1);
}

//AQS类的release()方法
public final boolean release(int arg) {
//尝试释放锁
if (tryRelease(arg)) {

Node h = head;
if (h != null && h.waitStatus != 0)
//唤醒后继结点的线程
unparkSuccessor(h);
return true;
}
return false;
}

//ReentrantLock类中的内部类Sync实现的tryRelease(int releases)
protected final boolean tryRelease(int releases) {

int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
//判断状态是否为0,如果是则说明已释放同步状态
if (c == 0) {
free = true;
//设置Owner为null
setExclusiveOwnerThread(null);
}
//设置更新同步状态
setState(c);
return free;
}

3、ReentrantReadWriteLock

构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码Lock readLock();

Lock writeLock();

/** 使用默认(非公平)的排序属性创建一个新的 ReentrantReadWriteLock */
public ReentrantReadWriteLock() {
this(false);
}

/** 使用给定的公平策略创建一个新的 ReentrantReadWriteLock */
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}

java.util.concurrent.locks.ReentrantReadWriteLock,实现 ReadWriteLock 接口,可重入的读写锁实现类。在它内部,维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。只要没有 Writer 线程,读取锁可以由多个 Reader 线程同时保持。也就说说,写锁是独占的,读锁是共享的。

在 ReentrantLock 中,使用 Sync ( 实际是 AQS )的 int 类型的 state 来表示同步状态,表示锁被一个线程重复获取的次数。但是,读写锁 ReentrantReadWriteLock 内部维护着一对读写锁,如果要用一个变量维护多种状态,需要采用“按位切割使用”的方式来维护这个变量,将其切分为两部分:高16为表示读,低16为表示写。

分割之后,读写锁是如何迅速确定读锁和写锁的状态呢?通过位运算。假如当前同步状态为S,那么:

  • 写状态,等于 S & 0x0000FFFF(将高 16 位全部抹去)
  • 读状态,等于 S >>> 16 (无符号补 0 右移 16 位)。

1、readLock

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
复制代码    public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}

protected final int tryAcquireShared(int unused) {
//当前线程
Thread current = Thread.currentThread();
int c = getState();
//exclusiveCount(c)计算写锁
//如果存在写锁,且锁的持有者不是当前线程,直接返回-1
//存在锁降级问题,后续阐述
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
//读锁
int r = sharedCount(c);

/*
* readerShouldBlock():读锁是否需要等待(公平锁原则)
* r < MAX_COUNT:持有线程小于最大数(65535)
* compareAndSetState(c, c + SHARED_UNIT):设置读取锁状态
*/
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) { //修改高16位的状态,所以要加上2^16
/*
* holdCount部分后面讲解
*/
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
}
return 1;
}
return fullTryAcquireShared(current);
}

4、synchronized 和 ReentrantLock 异同?

相同点

1
2
复制代码都实现了多线程同步和内存可见性语义。
都是可重入锁。

不同点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码同步实现机制不同
synchronized 通过 Java 对象头锁标记和 Monitor 对象实现同步。
ReentrantLock 通过CAS、AQS(AbstractQueuedSynchronizer)和 LockSupport(用于阻塞和解除阻塞)实现同步。


可见性实现机制不同
synchronized 依赖 JVM 内存模型保证包含共享变量的多线程内存可见性。
ReentrantLock 通过 ASQ 的 volatile state 保证包含共享变量的多线程内存可见性。

使用方式不同
synchronized 可以修饰实例方法(锁住实例对象)、静态方法(锁住类对象)、代码块(显示指定锁对象)。
ReentrantLock 显示调用 tryLock 和 lock 方法,需要在 finally 块中释放锁。

功能丰富程度不同
synchronized 不可设置等待时间、不可被中断(interrupted)。
ReentrantLock 提供有限时间等候锁(设置过期时间)、可中断锁(lockInterruptibly)、condition(提供 await、condition(提供 await、signal 等方法)等丰富功能

锁类型不同
synchronized 只支持非公平锁。
ReentrantLock 提供公平锁和非公平锁实现。当然,在大部分情况下,非公平锁是高效的选择。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

if-else代码优化的八种方案

发表于 2020-03-07

前言

代码中如果if-else比较多,阅读起来比较困难,维护起来也比较困难,很容易出bug,接下来,本文将介绍优化if-else代码的八种方案。

github.com/whx123/Java…

优化方案一:提前return,去除不必要的else

如果if-else代码块包含return语句,可以考虑通过提前return,把多余else干掉,使代码更加优雅。

优化前:

1
2
3
4
5
复制代码if(condition){
//doSomething
}else{
return ;
}

优化后:

1
2
3
4
复制代码if(!condition){
return ;
}
//doSomething

优化方案二:使用条件三目运算符

使用条件三目运算符可以简化某些if-else,使代码更加简洁,更具有可读性。

优化前:

1
2
3
4
5
6
复制代码int  price ;
if(condition){
price = 80;
}else{
price = 100;
}

优化后:

1
复制代码int price = condition?80:100;

优化方案三:使用枚举

在某些时候,使用枚举也可以优化if-else逻辑分支,按个人理解,它也可以看做一种表驱动方法。

优化前:

1
2
3
4
5
6
7
8
9
复制代码String OrderStatusDes;
if(orderStatus==0){
OrderStatusDes ="订单未支付";
}else if(OrderStatus==1){
OrderStatusDes ="订单已支付";
}else if(OrderStatus==2){
OrderStatusDes ="已发货";
}
...

优化后:

先定义一个枚举

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
复制代码:
public enum OrderStatusEnum {
UN_PAID(0,"订单未支付"),PAIDED(1,"订单已支付"),SENDED(2,"已发货"),;

private int index;
private String desc;

public int getIndex() {
return index;
}

public String getDesc() {
return desc;
}

OrderStatusEnum(int index, String desc){
this.index = index;
this.desc =desc;
}

OrderStatusEnum of(int orderStatus) {
for (OrderStatusEnum temp : OrderStatusEnum.values()) {
if (temp.getIndex() == orderStatus) {
return temp;
}
}
return null;
}
}

有了枚举之后,以上if-else逻辑分支,可以优化为一行代码

1
复制代码String OrderStatusDes = OrderStatusEnum.0f(orderStatus).getDesc();

优化方案四:合并条件表达式

如果有一系列条件返回一样的结果,可以将它们合并为一个条件表达式,让逻辑更加清晰。

优化前

1
2
3
4
5
6
7
8
9
10
11
12
复制代码 double getVipDiscount() {
if(age<18){
return 0.8;
}
if("深圳".equals(city)){
return 0.8;
}
if(isStudent){
return 0.8;
}
//do somethig
}

优化后

1
2
3
4
5
6
复制代码 double getVipDiscount(){
if(age<18|| "深圳".equals(city)||isStudent){
return 0.8;
}
//doSomthing
}

优化方案五:使用 Optional

有时候if-else比较多,是因为非空判断导致的,这时候你可以使用java8的Optional进行优化。

优化前:

1
2
3
4
5
6
复制代码String str = "jay@huaxiao";
if (str != null) {
System.out.println(str);
} else {
System.out.println("Null");
}

优化后:

1
2
复制代码Optional<String> strOptional = Optional.of("jay@huaxiao");
strOptional.ifPresentOrElse(System.out::println, () -> System.out.println("Null"));

优化方案六:表驱动法

表驱动法,又称之为表驱动、表驱动方法。表驱动方法是一种使你可以在表中查找信息,而不必用很多的逻辑语句(if或Case)来把它们找出来的方法。以下的demo,把map抽象成表,在map中查找信息,而省去不必要的逻辑语句。

优化前:

1
2
3
4
5
6
7
8
复制代码if (param.equals(value1)) {
doAction1(someParams);
} else if (param.equals(value2)) {
doAction2(someParams);
} else if (param.equals(value3)) {
doAction3(someParams);
}
// ...

优化后:

1
2
3
4
5
6
7
8
9
复制代码Map<?, Function<?> action> actionMappings = new HashMap<>(); // 这里泛型 ? 是为方便演示,实际可替换为你需要的类型

// 初始化
actionMappings.put(value1, (someParams) -> { doAction1(someParams)});
actionMappings.put(value2, (someParams) -> { doAction2(someParams)});
actionMappings.put(value3, (someParams) -> { doAction3(someParams)});

// 省略多余逻辑语句
actionMappings.get(param).apply(someParams);

优化方案七:优化逻辑结构,让正常流程走主干

优化前:

1
2
3
4
5
6
7
8
9
复制代码public double getAdjustedCapital(){
if(_capital <= 0.0 ){
return 0.0;
}
if(_intRate > 0 && _duration >0){
return (_income / _duration) *ADJ_FACTOR;
}
return 0.0;
}

优化后:

1
2
3
4
5
6
7
8
9
10
复制代码public double getAdjustedCapital(){
if(_capital <= 0.0 ){
return 0.0;
}
if(_intRate <= 0 || _duration <= 0){
return 0.0;
}

return (_income / _duration) *ADJ_FACTOR;
}

将条件反转使异常情况先退出,让正常流程维持在主干流程,可以让代码结构更加清晰。

优化方案八:策略模式+工厂方法消除if else

假设需求为,根据不同勋章类型,处理相对应的勋章服务,优化前有以下代码:

1
2
3
4
5
6
7
8
9
复制代码    String medalType = "guest";
if ("guest".equals(medalType)) {
System.out.println("嘉宾勋章");
} else if ("vip".equals(medalType)) {
System.out.println("会员勋章");
} else if ("guard".equals(medalType)) {
System.out.println("展示守护勋章");
}
...

首先,我们把每个条件逻辑代码块,抽象成一个公共的接口,可以得出以下代码:

1
2
3
4
复制代码//勋章接口
public interface IMedalService {
void showMedal();
}

我们根据每个逻辑条件,定义相对应的策略实现类,可得以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码//守护勋章策略实现类
public class GuardMedalServiceImpl implements IMedalService {
@Override
public void showMedal() {
System.out.println("展示守护勋章");
}
}
//嘉宾勋章策略实现类
public class GuestMedalServiceImpl implements IMedalService {
@Override
public void showMedal() {
System.out.println("嘉宾勋章");
}
}
//VIP勋章策略实现类
public class VipMedalServiceImpl implements IMedalService {
@Override
public void showMedal() {
System.out.println("会员勋章");
}
}

接下来,我们再定义策略工厂类,用来管理这些勋章实现策略类,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码//勋章服务工产类
public class MedalServicesFactory {

private static final Map<String, IMedalService> map = new HashMap<>();
static {
map.put("guard", new GuardMedalServiceImpl());
map.put("vip", new VipMedalServiceImpl());
map.put("guest", new GuestMedalServiceImpl());
}
public static IMedalService getMedalService(String medalType) {
return map.get(medalType);
}
}

使用了策略+工厂模式之后,代码变得简洁多了,如下:

1
2
3
4
5
6
7
复制代码public class Test {
public static void main(String[] args) {
String medalType = "guest";
IMedalService medalService = MedalServicesFactory.getMedalService(medalType);
medalService.showMedal();
}
}

参考与感谢

  • 6个实例详解如何把if-else代码重构成高质量代码
  • 如何 “干掉” if…else

个人公众号

  • 觉得写得好的小伙伴给个点赞+关注啦,谢谢~
  • 同时非常期待小伙伴们能够关注我公众号,后面慢慢推出更好的干货~嘻嘻
  • github地址:github.com/whx123/Java…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

面试官:你会几种Redis分布式锁?我会三种!

发表于 2020-03-07

文章每周持续更新,各位的「三连」是对我最大的肯定。可以微信搜索公众号「 后端技术学堂 」第一时间阅读(一般比博客早更新一到两篇)

大家春节在家抢红包玩的不亦乐乎,抢红包服务看起来非常简单,实际上要做好这个服务,特别是money相关服务是不允许出错的,想想看每个红包的数字都是真金白银,要求服务的鲁棒性非常高,背后包含着很多后台服务技术细节。

今天就来说说高并发服务编程中的redis分布式锁。

这里罗列出3种redis实现的分布式锁,并分别对比说明各自特点。

Redis单实例分布式锁

实现一: SETNX实现的分布式锁

setnx用法参考redis官方文档

语法

SETNX key value

将key设置值为value,如果key不存在,这种情况下等同SET命令。 当key存在时,什么也不做。SETNX是”SET if Not eXists”的简写。

返回值:

  • 1 设置key成功
  • 0 设置key失败

加锁步骤

  1. SETNX lock.foo <current Unix time + lock timeout + 1>

如果客户端获得锁,SETNX返回1,加锁成功。

如果SETNX返回0,那么该键已经被其他的客户端锁定。

  1. 接上一步,SETNX返回0加锁失败,此时,调用GET lock.foo获取时间戳检查该锁是否已经过期:
  2. 如果没有过期,则休眠一会重试。
  3. 如果已经过期,则可以获取该锁。具体的:调用GETSET lock.foo <current Unix timestamp + lock timeout + 1>基于当前时间设置新的过期时间。

注意: 这里设置的时候因为在SETNX与GETSET之间有个窗口期,在这期间锁可能已被其他客户端抢去,所以这里需要判断GETSET的返回值,他的返回值是SET之前旧的时间戳:

  • 若旧的时间戳已过期,则表示加锁成功。
  • 若旧的时间戳还未过期(说明被其他客户端抢去并设置了时间戳),代表加锁失败,需要等待重试。

解锁步骤

解锁相对简单,只需GET lock.foo时间戳,判断是否过期,过期就调用删除DEL lock.foo

实现二:SET实现的分布式锁

set用法参考官方文档

语法

SET key value [EX seconds|PX milliseconds] [NX|XX]

将键key设定为指定的“字符串”值。如果 key 已经保存了一个值,那么这个操作会直接覆盖原来的值,并且忽略原始类型。当set命令执行成功之后,之前设置的过期时间都将失效。

从2.6.12版本开始,redis为SET命令增加了一系列选项:

  • EXseconds – Set the specified expire time, in seconds.
  • PXmilliseconds – Set the specified expire time, in milliseconds.
  • NX – Only set the key if it does not already exist.
  • XX – Only set the key if it already exist.
  • EXseconds – 设置键key的过期时间,单位时秒
  • PXmilliseconds – 设置键key的过期时间,单位是毫秒
  • NX – 只有键key不存在的时候才会设置key的值
  • XX – 只有键key存在的时候才会设置key的值

版本>= 6.0

  • KEEPTTL – 保持 key 之前的有效时间TTL

加锁步骤

一条命令即可加锁: SET resource_name my_random_value NX PX 30000

The command will set the key only if it does not already exist (NX option), with an expire of 30000 milliseconds (PX option). The key is set to a value “myrandomvalue”. This value must be unique across all clients and all lock requests.

这个命令只有当key 对应的键不存在resource_name时(NX选项的作用)才生效,同时设置30000毫秒的超时,成功设置其值为my_random_value,这是个在所有redis客户端加锁请求中全局唯一的随机值。

解锁步骤

解锁时需要确保my_random_value和加锁的时候一致。下面的Lua脚本可以完成

1
2
3
4
5
复制代码if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end

这段Lua脚本在执行的时候要把前面的my_random_value作为ARGV[1]的值传进去,把resource_name作为KEYS[1]的值传进去。释放锁其实包含三步操作:’GET’、判断和’DEL’,用Lua脚本来实现能保证这三步的原子性。

Redis集群分布式锁

实现三:Redlock

前面两种分布式锁的实现都是针对单redis master实例,既不是有互为备份的slave节点也不是多master集群,如果是redis集群,每个redis master节点都是独立存储,这种场景用前面两种加锁策略有锁的安全性问题。

比如下面这种场景:

  1. 客户端1从Master获取了锁。
  2. Master宕机了,存储锁的key还没有来得及同步到Slave上。
  3. Slave升级为Master。
  4. 客户端2从新的Master获取到了对应同一个资源的锁。

于是,客户端1和客户端2同时持有了同一个资源的锁。锁的安全性被打破。

针对这种多redis服务实例的场景,redis作者antirez设计了Redlock (Distributed locks with Redis)算法,就是我们接下来介绍的。

加锁步骤

集群加锁的总体思想是尝试锁住所有节点,当有一半以上节点被锁住就代表加锁成功。集群部署你的数据可能保存在任何一个redis服务节点上,一旦加锁必须确保集群内任意节点被锁住,否则也就失去了加锁的意义。

具体的:

  1. 获取当前时间(毫秒数)。
  2. 按顺序依次向N个Redis节点执行获取锁的操作。这个获取操作跟前面基于单Redis节点的获取锁的过程相同,包含随机字符串my_random_value,也包含过期时间(比如PX 30000,即锁的有效时间)。为了保证在某个Redis节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(time out),它要远小于锁的有效时间(几十毫秒量级)。客户端在向某个Redis节点获取锁失败以后,应该立即尝试下一个Redis节点。这里的失败,应该包含任何类型的失败,比如该Redis节点不可用,或者该Redis节点上的锁已经被其它客户端持有(注:Redlock原文中这里只提到了Redis节点不可用的情况,但也应该包含其它的失败情况)。
  3. 计算整个获取锁的过程总共消耗了多长时间,计算方法是用当前时间减去第1步记录的时间。如果客户端从大多数Redis节点(>= N/2+1)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time),那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败。
  4. 如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第3步计算出来的获取锁消耗的时间。
  5. 如果最终获取锁失败了(可能由于获取到锁的Redis节点个数少于N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应该立即向所有Redis节点发起释放锁的操作(即前面介绍的Redis Lua脚本)。

解锁步骤

客户端向所有Redis节点发起释放锁的操作,不管这些节点当时在获取锁的时候成功与否。

算法实现

上面描述的算法已经有现成的实现,各种语言版本。

  • Redlock-rb (Ruby implementation). There is also a fork of Redlock-rb that adds a gem for easy distribution and perhaps more.
  • Redlock-py (Python implementation).
  • Aioredlock (Asyncio Python implementation).
  • Redlock-php (PHP implementation).
  • PHPRedisMutex (further PHP implementation)
  • cheprasov/php-redis-lock (PHP library for locks)
  • Redsync (Go implementation).
  • Redisson (Java implementation).
  • Redis::DistLock (Perl implementation).
  • Redlock-cpp (C++ implementation).
  • Redlock-cs (C#/.NET implementation).
  • RedLock.net (C#/.NET implementation). Includes async and lock extension support.
  • ScarletLock (C# .NET implementation with configurable datastore)
  • Redlock4Net (C# .NET implementation)
  • node-redlock (NodeJS implementation). Includes support for lock extension.

比如我用的C++实现

源码在这

创建分布式锁管理类CRedLock

1
2
3
4
复制代码CRedLock * dlm = new CRedLock();
dlm->AddServerUrl("127.0.0.1", 5005);
dlm->AddServerUrl("127.0.0.1", 5006);
dlm->AddServerUrl("127.0.0.1", 5007);

加锁并设置超时时间

1
2
复制代码CLock my_lock;
bool flag = dlm->Lock("my_resource_name", 1000, my_lock);

加锁并保持直到释放

1
2
复制代码CLock my_lock;
bool flag = dlm->ContinueLock("my_resource_name", 1000, my_lock);

my_resource_name是加锁标识;1000是锁的有效期,单位毫秒。

加锁失败返回false, 加锁成功返回Lock结构如下

1
2
3
4
5
6
复制代码class CLock {
public:
int m_validityTime; => 9897.3020019531 // 当前锁可以存活的时间, 毫秒
sds m_resource; => my_resource_name // 要锁住的资源名称
sds m_val; => 53771bfa1e775 // 锁住资源的进程随机名字
};

解锁

1
复制代码dlm->Unlock(my_lock);

总结

综上所述,三种实现方式。

  • 单redis实例场景,分布式锁实现一和实现二都可以,实现二更简洁推荐用实现二,用实现三也可以,但是实现三有点复杂略显笨重。
  • 多redis实例场景推荐用实现三最安全,不过实现三也不是完美无瑕,也有针对这种算法缺陷的讨论(节点宕机同步时延、时间同步假设),大家还需要根据自身业务场景灵活选择或定制自己的分布式锁。

参考

Distributed locks with Redis

How to do distributed locking

基于Redis的分布式锁到底安全吗


更多精彩推荐

超详细Linux后台开发C++学习路线技能加点,已拿腾讯offer

腾讯后台开发面试笔试C++知识点参考笔记

我用大数据分析了一线城市1000多份岗位招聘需求,告诉你如何科学找工作


原创不易,看到这里动动手指,各位的「三连」是对我持续创作的最大支持。

更多

可以微信搜索公众号「 后端技术学堂 」回复「资料」有我给你准备的各种编程学习资料。文章每周持续更新,我们下期见!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Spring-Boot-全栈开发:漂亮的邮件注册

发表于 2020-03-07

效果图

邮箱注册

注册流程

1.[前端] 用户在注册页面提交注册信息;
2.[后端] 校验用户提交的参数,有误直接返回错误信息,无误向下执行;
3.[后端] 随机生成一个ID,将ID作为key,用户信息作为value,存入redis,设置时长;
4.[后端] 生成激活链接,通过邮件系统发送邮件到用户邮箱
5.[前端] 用户点击上图的“确认注册”;
6.[后端] 校验value是否过期,校验邮箱是否已经注册,没有则保存用户信息到数据库,提示用户已经注册成功;

功能实现(逆向分析)

1、先看看邮件发送的实现 cn.ictgu.tools.mail.MailService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
复制代码package cn.ictgu.tools.mail;

import cn.ictgu.dao.model.User;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.log4j.Log4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;

import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;

/**
* 邮件服务
* Created by Silence on 2017/3/11.
*/
@Service
@Log4j
public class MailService {

@Autowired
private JavaMailSender mailSender;

@Value("${spring.mail.username}")
private String from;

private static final String TITLE_SIGN_UP = "[邮件标题]";

private static final String CONTENT = "[邮件内容]";

public void userValidate(User user, String token){
MimeMessage mailMessage = mailSender.createMimeMessage();
try {
MimeMessageHelper helper = new MimeMessageHelper(mailMessage, true, "GBK");
helper.setFrom(from);
helper.setTo(user.getEmail());
helper.setSubject(TITLE_SIGN_UP);
String link = "http://www.ictgu.cn/validate/" + token;
String message = String.format(CONTENT, user.getNickname(), link, link, user.getEmail());
helper.setText(message, true);
mailSender.send(mailMessage);
} catch (MessagingException e) {
log.error("发送邮件失败:User:" + JSONObject.toJSONString(user) + ", Token: " + token);
}

}

说明:发送邮件需要传入2个参数 user 和 token,user即为用户注册信息,token是一个随机的UUID,redis中有与之对应的key,其value是user的json字符串。(key的规则是配置文件redis.prefix.signUp + UUID)

邮件模板问题:
邮件内容的本质是一段字符串,样式由html+css控制,开发时,在邮箱设计好模板,变量用%s代替,然后将整段字符串存放在一个合适的地方,发送邮件时,使用String.format()方法将%s替换为实际值,就生成了最终用户看到邮件。所以没有必要使用第三方模板,添加jar包会增加系统的复杂度,同样的功能,能简化的尽量简化。

2、如何生成Token呢? 请看 cn.ictgu.redis.RedisTokenManager

1
2
3
4
5
6
7
复制代码  public String getTokenOfSignUp(User user){
String token = UUID.randomUUID().toString();
String value = JSONObject.toJSONString(user);
stringRedisTemplate.opsForValue().set(signUpPrefix + token, value);
stringRedisTemplate.expire(signUpPrefix + token, 12, TimeUnit.HOURS);
return token;
}

说明:UUID.randomUUID().toString()是javaJDK提供的一个自动生成主键的方法。UUID(Universally Unique Identifier)全局唯一标识符,是指在一台机器上生成的数字,它保证对在同一时空中的所有机器都是唯一的,是由一个十六位的数字组成,表现出来的形式。由以下几部分的组合:当前日期和时间(UUID的第一个部分与时间有关,如果你在生成一个UUID之后,过几秒又生成一个UUID,则第一个部分不同,其余相同),时钟序列,全局唯一的IEEE机器识别号(如果有网卡,从网卡获得,没有网卡以其他方式获得),UUID的唯一缺陷在于生成的结果串会比较长。

3、token有了,就需要在service层关联user和token发送邮件啦,看看 cn.ictgu.dao.service.UserService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码  public boolean signUp(User user){
String email = user.getEmail();
if (existEmail(email)){
log.error("用户注册,邮箱已注册:" + email);
return false;
}
sendValidateEmail(user);
return true;
}

@Async
private void sendValidateEmail(User user){
String token = tokenManager.getTokenOfSignUp(user);
log.error("用户注册,准备发送邮件:User:" + JSONObject.toJSONString(user) + ", Token: " + token);
mailService.userValidate(user, token);
}

private boolean existEmail(String email){
return mapper.selectByEmail(email) != null;
}

说明:发送邮件耗时较长,使用异步来做,提高用户体验

4、user就简单了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
复制代码@RestController
public class UserApi {

@Autowired
private UserService userService;

@RequestMapping(value = "/sign-up", method = RequestMethod.POST)
public SimpleResponse signUp(HttpServletRequest request){
User user = createUser(request);
SimpleResponse response = checkSignUpRequest(user);
if (response.getCode() == 100){
if (!userService.signUp(user)){
response.setCode(200);
response.setMessage("此邮箱已注册,请勿重复注册!");
return response;
}else {
response.setMessage("注册激活邮件已发送至您邮箱,请在12小时内激活完成注册!");
return response;
}
}
return response;
}

private SimpleResponse checkSignUpRequest(User user){
SimpleResponse response = new SimpleResponse();
String email = user.getEmail();
if (!CheckUtils.checkEmail(email)){
response.setCode(200);
response.setMessage("邮箱格式不合法");
return response;
}
String password = user.getPassword();
if (!CheckUtils.checkPassword(password)){
response.setCode(200);
response.setMessage("密码长度必须为8-16位,且必须包含数字和字母");
return response;
}
String nickname = user.getNickname();
if (!CheckUtils.checkNickname(nickname)){
response.setCode(200);
response.setMessage("昵称长度不合法");
return response;
}
response.setCode(100);
return response;
}

}

说明:这一层主要做了用户注册参数的校验

回顾

从下往上看,从用户注册到邮件发送就实现了,大部分代码都是做的参数校验,因为用户行为是不可信的,要构建安全的后台系统,就要无死角的校验校验。。。。

Github地址

地址: github.com/ChinaSilenc…

演示地址:

地址:www.ictgu.cn

相关文章

Spring Boot 全栈开发:初章
Spring Boot 全栈开发:开发环境搭建
Spring Boot 全栈开发:应用部署使用
Spring Boot 全栈开发:漂亮的邮件注册
Spring Boot 全栈开发:解析器实现
Spring Boot 全栈开发:视频解析之优酷
Spring Boot 全栈开发:视频解析之乐视
Spring Boot 全栈开发:用户安全
Spring Boot 全栈开发:并发爬虫优化
未完待续

文末福利

Java 资料大全 链接:pan.baidu.com/s/1pUCCPstP… 密码:b2xc
更多资料: 2020 年 精选阿里 Java、架构、微服务精选资料等,加 v ❤ :qwerdd111

本文由博客一文多发平台 OpenWrite 发布!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

看完这篇,再也不用焦虑如何写dockerfile了

发表于 2020-03-05

Dockerfile是Docker用来构建镜像的文本文件,包括自定义的指令和格式。可以通过docker build命令从Dockerfile中构建镜像。用户可以通过统一的语法命令来根据需求进行配置,通过这份统一的配置文件,在不同的文件上进行分发,需要使用时就可以根据配置文件进行自动化构建,这解决了开发人员构建镜像的复杂过程。

Dockerfile的使用

Dockerfile描述了组装对象的步骤,其中每条指令都是单独运行的。除了FROM指令,其他每条命令都会在上一条指令所生成镜像的基础上执行,执行完后会生成一个新的镜像层,新的镜像层覆盖在原来的镜像之上从而形成了新的镜像。Dockerfile所生成的最终镜像就是在基础镜像上面叠加一层层的镜像层组建的。

Dockerfile指令

Dockerfile的基本格式如下:

1
2
复制代码# Comment  
INSTRUCTION arguments

在Dockerfile中,指令(INSTRUCTION)不区分大小写,但是为了与参数区分,推荐大写。
Docker会顺序执行Dockerfile中的指令,第一条指令必须是FROM指令,它用于指定构建镜像的基础镜像。在Dockerfile中以#开头的行是注释,而在其他位置出现的#会被当成参数。

Dockerfile中的指令有FROM、MAINTAINER、RUN、CMD、EXPOSE、ENV、ADD、COPY、ENTRYPOING、VOLUME、USER、WORKDIR、ONBUILD,错误的指令会被忽略。下面将详细讲解一些重要的Docker指令。

FROM

格式: FROM <image> 或者 FROM <image>:<tag>

FROM指令的功能是为后面的指令提供基础镜像,因此Dockerfile必须以FROM指令作为第一条非注释指令。从公共镜像库中拉取镜像很容易,基础镜像可以选择任何有效的镜像。
在一个Dockerfile中FROM指令可以出现多次,这样会构建多个镜像。tag的默认值是latest,如果参数image或者tag指定的镜像不存在,则返回错误。

ENV

格式: ENV <key> <value> 或者 ENV <key>=<value> ...

ENV指令可以为镜像创建出来的容器声明环境变量。并且在Dockerfile中,ENV指令声明的环境变量会被后面的特定指令(即ENV、ADD、COPY、WORKDIR、EXPOSE、VOLUME、USER)解释使用。

其他指令使用环境变量时,使用格式为$variable_name或者${variable_name}。如果在变量面前添加斜杠\可以转义。如\$foo或者\${foo}将会被转换为$foo和${foo},而不是环境变量所保存的值。另外,ONBUILD指令不支持环境替换。

COPY

格式: COPY <src> <dest>

COPY指令复制所指向的文件或目录,将它添加到新镜像中,复制的文件或目录在镜像中的路径是<dest>。<src>所指定的源可以有多个,但必须是上下文根目录中的相对路径。
不能只用形如 COPY ../something /something这样的指令。此外,<src>可以使用通配符指向所有匹配通配符的文件或目录,例如,COPY home* /mydir/ 表示添加所有以”hom”开头的文件到目录/mydir/中。

<dest>可以是文件或目录,但必须是目标镜像中的绝对路径或者相对于WORKDIR的相对路径(WORKDIR即Dockerfile中WORKDIR指令指定的路径,用来为其他指令设置工作目录)。
若<dest>以反斜杠/结尾则其指向的是目录;否则指向文件。<src>同理。若<dest>是一个文件,则<src>的内容会被写到<dest>中;否则<src>指向的文件或目录中的内容会被复制添加到<dest>目录中。
当<src>指定多个源时,<dest>必须是目录。如果<dest>不存在,则路径中不存在的目录会被创建。

ADD

格式:ADD <src> <dest>

ADD与COPY指令在功能上很相似,都支持复制本地文件到镜像的功能,但ADD指令还支持其他功能。<src>可以是指向网络文件的URL,此时若<dest>指向一个目录,则URL必须是完全路径,这样可以获得网络文件的文件名filename,该文件会被复制添加到<dest>/<filename>。
比如 ADD http://example.com/config.property / 会创建文件/config.property。

<src>还可以指向一个本地压缩归档文件,该文件会在复制到容器时会被解压提取,如ADD sxample.tar.xz /。但是若URL中的文件为归档文件则不会被解压提取。

ADD 和 COPY指令虽然功能相似,但一般推荐使用COPY,因为COPY只支持本地文件,相比ADD而言,它更加透明。

EXPOSE

格式: EXPOSE <port> [<port>/<protocol>...]

EXPOSE指令通知Docker该容器在运行时侦听指定的网络端口。可以指定端口是侦听TCP还是UDP,如果未指定协议,则默认值为TCP。
这个指令仅仅是声明容器打算使用什么端口而已,并不会自动在宿主机进行端口映射,可以在运行的时候通过docker -p指定。

1
2
复制代码EXPOSE 80/tcp  
EXPOSE 80/udp

USER

格式: USER <user>[:<group] 或者 USER <UID>[:<GID>]

USER指令设置了user name和user group(可选)。在它之后的RUN,CMD以及ENTRYPOINT指令都会以设置的user来执行。

WORKDIR

格式: WORKDIR /path/to/workdir

WORKDIR指令设置工作目录,它之后的RUN、CMD、ENTRYPOINT、COPY以及ADD指令都会在这个工作目录下运行。如果这个工作目录不存在,则会自动创建一个。
WORKDIR指令可在Dockerfile中多次使用。如果提供了相对路径,则它将相对于上一个WORKDIR指令的路径。例如

1
2
3
4
复制代码WORKDIR /a  
WORKDIR b
WORKDIR c
RUN pwd

输出结果是 /a/b/c

RUN

格式1: RUN <command> (shell格式)
格式2: RUN ["executable", "param1", "param2"] (exec格式,推荐使用)

RUN指令会在前一条命令创建出的镜像的基础上创建一个容器,并在容器中运行命令,在命令结束运行后提交容器为新镜像,新镜像被Dockerfile中的下一条指令使用。

RUN指令的两种格式表示命令在容器中的两种运行方式。当使用shell格式时,命令通过/bin/sh -c运行。
当使用exec格式时,命令是直接运行的,容器不调用shell程序,即容器中没有shell程序。
exec格式中的参数会被当成JSON数组被Docker解析,故必须使用双引号而不能使用单引号。因为exec格式不会在shell中执行,所以环境变量的参数不会被替换。

比如执行RUN ["echo", "$HOME"]指令时,$HOME不会做变量替换。如果希望运行shell程序,指令可以写成 RUN ["/bin/bash", "-c", "echo", "$HOME"]。

CMD

CMD指令有3种格式。

格式1:CMD <command> (shell格式)
格式2:CMD ["executable", "param1", "param2"] (exec格式,推荐使用)
格式3:CMD ["param1", "param2"] (为ENTRYPOINT指令提供参数)

CMD指令提供容器运行时的默认值,这些默认值可以是一条指令,也可以是一些参数。一个Dockerfile中可以有多条CMD指令,但只有最后一条CMD指令有效。
CMD [“param1”, “param2”]格式是在CMD指令和ENTRYPOINT指令配合时使用的,CMD指令中的参数会添加到ENTRYPOING指令中.使用shell和exec格式时,命令在容器中的运行方式与RUN指令相同。

不同之处在于,RUN指令在构建镜像时执行命令,并生成新的镜像;CMD指令在构建镜像时并不执行任何命令,而是在容器启动时默认将CMD指令作为第一条执行的命令。如果用户在命令行界面运行docker run命令时指定了命令参数,则会覆盖CMD指令中的命令。

ENTRYPOINT

ENTRYPOINT指令有两种格式。

格式1:ENTRYPOINT <command> (shell格式)
格式2:ENTRYPOINT ["executable", "param1", "param2"] (exec格式,推荐格式)

ENTRYPOINT指令和CMD指令类似,都可以让容器在每次启动时执行相同的命令,但它们之间又有不同。一个Dockerfile中可以有多条ENTRYPOINT指令,但只有最后一条ENTRYPOINT指令有效。

当使用Shell格式时,ENTRYPOINT指令会忽略任何CMD指令和docker run命令的参数,并且会运行在bin/sh -c中。这意味着ENTRYPOINT指令进程为bin/sh -c的子进程,进程在容器中的PID将不是1,且不能接受Unix信号。即当使用docker stop <container>命令时,命令进程接收不到SIGTERM信号。

推荐使用exec格式,使用此格式时,docker run传入的命令参数会覆盖CMD指令的内容并且附加到ENTRYPOINT指令的参数中。从ENTRYPOINT的使用中可以看出,CMD可以是参数,也可以是指令,而ENTRYPOINT只能是命令;另外,docker run命令提供的运行命令参数可以覆盖CMD,但不能覆盖ENTRYPOINT。

Dockerfile实践心得

使用标签

给镜像打上标签,有利于帮助了解进镜像功能

谨慎选择基础镜像

选择基础镜像时,尽量选择当前官方镜像库的肩宽,不同镜像的大小不同,目前Linux镜像大小由如下关系:

busybox < debian < centos < ubuntu

同时在构建自己的Docker镜像时,只安装和更新必须使用的包。此外相比Ubuntu镜像,更推荐使用Debian镜像,因为它非常轻量级(目前其大小是在100MB以下),并且仍然是一个完整的发布版本。

充分利用缓存

Docker daemon会顺序执行Dockerfile中的指令,而且一旦缓存失效,后续命令将不能使用缓存。为了有效地利用缓存,需要保证指令的连续性,尽量将所有Dockerfile文件相同的部分都放在前面,而将不同的部分放到后面。

正确使用ADD与COPY命令

当在Dockerfile中的不同部分需要用到不同的文件时,不要一次性地将这些文件都添加到镜像中去,而是在需要时添加,这样也有利于重复利用docker缓存。
另外考虑到镜像大小问题,使用ADD指令去获取远程URL中的压缩包不是推荐的做法。应该使用RUN wget或RUN curl代替。这样可以删除解压后不在需要的文件,并且不需要在镜像中在添加一层。

错误做法:

1
2
3
复制代码ADD http://example.com/big.tar.xz /usr/src/things/  
RUN tar -xJf /usr/src/things/big.tar.xz -C /usr/src/things
RUN make -C /usr/src/things all

正确的做法:

1
2
3
4
复制代码RUN mkdir -p /usr/src/things \  
    && curl -SL http://example.com/big.tar.xz \
    | tar -xJC /usr/src/things \
    && make -C /usr/src/things all

RUN指令

在使用较长的RUN指令时可以使用反斜杠\分隔多行。大部分使用RUN指令的常见是运行apt-wget命令,在该场景下请注意以下几点。

  1. 不要在一行中单独使用指令RUN apt-get update。当软件源更新后,这样做会引起缓存问题,导致RUN apt-get install指令运行失败。所以,RUN apt-get update和RUN apt-get install应该写在同一行。比如 RUN apt-get update && apt-get install -y package-1 package-2 package-3
  2. 避免使用指令RUN apt-get upgrade 和 RUN apt-get dist-upgrade。因为在一个无特权的容器中,一些必要的包会更新失败。如果需要更新一个包(如package-1),直接使用命令RUN apt-get install -y package-1。

CMD和ENTRYPOINT命令

CMD和ENTRYPOINT命令指定是了容器运行的默认命令,推荐二者结合使用。使用exec格式的ENTRYPOINT指令设置固定的默认命令和参数,然后使用CMD指令设置可变的参数。

比如下面这个例子:

1
2
3
4
5
6
复制代码FROM busybox  
WORKDIR /app
COPY run.sh /app
RUN chmod +x run.sh
ENTRYPOINT ["/app/run.sh"]
CMD ["param1"]

run.sh内容如下:

1
2
复制代码#!/bin/sh  
echo "$@"

运行后输出结果为param1, Dockerfile中CMD和ENTRYPOINT的顺序不重要(CMD写在ENTRYPOINT前后都可以)。

当在windows系统下build dockerfile你可能会遇到这个问题

1
复制代码standard_init_linux.go:207: exec user process caused "no such file or directory"

这是因为sh文件的fileformat是dos,这里需要修改为unix,不需要下载额外的工具,一般我们机器上安装了git会自带git bash,进入git bash,使用vi 编辑,在命令行模式下修改(:set ff=unix)。

不要再Dockerfile中做端口映射

使用Dockerfile的EXPOSE指令,虽然可以将容器端口映射在主机端口上,但会破坏Docker的可移植性,且这样的镜像在一台主机上只能启动一个容器。所以端口映射应在docker run命令中用-p 参数指定。

1
2
3
4
5
复制代码# 不要再Dockerfile中做如下映射  
EXPOSE 80:8080

# 仅暴露80端口,需要另做映射
EXPOSE 80

实践Dockerfile的写法

Java 服务的DockerFile

1
2
3
4
5
6
7
8
bash复制代码FROM openjdk:8-jre-alpine  
ENV spring_profiles_active=dev
ENV env_java_debug_enabled=false
EXPOSE 8080
WORKDIR /app
ADD target/smcp-web.jar /app/target/smcp-web.jar
ADD run.sh /app
ENTRYPOINT ./run.sh

可以看到基础镜像是openjdk,然后设置了两个环境变量,服务访问端口是8080(意味着springboot应用中指定了server.port=8080),设置了工作目录是/app。通过ENTRYPOINT设定了启动镜像时要启动的命令(./run.sh)。这个脚本中的内容如下:

1
2
3
4
5
6
7
8
复制代码#!/bin/sh  
# Set debug options if required
if [ x"${env_java_debug_enabled}" != x ] && [ "${env_java_debug_enabled}" != "false" ]; then
    java_debug_args="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005"
fi

# ex: env_jvm_flags="-Xmx1200m -XX:MaxRAM=1500m" for production
java $java_debug_args $env_jvm_flags -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -jar target/smcp-web.jar

如果我们要指定jvm的一些参数,可以通过在环境变量中设置env_jvm_flags来指定。

Maven Dockerfile

maven的Dockerfile也写的很好,这里我发上来也给大家参考下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码FROM openjdk:8-jdk  

ARG MAVEN_VERSION=3.6.3
ARG USER_HOME_DIR="/root"
ARG SHA=c35a1803a6e70a126e80b2b3ae33eed961f83ed74d18fcd16909b2d44d7dada3203f1ffe726c17ef8dcca2dcaa9fca676987befeadc9b9f759967a8cb77181c0
ARG BASE_URL=https://apache.osuosl.org/maven/maven-3/${MAVEN_VERSION}/binaries

RUN mkdir -p /usr/share/maven /usr/share/maven/ref \
  && curl -fsSL -o /tmp/apache-maven.tar.gz ${BASE_URL}/apache-maven-${MAVEN_VERSION}-bin.tar.gz \
  && echo "${SHA}  /tmp/apache-maven.tar.gz" | sha512sum -c - \
  && tar -xzf /tmp/apache-maven.tar.gz -C /usr/share/maven --strip-components=1 \
  && rm -f /tmp/apache-maven.tar.gz \
  && ln -s /usr/share/maven/bin/mvn /usr/bin/mvn

ENV MAVEN_HOME /usr/share/maven
ENV MAVEN_CONFIG "$USER_HOME_DIR/.m2"

COPY mvn-entrypoint.sh /usr/local/bin/mvn-entrypoint.sh
COPY settings-docker.xml /usr/share/maven/ref/

ENTRYPOINT ["/usr/local/bin/mvn-entrypoint.sh"]
CMD ["mvn"]

可以看到它是基于openjdk这个基础镜像来创建的,先去下载maven的包,然后进行了安装。 然后又设置了MAVEN_HOME和MAVEN_CONFIG这两个环境变量,最后通过mvn-entrypoint.sh来进行了启动。

前端服务的两阶段构建

我有一个前端服务,目录结构如下:

1
2
复制代码$ ls frontend/  
myaccount/  resources/  third_party/

myaccount目录下是放置的js,vue等,resources放置的是css,images等。third_party放的是第三方应用。

这里采用了两阶段构建,即采用上一阶段的构建结果作为下一阶段的构建数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码FROM node:alpine as builder  
WORKDIR '/build'
COPY myaccount ./myaccount
COPY resources ./resources
COPY third_party ./third_party

WORKDIR '/build/myaccount'

RUN npm install
RUN npm rebuild node-sass
RUN npm run build

RUN ls /build/myaccount/dist

FROM nginx
EXPOSE 80
COPY --from=builder /build/myaccount/dist /usr/share/nginx/html

需要注意结尾的 --from=builder这里和开头是遥相呼应的。

总结

我相信看完dockerfile指令,你看任何一个dockerfile应该都没有太大问题,不记得的命令回来翻一下就行了。如果你觉得还可以,关注下哟。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

盘点下我用的顺手的那些工具!

发表于 2020-03-05

SpringBoot实战电商项目mall(30k+star)地址:github.com/macrozheng/…

摘要

之前经常有朋友问我一些常用的工具,比如我的架构图是用什么工具做的?我的数据库是用什么工具设计的?今天给大家介绍下我用的顺手的工具!

IntelliJ IDEA

业界公认最好的Java开发工具,平时用的最多。可以安装大量插件丰富功能,开发前端应用也不在话下!

X-shell

一款强大的安全终端模拟软件,可以用来连接和管理远程Linux服务器。

Postman

API接口调试工具,平时用来测试开发好的接口,有时也用来格式化下JSON字符串。

PowerDesigner

数据库设计工具,平时用来设计数据库表,设计完成之后可以直接导出数据库表。

Navicat

数据库可视化工具,支持多种数据库,平时用来连接并管理数据库,项目上线的时候可以用来同步表结构。

RedisDesktop

Redis可视化工具,平时用来查看和管理Redis缓存中的数据,有时候需要清空缓存的时候就用到它了。

Robomongo

MongoDB可视化工具,平时用来查看和管理MongoDB中的数据。

Typora

平时用来写文章的Markdown编辑器,编辑与预览二合一,界面简洁且功能强大!

ProcessOn

作图工具,可以用来制作思维导图和流程图,mall项目的架构图就是用这个画的!

MindMaster

好用的思维导图制作工具,设计功能的时候可以用来整理下思路。

Snipaste

一款好用的截屏工具,文章中很多图片都是用这个截的。

ScreenToGif

用来制作Gif的工具,mall项目功能演示的Gif就是用这个做的。

官网地址

  • IntelliJ IDEA:www.jetbrains.com/idea/downlo…
  • X-shell:www.netsarang.com/download/so…
  • Postman:www.postman.com/
  • PowerDesigner:powerdesigner.de/
  • Navicat:www.formysql.com/xiazai.html
  • RedisDesktop:redisdesktop.com/download
  • Robomongo:robomongo.org/download
  • Typora:typora.io/
  • ProcessOn:processon.com/
  • MindMaster:www.edrawsoft.cn/mindmaster
  • Snipaste:www.snipaste.com/
  • ScreenToGif:www.screentogif.com

公众号

mall项目全套学习教程连载中,关注公众号第一时间获取。

公众号图片

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

终于和 null say 拜拜了,我超开心

发表于 2020-03-05

你好呀,我是沉默王二,一个和黄家驹一样身高,和刘德华一样颜值的程序员。从 10 年前我开始写第一行 Java 代码至今,一直觉得 null 在 Java 中是一个最特殊的存在,它既是好朋友,可以把不需要的变量置为 null 从而释放内存,提高性能;它又是敌人,因为它和大名鼎鼎且烦不胜烦的 NullPointerException(NPE)如影随形,而 NPE 的发明人 Tony Hoare 曾在 2009 年承认:“Null References 是一个荒唐的设计,就好像我赌输掉了十亿美元”。

你看,null 竟然是一个亦敌亦友的家伙。

通常,为了表示列表中的元素不存在,我们首先想到的就是返回 null,这种想法很合理,合理到无法反驳。我们来模拟一个实际的应用场景,假设小二现在要从数据库中获取一个姓名的列表,然后将姓名打印到控制台,对应的代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码public class NullDemo {  
    public static void main(String[] args) {
        List<String> names = getNamesFromDB();
        if (names != null) {
            for (String name : names) {
                System.out.println(name);
            }
        }
    }

    public static List<String> getNamesFromDB() {
        // 模拟此时没有从数据库获取到对应的姓名。
        return null;
    }
}

由于 getNamesFromDB() 方法返回了 null 来作为没有姓名列表的标志,那就意味着在遍历列表的时候要先对列表判空,否则将会抛出 NPE 错误,不信你把 if (names != null) 去掉试试,立马给你颜色看。

1
2
复制代码Exception in thread "main" java.lang.NullPointerException  
    at com.cmower.dzone.stopdoing3things.NullDemo.main(NullDemo.java:12)

那假如小二在遍历的时候不想判空又不想代码抛出 NPE 错误,他该怎么做呢?闭上你的大眼睛好好想一想。

嗯,报告,我想出来了,建议小二从数据库中获取姓名的时候返回长度为 0 的列表,来表示未找到数据的情况。代码示例如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public class Null2Length0Demo {  
    public static void main(String[] args) {
        List<String> names = getNamesFromDB();
        for (String name : names) {
            System.out.println(name);
        }
    }

    public static List<String> getNamesFromDB() {
        // 模拟此时没有从数据库获取到对应的姓名。
        return Collections.emptyList();
    }
}

注:Collections.emptyList() 用于返回一个不可变的空列表,能理解吧?假如不能理解的话,我再写一个返回可变的空列表的示例,你对比着感受一下就理解了。

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public class Null2Length0MutableDemo {  
    public static void main(String[] args) {
        List<String> names = getNamesFromDB();
        for (String name : names) {
            System.out.println(name);
        }
    }

    public static List<String> getNamesFromDB() {
        // 模拟此时没有从数据库获取到对应的姓名。
        return new ArrayList<>();
    }
}

new ArrayList<>() 返回的就是可变的,意味着你还可以改变这个列表的元素,比如说增加,删除是不可能的了,因为本身就没有元素可删。

你看,Collections.emptyList() 和 new ArrayList<>() 都可以替代 null,来减少打印列表时不必要的判空以及那个讨厌的家伙——NPE。

除了我这个想法之外,你还能想到其他的解决方案吗?来,再次闭上你的大眼睛,替小二想一想,没准你还能想到一个—— Java 8 新增的 Optional 类,一个容器类,可以存放任意类型的元素,如果值存在则
isPresent() 方法会返回 true;Optional 类提供了很多专业的方法而不用显式进行空值检查,从而巧妙地消除了 NPE。

来,先读示例为快!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码public class Null2OptionalDemo {  
    public static void main(String[] args) {
        Optional<List<String>> list = getNamesFromDB();
        list.ifPresent(names -> {
            for (String name : names) {
                System.out.println(name);
            }
        });
    }

    public static Optional<List<String>> getNamesFromDB() {
        boolean hasName = true;
        if (hasName) {
            String [] names = {"沉默王二", "一枚有趣的程序员", "微信搜索关注我"};
            return Optional.of(Arrays.asList(names));
        }
        return Optional.empty();
    }
}

看得不太懂?我来负责任地介绍一下,你们握个手。

假如数据库中存在姓名,则使用 Optional.of() 对返回值进行包装,从而返回一个 Optional 类型的对象。为什么不用构造方法呢,因为构造方法是 private 的(源码如下所示)。

1
2
3
java复制代码private Optional(T value) {  
    this.value = value;
}

那为什么要用 Optional.of() 呢?嗯,good question。继续上源码。

1
2
3
java复制代码public static <T> Optional<T> of(T value) {  
    return new Optional<>(Objects.requireNonNull(value));
}

1)如果 value 为 null,那么 Objects.requireNonNull(value) 就会抛出 NPE(嗯哼,总归是要碰面的,但好歹不用我们程序员主动 check 了)。

2)如果 value 不为 null,则通过 new 关键字创建正常的 Optional 对象。

假如数据库中不存在姓名呢?使用 Optional.empty() 作为返回值。来,继续上源码。

1
2
3
4
5
java复制代码public static<T> Optional<T> empty() {  
    @SuppressWarnings("unchecked")
    Optional<T> t = (Optional<T>) EMPTY;
    return t;
}

嗯哼,EMPTY 是什么玩意?

1
java复制代码private static final Optional<?> EMPTY = new Optional<>(null);

竟然是 Optional 类的一个私有常量(static + final)。怎么此刻我的脑子里想起了安徒生先生的寓言故事——皇帝的新衣,嗯,甭管了,反正“底层终究是丑陋的”。

这样的话,就可以使用 Optional 对象的 ifPresent() 方法来判断值是否存在,如果只需要处理值存在的情况,就可以使用 Lambda 表达式的方式直接打印姓名。

1
2
3
4
5
java复制代码list.ifPresent(names -> {  
    for (String name : names) {
        System.out.println(name);
    }
});

有点简单粗暴,对不对?但不管怎么说,终于可以在表象上和 null,NPE 说拜拜了,做人嘛,开心点。

好了,我亲爱的读者朋友,以上就是本文的全部内容了,能看到这里的就是最优秀的程序员。原创不易,莫要白票,请你为本文点赞个吧,这将是我写作更多优质文章的最强动力。

如果你觉得文章对你有点帮助,请微信搜索「 沉默王二 」第一时间阅读,回复【666】【1024】更有我为你精心准备的 500G 高清教学视频(已分门别类),以及大厂技术牛人整理的面经一份,本文源码已收录在码云,传送门~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

写博客必备!手把手教大家搭建免费图床,真香!

发表于 2020-03-04

前段时间松哥发了一篇文章,鼓励大家写博客多做积累(如果你没写过博客,现在就是你写博客的最佳时机),有小伙伴告诉松哥他已经开始实践了,感觉到自己的苦口婆心总算有了一点点作用还是蛮欣慰的。

今天想和大家分享一个写博客过程中经常遇到的技术性问题,就是博客图片存到哪里。因为松哥自己最近搞了一个免费的图床,感觉还是蛮香的,于是和大家伙分享下。

蛮荒时代

先说说我刚开始写博客的时候,就直接打开博客平台的后台编辑器开始写,写好之后保存发布,写的过程中可能需要上传图片,于是就做好图片上传,博客写好之后,本地啥都没剩下。过了很久,我想把曾经写的博客整理成一个系列,结果发现只能从网站上重新拷贝文档下来,down 下来的图片还都自动加上博客平台的水印,真是让人又气又恼。

后来就学乖了,博客一般我在本地用 vscode 写,需要的图片,都一一编号,按顺序摆放好,然后在 Markdown 中通过相对路径的方式引入,像下面这样:

写好之后,再拷贝到博客网站发表即可。这样,我本地就有一份存档,这也是为什么大家可以看到松哥整理的 274 页离线版 Spring Boot 教程,就是这个原因。这样有一天,如果我想对文章进行二次整理或者完善就会非常方便。

用 vscode 这样写当然没问题,但是也有很多小伙伴用的是一些笔记软件,印象笔记,有道笔记等等,如果用笔记软件的话,图片的引用就不会这么方便了,此时如果我们有自己的图床就会方便很多。

对于自己的图床,我就两方面的要求:

  • 稳定
  • 图片编号有序

稳定就不用多说了,图片编号有序,这个挺关键的,这关系到未来有一天你是否还能够方便的找到自己的图片,而免费图床都是用随机字符串做图片名的,这块不是很方便,所以就想着能不能自己搭建一个图床。

刚好之前听说七牛云有免费的 10G 空间可以用来搭建个人图床,一直没有机会尝试,这次就试了下,没想到整个过程还是蛮顺利的,于是记录下来和小伙伴们分享下。

搭建图床

搭建需要大家首先做两方面的准备工作:

  1. 有一个自己的已备案域名,早期这个不是必须的,但是现在这个是必须的。当我们在七牛云上申请好存储之后,会生成一个临时的二级域名,这个二级域名有效期 1 个月,所以我们得提前准备好一个自己的域名。
  2. 准备一个七牛云账户,并且实名认证下,这个就不需要我多说了吧,网站注册+上传身份证认证,这算是基本操作了。

好了,这两样准备好之后,我们就可以开始搭建我们的图床了。

首先在七牛云网站首页点击 管理控制台 进入到管理控制台页面,然后选择第一个对象存储,点击立即添加。

然后给新的存储空间取一个名字,选择合适的存储区域,由于我们是要搭建自己的图床,所以最后一项访问控制选择公开,切勿选错。

创建完成后,接下来,我们再来配置域名。

首先点击左边菜单栏的 CDN,然后再点击域名管理。

默认情况下,只有一个测试域名,我们点击上方的 添加域名,添加自己的域名进来,这里如果没有特殊需求,配置一下域名地址即可,其他都使用默认即可:

其他的选项,大家可以根据实际情况配置,我就不挨个说了,至少配置一个域名,后面就可以用了。配置完成之后,保存。

保存之后,这里会给你一个 CNAME,其实就是一个临时的二级域名:

把这个 CNAME 复制下来,然后打开自己的域名服务商控制台,添加一条 CNAME 解析记录,像下面这样:

这里的配置大家注意:

  • 由于一开始添加域名的时候,我们配置的是 img.javaboy.com,所以这里的主机记录就是 img,不要写错了
  • 记录类型就是 CNAME,这个不能改
  • 线路类型默认即可
  • 记录值就是你复制的 CNAME

其他都默认,配置完成之后,保存即可。

那么这个域名配置是什么含义呢?我在这里给大家稍为科普一下。我们把图片上传到七牛云之后,肯定会生成一个访问路径,例如 abc-javaboy.qiniu.com/111.png,这个访… 1 个月,1 个月之后就会失效。所以,我们要配置自己的域名,例如 img.javaboy.com,但是当我访问 img.javaboy.com/111.png 时,要让这个地址指向七牛云上去,怎么做呢?这个时候就要靠一条 CNAME 解析了,它可以把域名解析到别的域名上。

OK,这些事情全部做完之后,等待一会,十分钟左右,在 空间管理->空间概览 里边,看到 CDN 加速域名的状态为成功时,我们的所有配置就算都 OK 了。

接下来点到 空间管理->空间概览 ,点击文件上传,就可以上传文件了。文件上传成功后,点击右边的更多,就可以复制文件链接了。

整个操作过程还是比较 Easy 的,没有什么难度。

PicGo 上传

每次需要上传图片的时候,都要打开七牛云后台,这显然有些麻烦,于是有人开发了不少好用的客户端工具,松哥经过各种踩坑之后,感觉 PicGo 比较好用,在 Mac/Windows 上都支持,挺方便的,所以采用这个来做文件上传。

PicGo 可以从 GitHub 上下载,根据自己的系统选择合适的版本下载:github.com/Molunerfinn…。

下载安装,这个我就不用说了,普通软件安装。

安装好之后,在图床配置里边,配置一下七牛图床:

AccessKey、SecretKey 都可以从你的七牛后台的个人中心页面看到,直接复制过来即可。存储空间名就是你一开始创建的存储空间,访问地址则是你自己的域名,例如 img.javaboy.org。存储区域根据自己一开始选择的华南/华东/华北等,根据实际情况填写,但是这里注意填写区域简称:

配置完成后,以后就可以把图片拖拽到上传区上传了,上传成功后会返回图片访问路径,这些就比较简单了,不再赘述。

好了,这就是今天和小伙伴们分享的免费搭建自己的图床,写博客的小伙伴,可以试试哦。真香。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

《「面试突击」— Redis篇》-- Redis的主从复制?

发表于 2020-03-04

原文链接

能坚持别人不能坚持的,才能拥有别人未曾拥有的。

关注 编程大道 公众号,让我们一同坚持心中所想,一起成长!!

《【面试突击】— Redis篇》– Redis的主从复制?哨兵机制?

在这个系列里,我整理了一些面试题与大家分享,这是第三篇帮助想要在金三银四准备跳槽的同学。
我们一起巩固、突击面试官常问的一些面试题,加油!!

《【面试突击】— Redis篇》–Redis数据类型?适用于哪些场景?

《【面试突击】— Redis篇》–Redis的线程模型了解吗?为啥单线程效率还这么高?

面试官在问了上两次提到的问题之后,可能就会开始更加猛烈的攻势,一连串的Redis的知识点向你抛过来,你顶的住吗?

Redis如何保证高并发,高可用?

高并发: redis的单机吞吐量可以达到几万不是问题,如果想提高redis的读写能力,可以用redis的主从架构,redis天热支持一主多从的准备模式,单主负责写请求多从负责读请求,主从之间异步复制,把主的数据同步到从。

高可用: 首先利用redis的主从架构解决redis的单点故障导致的不可用,然后如果使用的是主从架构,那么只需要增加哨兵机制即可,就可以实现,redis主实例宕机,自动会进行主备切换。以此来达到redis的高可用。

你刚才说主从复制,那你能具体聊聊主从复制的原理吗?

在redis主从架构中,master负责接收写请求,写操作成功后返回客户端OK,然后后将数据异步的方式发送给多个slaver进行数据同步,不过从redis 2.8开始,slave node会周期性地确认自己每次复制的数据量。

当启动一个slave node的时候,它会发送一个PSYNC命令给master node。如果slave node是重新连接master node,那么master node仅仅会复制给slave部分缺少的数据; 否则如果是slave node第一次连接master node,那么会触发一次full resynchronization全量复制。

开始full resynchronization的时候,master会启动一个后台线程,开始生成一份RDB快照文件,同时还会将从客户端收到的所有写命令缓存在内存(内存缓冲区)中。RDB文件生成完毕之后,master会将这个RDB发送给slave,slave会先写入本地磁盘,然后再从本地磁盘加载到内存中。然后master会将内存中缓存的写命令发送给slave,slave也会同步这些数据。

另外slave node做复制的时候,是不会block master node的正常工作的,也不会block对自己的查询操作,它会用旧的数据集来提供服务; 但是复制完成的时候,需要删除旧数据集,加载新数据集,这个时候就会暂停对外服务了。slave node主要用来进行横向扩容,做读写分离,扩容的slave node可以提高读的吞吐量。slave与高可用性有很大的关系。

主从复制的过程中如果因为网络原因停止复制了会怎么样?

如果出现网络故障断开连接了,会自动重连的,从redis 2.8开始,就支持主从复制的断点续传,可以接着上次复制的地方,继续复制下去,而不是从头开始复制一份。

master如果发现有多个slave node都来重新连接,仅仅会启动一个rdb save操作,用一份数据服务所有slave node。

master node会在内存中创建一个backlog,master和slave都会保存一个replica offset,还有一个master id,offset就是保存在backlog中的。如果master和slave网络连接断掉了,slave会让master从上次的replica offset开始继续复制。

但是如果没有找到对应的offset,那么就会执行一次resynchronization全量复制。

说说什么是哨兵有什么作用吧

哨兵是redis集群架构中非常重要的一个组件,主要功能如下:

(1)集群监控,负责监控redis master和slave进程是否正常工作

(2)消息通知,如果某个redis实例有故障,那么哨兵负责发送消息作为报警通知给管理员

(3)故障转移,如果master node挂掉了,会自动转移到slave node上

(4)配置中心,如果故障转移发生了,通知client客户端新的master地址

哨兵本身也是分布式的,作为一个哨兵集群去运行,互相协同工作
(1)故障转移时,判断一个master node是宕机了,需要大部分的哨兵都同意才行,涉及到了分布式选举的问题
(2)即使部分哨兵节点挂掉了,哨兵集群还是能正常工作的,因为如果一个作为高可用机制重要组成部分的故障转移系统本身是单点的,那就很坑爹了。

目前采用的是sentinal 2版本,sentinal 2相对于sentinal 1来说,重写了很多代码,主要是让故障转移的机制和算法变得更加健壮和简单。

为什么redis哨兵集群只有2个节点无法正常工作?

如果两个哨兵实例,即两个redis实例,一主一从的模式。

则redis的配置quorum=1,表示一个哨兵认为master宕机即可认为master已宕机。

但是如果是机器1宕机了,那哨兵1和master都宕机了,虽然哨兵2知道master宕机了,但是这个时候,需要majority,也就是大多数哨兵都是运行的,2个哨兵的majority就是2(2的majority=2,3的majority=2,5的majority=3,4的majority=2),2个哨兵都运行着,就可以允许执行故障转移。

但此时哨兵1没了就只有1个哨兵了了,此时就没有majority来允许执行故障转移,所以故障转移不会执行。

主备切换时会有数据丢失的可能吗?

会有,而且有两种可能,一种是异步复制,一种是脑裂导致的数据丢失。

简单描述一下这两种数据丢失的过程吧

好的,第一种很好理解,因为master 到 slave的复制是异步的,所以可能有部分数据还没复制到slave的时候,master就宕机了,此时这些部分数据就丢失了。虽然master会做持久化,但是哨兵将slave提升为master后,如果旧的master这时候好了,会当做slave挂到新的master上,从新的master同步数据,原来的数据还是会丢失。

第二种,也就是说,某个master所在机器突然脱离了正常的网络,跟其他slave机器不能连接,但是实际上master还运行着,即集群分区现象。此时哨兵可能就会认为master宕机了,然后开启选举,将其他slave切换成了master.

这个时候,集群里就会有两个master,也就是所谓的脑裂。

此时虽然某个slave被切换成了master,但是可能client还没来得及切换到新的master,还继续向旧master写数据,这部分数据可能就丢失了。因此旧master再次恢复的加入到主从结构中时,会被作为一个slave挂到新的master上去,自己的数据会清空,重新从新的master复制数据,原来的写到旧master的数据就丢失了。

那有什么办法解决这个数据丢失的问题吗?

数据丢失的问题是不可避免的,但是我们可以尽量减少。
在redis的配置文件里设置参数

1
2
复制代码min-slaves-to-write 1
min-slaves-max-lag 10

min-slaves-to-write默认情况下是0,min-slaves-max-lag默认情况下是10。

上面的配置的意思是要求至少有1个slave,数据复制和同步的延迟不能超过10秒。如果说一旦所有的slave,数据复制和同步的延迟都超过了10秒钟,那么这个时候,master就不会再接收任何请求了。

上面两个配置可以减少异步复制和脑裂导致的数据丢失。

设置了这俩参数具体是怎么减少数据丢失的呢?

以上面配置为例,这两个参数表示至少有1个salve的与master的同步复制延迟不能超过10s,一旦所有的slave复制和同步的延迟达到了10s,那么此时master就不会接受任何请求。

我们可以减小min-slaves-max-lag参数的值,这样就可以避免在发生故障时大量的数据丢失,一旦发现延迟超过了该值就不会往master中写入数据。

那么对于client,我们可以采取降级措施,将数据暂时写入本地缓存和磁盘中,在一段时间后重新写入master来保证数据不丢失;也可以将数据写入kafka消息队列,隔一段时间去消费kafka中的数据。

通过上面两个参数的设置我们尽可能的减少数据的丢失,具体的值还需要在特定的环境下进行测试设置。

好的,今天回答的还不错,下一轮面试继续努力哦~

本系列文章在于面试突击,不是教程,要是细挖,能讲好多,而面试你只需要把这个原理说出来就行了,如果边讲边画图那就更好了。
该系列文章在于快速突击,快速拾遗,温习。

关注公众号 编程大道 ,第一时间获文章推送。

觉得好看,请 点赞、关注、转发 哦~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

关于面试中经常被问到的JDK8新特性

发表于 2020-03-04

序言

面试官经常会问到新版JDK新的特性, 尤其是JDK8的特性。

下面将首先讲解JDK版本发布情况,概括性介绍JDK8的主要新的特征。

主要包括:

  • 新的语言特性
  • 集合对象的修改
  • JVM新特性
  • HashMap的修改

关注“”非典型理科男“”公众号, 回复 jdk文档 获取JDK官方文档合集。

为什么面试经常问JDK8新的特性

JDK8是2014年3月发行版本, 面试官会在面试中问JDK8的一些新的特性。

Java从已经从JDK1.0版本发展到了最新的JDK13, 为什么目前Jdk8经常被问到呢?

大概有一些原因:

第一、JDK8仍然是最受欢迎的JDK版本。

从skyn网站的《2018年JVM生态报告》中可以看到截止到2018年, JDK8仍然是生成环境使用最多的JDK版本。

第二、新的发版周期,让很多公司无所适从。JDK9非LTS版本。
JDK8之后Oracle使用了新的发布周期。

见《javaSE support roadMap》》

从JDK版本历史发行看, Oracle从Java9开始实行了新的版本发布规则,缩短了新版本发布周期。

JDK1.6到JDK1.7经过了5年的时间

JDK1.7到JDK1.8经过了3年

JDK1.8到JDK1.9经过了3年

之后的版本 每6个月发布一个版本

每3年发布一个LTS版本(最新的LTS版本是JDK11)

几乎 三分之一 的开发人员还不知道他们将如何应对新的发布周期。

第三、JDK8 引入了很多非常实用和长期的影响

  1. JDK8引入新的语法特性,比如Lambda表达式,默认方法,方法引用,新增新的日期处理类
  2. JDK8为Collection新增Stream流式接口, 修改HashMap和ConcurrentHashMap实现
  3. JDK8修改了JVM内存模型, 实用metaSpace代替永久代
  4. JDK新增并发接口和实现, 包括新增CompletableFuture、为ConcurrentHashMap新增支持Stream方法、新增StampedLock

下面介绍几个面试过程经常被问到的几个新特性

Lambda表达式、方法引用和默认方法

1. Lambda表达式

Lambda表达式允许把函数作为一个方法的参数。

有几种常见的Lambda表达式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码// 1. 不需要参数,返回值为 5  
() -> 5

// 2. 接收一个参数(数字类型),返回其2倍的值
x -> 2 * x

// 3. 接受2个参数(数字),并返回他们的差值
(x, y) -> x – y

// 4. 接收2个int型整数,返回他们的和
(int x, int y) -> x + y

// 5. 接受一个 string 对象,并在控制台打印,不返回任何值(看起来像是返回void)
(String s) -> System.out.print(s)

2. 方法引用

JDK8支持了四种方式方法引用

类型 方法引用
引用静态方法 ContainingClass::staticMethodName
引用特定对象的实例方法 containingObject::instanceMethodName
引用特定类型的任意对象的实例方法 String::compareToIngoreCase
引用构造函数 ClassName::new

3. 默认方法和静态方法

JDK1.8支持在接口中定义默认方法和静态方法, 默认方法可以被接口实现引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
复制代码package defaultmethods;

import java.time.*;

public interface TimeClient {
void setTime(int hour, int minute, int second);
void setDate(int day, int month, int year);
void setDateAndTime(int day, int month, int year,
int hour, int minute, int second);
LocalDateTime getLocalDateTime();

// 静态方法
static ZoneId getZoneId (String zoneString) {
try {
return ZoneId.of(zoneString);
} catch (DateTimeException e) {
System.err.println("Invalid time zone: " + zoneString +
"; using default time zone instead.");
return ZoneId.systemDefault();
}
}

// 默认方法
default ZonedDateTime getZonedDateTime(String zoneString) {
return ZonedDateTime.of(getLocalDateTime(), getZoneId(zoneString));
}
}

Colletion的修改

JDK1.8增强了Collection FrameWork, 支持了lambda, 流和聚合操作。

改动有两个方面:

  • 支持了lambda, 流和聚合操作
  • 改进的类型推断

改进的类型推断

Java编译器利用目标类型来推断通用方法调用的类型参数。

考虑以下示例:

1
2
3
复制代码List <String> stringList = new ArrayList <>();
stringList.add("A");
stringList.addAll(Arrays.asList());

Java管理扩展(JMX)提供了对诊断命令的远程访问。

目前,不考虑泛型,该方法addAll将Collection实例作为其参数,然后该方法Arrays.asList返回一个List实例。这是有效的,因为List是的子类型Collection。

现在考虑泛型,的目标类型addAll为Collection<? extends String>,并Arrays.asList返回一个List实例。在此示例中,Java SE 8编译器可以推断类型变量的T值为String。编译器从目标类型推断出这一点Collection<? extends String>。

Java SE 7和更早版本的编译器不接受此代码,因为它们不使用目标类型来推断方法调用参数的类型。例如,Java SE 7编译器生成类似于以下内容的错误消息:

1
2
复制代码error: no suitable method found for addAll(List<Object>) ...
method List.addAll(Collection<? extends String>) is not applicable (actual argument List<Object> cannot be converted to Collection<? extends String> by method invocation conversion)

因此,在Java编译器无法推断类型的情况下,必须使用显式指定类型变量的值。例如,以下在Java SE 7中有效:

1
2
3
复制代码List <String> stringList = new ArrayList <>();
stringList.add(“ A”);
stringList.addAll(Arrays.<String> asList());

JVM新特性

JDK8在JVM中修改重要有:

  • 新增JVM工具:jdeps提供了用于分析类文件的命令行工具。
  • 使用metaSpace代替永久区
  • 新增NMT(Native Memeory Trace)本地内存跟踪器,参见NMT

HashMap变化

JDK8优化了HashMap的实现, 主要优化点包括:

  • 将链表方式修改成链表或者红黑树的形式
  • 修改resize的过程,解决JDK7在resize在并发场景下死锁的隐患
  • JDK1.7存储使用Entry数组, JDK8使用Node或者TreeNode数组存储

当链表长度大于8是链表的存储结构会被修改成红黑树的形式。

查询效率从O(N)提升到O(logN)。链表长度小于6时,红黑树的方式退化成链表。

JDK7链表插入是从链表头部插入, 在resize的时候会将原来的链表逆序。

JDK8插入从链表尾部插入, 因此在resize的时候仍然保持原来的顺序。

👁 关注微信公众号:非典型理科男 回复:JDK文档 获取JDK官方文档。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…829830831…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%