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

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


  • 首页

  • 归档

  • 搜索

拜托,不要在问我Transactional注解了

发表于 2020-04-25

前言

在一个阳光明媚的周五,我正开心的敲着代码,突然看到一个技术交流群中正在火热的讨论着某个话题,好奇心驱使着我点开看了一下, 原来是某位同僚正在远程面试,面试官出了这样的一道题

我心里默默的笑了笑,这特喵的该不会是哪个crud仔自己排查不出来,所以找面试者来套方案的吧。

本想帮一帮这位同僚,奈何公司规定:上班时间不允许装逼。

ps:突然想到自己似乎也没有对这方面知识做过系统的学习,于是默默的拿出小本本记录了下来。

ps:先明确一点,要使用事务,首先你的数据库肯定得支持事务,你说你数据库都不支持事务,那就算神仙来了也没用的好吧。

事务不生效的场景

非事务方法内部调用不生效

Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码/**  
 * 用于验证事务不生效的场景
 * @author hcq
 * @date 2020/4/24 22:27
 */
@RestController
@RequestMapping("/student/transaction")
@AllArgsConstructor
public class TransactionController {

    private TransactionService transactionService;

    /**
     * 非事务方法内部调用 事务不生效
     */
    @RequestMapping("/innerCall")
    public String  innerCall(){
        Student stu=new Student(IdUtils.get32UUID(),"张三",22,"男");
        transactionService.innerCall(stu);
        return "SUCCESS";
    }


}

Service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码@Service  
@AllArgsConstructor
public class TransactionServiceImpl implements TransactionService {

    private StudentMapper studentMapper;

    @Override
    public void innerCall(Student student) {
        saveRecordAndThrowRuntimeException(student);
    }

    @Override
    @Transactional(rollbackFor = {})
    public void saveRecordAndThrowRuntimeException(Student student){
        studentMapper.insert(student);
        throw new RuntimeException("抛出运行时异常");
    }

}

可以看到最后的结果是,数据库中成功保存了这个数据 (也就是事务没有生效)

然后我们再来分析一下为什么事务会不生效(这就很考验你的java功底喽)。

之前我也因为这个问题困扰过一段时间,后来所幸遇到高人指点,高人告诉我“ java中的方法调用,调用的只是代码片段”,也就说方法间的调用其实和你直接把另一个方法的代码复制过来是一样的效果。所以代码在执行时就变成了下面这个样子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码@Service  
@AllArgsConstructor
public class TransactionServiceImpl implements TransactionService {

    private StudentMapper studentMapper;

    @Override
    public void innerCall(Student student) {
        studentMapper.insert(student);
        throw new RuntimeException("抛出运行时异常");
    }

    @Override
    @Transactional(rollbackFor = {})
    public void saveRecordAndThrowRuntimeException(Student student){
        studentMapper.insert(student);
        throw new RuntimeException("抛出运行时异常");
    }

}

看到这里你应该已经明白了为什么事务没生效了吧。(因为压根就没有@Transaction注解啊 )

对此我还有另一种解释:Controller中所依赖的Service其实是IOC提供的一个代理对象,而这个代理对象在调用具体的方法时,会通过判断该方法上面是否包含@Transactional注解来决定是否要开启事务,而这个innerCall方法没有包含此注解,所以Spring代理对象会认为此方法不需要开启事务,在innerCall方法调用事务方法的过程中,其实方法的调用者已经由Spring代理对象转换为了这个类的原生对象(也就是this关键字)。而我们的这个原生对象是没有对@Transaction注解做任何处理的,所以事务自然也不会生效。

ps:就算看不懂也没关系哦,这个作者的脑洞有点大。哈哈

抛出检查异常事务不回滚(需指定要回滚的异常才会回滚)

Controller

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
java复制代码@RestController  
@RequestMapping("/student/transaction")
@AllArgsConstructor
public class TransactionController {

    private TransactionService transactionService;

    /**
     * 检查异常(Checked Exception)事务不回滚
     */
    @RequestMapping("/checkedException")
    public String  checkedException() throws IOException {
        Student stu=new Student(IdUtils.get32UUID(),"张三",22,"男");
        transactionService.checkedException(stu);
        return "SUCCESS";
    }

    /**
     * 检查异常(Checked Exception)事务回滚
     */
    @RequestMapping("/checkedExceptionAndRollBack")
    public String  checkedExceptionAndRollBack() throws IOException {
        Student stu=new Student(IdUtils.get32UUID(),"张三",22,"男");
        transactionService.checkedExceptionAndRollBack(stu);
        return "SUCCESS";
    }
}

Service

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
java复制代码@Service  
@AllArgsConstructor
public class TransactionServiceImpl implements TransactionService {

    private StudentMapper studentMapper;

    @Override
    @Transactional(rollbackFor = {})
    public void checkedException(Student stu) throws IOException {
        studentMapper.insert(stu);
        throw new IOException("找不到XXX.xml");
    }

    /**
     * 指明IOException异常进行回滚
     * 此处需要注意的是不能在本方法中把IOException异常catch掉,否则也会导致事务无法回滚
     * @param stu 学生信息
     * @throws IOException IO异常
     */
    @Override
    @Transactional(rollbackFor = {IOException.class})
    public void checkedExceptionAndRollBack(Student stu) throws IOException {
        checkedException(stu);
    }
}

最终的执行结果是:

  • checkedException:成功的添加了一条记录(事务没有回滚)
  • checkedExceptionAndRollBack:没有添加记录,事务回滚

“ 抛出检查异常事务不会回滚 ” 和 “ 抛出检查异常事务不会生效 ” ,这是两个不同的概念,事务有没有生效是由IOC代理对象有没有捕获到异常决定的,而事务捕获到检查异常时要不要回滚,则应该是由你来告诉这个代理对象。

相关特性

传播机制

传播机制是Spring中定义的一个概念,在mysql中并不存在这一概念,它规定了多个事务之间应该以何种方式进行传播。

Spring为此定义了7种事务的传播途径

传播途径 描述/结论
Propagation.REQUIRED(必要的) Spring默认的传播机制,若存在事务则在原事务中运行,若不存在事务则创建一个事务
Propagation.SUPPORTS(支持) 支持当前事务,如果不存在事务,则以非事务方式执行。
Propagation.MANDATORY(强制的) 强势要求使用事务,若当前环境不存在事务则抛出异常
Propagation.REQUIRES_NEW(新事务) 把当前事务挂起(暂停),创建一个新的事务去执行,执行完毕后恢复原事务
Propagation.NOT_SUPPORTED(不支持) 把当前事务挂起(暂停),以非事务的方式去运行,运行完毕后恢复原事务
Propagation.NEVER(决不使用) 绝不使用事务,如果当前环境存在事务则抛出异常
Propagation.NESTED(嵌套的) 若当前环境存在事务则嵌套在当前事务中执行(类似于REQUIRED)

Propagation.REQUIRED

Spring默认的传播机制,若存在事务则在原事务中运行,若不存在事务则创建一个事务

Controller

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
java复制代码/**  
 * 用于验证事务Propagation.REQUIRED级别的传播机制
 *  Propagation.REQUIRED 是默认的传播机制
 * @author hcq
 * @date 2020/4/24 22:34
 */
@RestController
@RequestMapping("/student/propagation/required")
@AllArgsConstructor
@Slf4j
public class RequiredPropagationController {

    private StudentMapper studentMapper;
    private RequiredPropagationService requiredPropagationService;

    /**
     * 结论1:不存在事务则创建一个事务 (经常被使用到的场景)
     */
    @RequestMapping("/noExistTransaction")
    public String noExistTransaction(){
        Student stu=new Student(IdUtils.get32UUID(),"张三",22,"男");
        requiredPropagationService.saveRecordAndThrowRuntimeException(stu);
        return "SUCCESS";
    }

    /**
     * 结论2:若存在事务则在原事务中运行 (经常被使用到的场景)
     *  通过观察test001是否被回滚?可以验证Controller中与Service中的是否是同一个事务
     *  
     *  若test001被回滚则说明是同一个事务
     *  若test001未回滚则表示不是同一个事务
     */
    @RequestMapping("/existTransaction")
    @Transactional(rollbackFor = {})
    public String  existTransaction(){
        studentMapper.insert(new Student("test001","小红",22,"女"));
        Student stu=new Student(IdUtils.get32UUID(),"张三",22,"男");
        try {
            requiredPropagationService.saveRecordAndThrowRuntimeException(stu);
        }catch (Exception e){
           log.info("捕获异常,防止其影响观察结果!");
        }
        return "SUCCESS";
    }
}

Service

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码@Service  
@AllArgsConstructor
public class RequiredPropagationServiceImpl implements RequiredPropagationService {

    private StudentMapper studentMapper;

    @Override
    @Transactional(propagation = Propagation.REQUIRED, rollbackFor = {})
    public void saveRecordAndThrowRuntimeException(Student student) {
        studentMapper.insert(student);
        throw new RuntimeException("抛出运行时异常");
    }
}

执行结果:

  • 场景1:事务回滚,数据库中无数据,说明确实存在事务,结论1正确
  • 场景2:事务回滚,数据库中无数据。且后台打印了UnexpectedRollbackException异常日志,提示:”Transaction rolled back because it has been marked as rollback-only“,我们来分析一下这个过程,首先service抛出异常后将事务标记为仅回滚状态,然后controller中将service抛出的异常catch掉后想要提交事务,但是发现此事务已经被标记为仅回滚,所以又抛出了UnexpectedRollbackException异常。由此可得出结论2正确

Propagation.SUPPORTS

验证过程大同小异,篇幅原因就不把代码贴出来了,感兴趣的同学可以通过文章末尾的github链接去下载源码

Propagation.MANDATORY

验证过程大同小异,篇幅原因就不把代码贴出来了,感兴趣的同学可以通过文章末尾的github链接去下载源码

Propagation.REQUIRES_NEW

验证过程大同小异,篇幅原因就不把代码贴出来了,感兴趣的同学可以通过文章末尾的github链接去下载源码

需要注意的是在此级别下可能会由于代码原因从而导致数据库死锁。

Propagation.NOT_SUPPORTED

验证过程大同小异,篇幅原因就不把代码贴出来了,感兴趣的同学可以通过文章末尾的github链接去下载源码

需要注意的是在此级别下可能会由于代码原因从而导致数据库死锁。

Propagation.NEVER

验证过程大同小异,篇幅原因就不把代码贴出来了,感兴趣的同学可以通过文章末尾的github链接去下载源码

Propagation.NESTED

验证过程大同小异,篇幅原因就不把代码贴出来了,感兴趣话同学可以通过文章末尾的github链接去下载源码

此级别类似于REQUIRES_NEW,但是通过内部事务可以读到外部事务数据的特性,避免了REQUIRES_NEW这个级别下的死锁现象

隔离级别

Spring、mysql都有这一概念,它规定了事务之间的数据是否应该对其他事务可见。

这句话多少还是带有一点歧义,再具体一点来说应该是规定了一个事务是否可以访问到其他事务已经提交的或则是未提交的这部分数据

Spring与Mysql中都分别定义以下4种隔离级别,此外Spring还定义了一种DEFAULT的隔离级别,表示默认采用数据库隔离级别

隔离级别 描述信息
READ_UNCOMMITTED(读未提交) 可以读到其他事务未未提交的数据,此隔离级别下会出现脏读、幻读、不可重复读等问题
READ_COMMITTED(读已提交) 只能读到其他事务已经提交的数据,避免了脏读的现象,但是依然会出现幻读与不可重复读的问题
REPEATABLE_READ(可重复读) 通过MVCC解决了不可重复读的问题,然后又通过行锁加间隙锁来避免了幻读的现象,此隔离级别可以避免脏读、幻读与不可重复读等问题。同时这也是INNODB默认的隔离级别
SERIALIZABLE(序列化读) 最严格的隔离级别,规定每个事务必须按照顺序进行读取(也就是不允许并发)

READ_UNCOMMITTED(读未提交)

在这个隔离级别下,我们首先来了解一下什么是脏读

脏读

脏读又称无效数据的读出,是指在数据库访问中,事务T1将某一值修改,然后事务T2读取该值,此后T1因为某种原因撤销对该值的修改,这就导致了T2所读取到的数据是无效的。

ps:摘自百度百科

OK,下面我们通过代码来模拟这个现象,首先先看代码

Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码/**  
 * 用于模拟读未提交(READ_UNCOMMITTED)隔离级别下的脏读现象
 * @author hcq
 * @date 2020/4/25 18:19
 */
@RestController
@RequestMapping("/student/isolation")
@AllArgsConstructor
public class ReadUnCommitController {

    private ReadUnCommitService readUnCommitService;

    /**
     * 模拟脏读的现象
     */
    @RequestMapping("/readUnCommit")
    public String readUnCommit(){
        // 从数据库中查找id等于1943b39d5d684c60895614e3d4f7357f的学生信息
        String stuId="1943b39d5d684c60895614e3d4f7357f";
        Student student = readUnCommitService.getStudentById(stuId);
        return JSONObject.toJSONString(student);
    }
}

Service

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码@Service  
@AllArgsConstructor
public class ReadUnCommitServiceImpl implements ReadUnCommitService {

    private StudentMapper studentMapper;

    @Override
    @Transactional( isolation = Isolation.READ_UNCOMMITTED,rollbackFor = {})
    public Student getStudentById(String stuId) {
        return  studentMapper.selectById(stuId);
    }

}

然后我在数据库手动开启了一个事务,并添加了一条学生信息(注意此时我并没有提交这个事务,所以在数据库的表中是查不到这个记录的)。

然后通过接口进行查询,可以看到这条数据已经被查了出来,然后我再把数据库中添加数据的事务给回滚掉(一定要记得回滚或者是提交,否则的话这个事务就会一直占用着这把锁)。这样用户就查到了一条不存在的记录,这也就是所谓的脏读现象。

READ_COMMITTED(读已提交)

同样的套路:在这个隔离级别下,我们先来了解一下什么是不可重复读

不可重复读

不可重复读,是指在数据库访问中,一个事务范围内两个相同的查询却返回了不同数据。

ps:也是摘自百度百科。真香。。

Controller

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
java复制代码/**  
 * 用于模拟读已提交(READ_COMMITTED)隔离级别下的不可重复读、幻读现象
 * 此隔离级别下会出现的问题:幻读、不可重复读
 * @author hcq
 * @date 2020/4/25 18:19
 */
@RestController
@RequestMapping("/student/isolation")
@AllArgsConstructor
public class ReadCommitController {

    private ReadCommitService readCommitService;

    /**
     * 模拟不可重复读的现象
     */
    @RequestMapping("/readCommit")
    public String readUnCommit(){
        // 从数据库中查找id等于1943b39d5d684c60895614e3d4f7357f的学生信息
        String stuId="1943b39d5d684c60895614e3d4f7357f";
        Student student = readCommitService.getStudentById(stuId);
        return JSONObject.toJSONString(student);
    }


}

Service

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
java复制代码@Service  
@AllArgsConstructor
@Slf4j
public class ReadCommitServiceImpl implements ReadCommitService {

    private StudentMapper studentMapper;

    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED,rollbackFor = {})
    public Student getStudentById(String stuId) {
        Student stu=studentMapper.selectById(stuId);
        log.info("第一次读到的数据为:{}", JSONObject.toJSONString(stu));

        // 别问我为啥要休眠30秒,因为我要在数据库改数据提交事务呀
        try {
            Thread.sleep(30*1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 别问我为啥不直接selectById,还不是因为这令人难受的mybatis一级缓存
        stu=studentMapper.selectList(new QueryWrapper<>()).get(0);
        log.info("第二次读到的数据为:{}",JSONObject.toJSONString(stu));
        return  stu;
    }

}

执行结果:同一个事务内两次查询同一条记录,却返回了不一样的数据(第二条数据是我在数据库手动修改的)

幻读

幻读是指当事务不是独立执行时发生的一种现象。事务A读取与搜索条件相匹配的若干行。事务B以插入或删除行等方式来修改事务A的结果集,然后再提交。

ps:还是摘自百度百科。

Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码/**  
 * 用于模拟读已提交(READ_COMMITTED)隔离级别下的不可重复读、幻读现象
 * 此隔离级别下会出现的问题:幻读、不可重复读
 * @author hcq
 * @date 2020/4/25 18:19
 */
@RestController
@RequestMapping("/student/isolation")
@AllArgsConstructor
public class ReadCommitController {

    private ReadCommitService readCommitService;

    /**
     * 模拟幻读
     */
    @RequestMapping("/phantomRead")
    public List<Student> phantom(){
        Student stu=new Student("1943b39d5d684c60895614e3d4f7357f","小王",22,"男");
        return readCommitService.modifyAllStudentAndQueryAll(stu);
    }

}

Service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码@Service  
@AllArgsConstructor
@Slf4j
public class ReadCommitServiceImpl implements ReadCommitService {

    private StudentMapper studentMapper;

    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = {})
    public List<Student> modifyAllStudentAndQueryAll(Student stu) {
        // 更改表中所有的表数据
        studentMapper.update(stu, new UpdateWrapper<>());
        // 休眠30秒,因为我要在数据库添加数据提交事务
        try {
            Thread.sleep(30 * 1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 返回表中的所有数据
        return studentMapper.selectList(new QueryWrapper<>());
    }

}

执行结果:可以看到在同一个事务中执行全表更新时只更新了4条记录,然后查询全表数据时查出了7条数据。并且有3条数据没有被更新,这些没有没更新的行一般被称为幻影行

REPEATABLE_READ(可重复读)

可重复读隔离级别的验证流程跟读已提交的验证流程是一模一样的,只需要修改对应的隔离级别然后观察幻读与不可重复读现象即可。

感兴趣的小伙伴可以下载源码自行翻阅

SERIALIZABLE(序列化读)

时间原因、这个先行略过,后期再补上

事务超时时间

Spring中的概念,默认永不超时,数据库中没有事务超时时间(数据库超时一般指的是连接超时或则是锁等待超时)。在Spring中如果一个事务超时了,那么这个事务内就无法执行任何sql语句,否则将会抛出异常。但是如果此事务内所有的语句都有已经执行完成了,那么这个超时的事务还是可以被提交的。

感兴趣的同学可以通过文章末尾的github链接去下载源码

事务的ACID

  • 原子性(A)是指一个事务内所有的操作在逻辑上都表现为一个操作
  • 一致性(C)是指一个事务只有两种状态:已执行与未执行,不会存在执行到一半的情况。(执行到一半服务器宕机,这个事务会进行回滚,也就相当于是未执行状态)
  • 隔离性(I)描述了多个事务之间的数据是否应该对其他事务的可见性
  • 持久性(D)是指一个事务一旦执行完被提交后,所做的更改就会被永久保存

既然用到了框架,就应该明白框架帮自己做了什么事情,这样才能够在出现问题的时候,不至于束手无策。

源码地址:github.com/hechaoqi123…

本文转载自: 掘金

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

让ConcurrentHashMap成为你的面试加分点 JD

发表于 2020-04-24

在这里插入图片描述
因为上篇文章HashMap已经讲解的很详细了,因此此篇文章会简单介绍思路,再学习并发HashMap就简单很多了,上一篇文章中我们最终知道HashMap是线程不安全的,因此在老版本JDK中提供了HashTable来实现多线程级别的,改变之处重要有以下几点。

  1. HashTable的put, get,remove等方法是通过synchronized来修饰保证其线程安全性的。
  2. HashTable是 不允许key跟value为null的。
  3. 问题是synchronized是个关键字级别的==重量锁==,在get数据的时候任何写入操作都不允许。相对来说性能不好。因此目前主要用的ConcurrentHashMap来保证线程安全性。

ConcurrentHashMap主要分为JDK<=7跟JDK>=8的两个版本,ConcurrentHashMap的空间利用率更低一般只有10%~20%,接下来分别介绍。

JDK7

先宏观说下JDK7中的大致组成,ConcurrentHashMap由Segment数组结构和HashEntry数组组成。Segment是一种可重入锁,是一种数组和链表的结构,一个Segment中包含一个HashEntry数组,每个HashEntry又是一个链表结构。正是通过Segment==分段锁==,ConcurrentHashMap实现了高效率的并发。缺点是并发程度是有segment数组来决定的,并发度一旦初始化无法扩容。
先绘制个ConcurrentHashMap的形象直观图。
在这里插入图片描述
要想理解currentHashMap,可以简单的理解为将数据分表分库。 ConcurrentHashMap是由 Segment 数组 结构和HashEntry 数组 结构组成。

  • Segment 是一种可重入锁ReentrantLock的子类 ,在 ConcurrentHashMap 里扮演锁的角色,HashEntry则用于存储键值对数据。
  • ConcurrentHashMap 里包含一个 Segment 数组来实现锁分离,Segment的结构和 HashMap 类似,一个 Segment里包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素, 每个 Segment守护者一个 HashEntry 数组里的元素,当对 HashEntry数组的数据进行修改时,必须首先获得它对应的 Segment 锁。
  1. 我们先看下segment类:
1
2
3
复制代码static final class Segment<K,V> extends ReentrantLock implements Serializable {  
     transient volatile HashEntry<K,V>[] table; //包含一个HashMap 可以理解为
}

可以理解为我们的每个segment都是实现了Lock功能的HashMap。如果我们同时有多个segment形成了segment数组那我们就可以实现并发咯。

  1. 我们看下currentHashMap的构造函数,先总结几点。
  1. segment的数组大小最终一定是2的次幂
    1. 每一个segment里面包含的table(HashEntry数组)初始化大小也一定是2的次幂
    2. 这里设置了若干个用于位计算的参数。
    3. initialCapacity:初始容量大小 ,默认16。
    4. loadFactor: 扩容因子,默认0.75,当一个Segment存储的元素数量大于initialCapacity* loadFactor时,该Segment会进行一次扩容。
    5. concurrencyLevel:并发度,默认16。并发度可以理解为程序运行时能够同时更新ConccurentHashMap且不产生锁竞争的最大线程数,实际上就是ConcurrentHashMap中的分段锁个数,即Segment[]的数组长度。如果并发度设置的过小,会带来严重的锁竞争问题;如果并发度设置的过大,原本位于同一个Segment内的访问会扩散到不同的Segment中,CPU cache命中率会下降,从而引起程序性能下降。

构造函数详解:

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
复制代码   //initialCapacity 是我们保存所以KV数据的初始值  
   //loadFactor这个就是HashMap的负载因子
   // 我们segment数组的初始化大小
      @SuppressWarnings("unchecked")
       public ConcurrentHashMap(int initialCapacity,
                                float loadFactor, int concurrencyLevel) {
           if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
               throw new IllegalArgumentException();
           if (concurrencyLevel > MAX_SEGMENTS) // 最大允许segment的个数,不能超过 1< 24
               concurrencyLevel = MAX_SEGMENTS;
           int sshift = 0; // 类似扰动函数
           int ssize = 1; 
           while (ssize < concurrencyLevel) {
               ++sshift;
               ssize <<= 1; // 确保segment一定是2次幂
           }
           this.segmentShift = 32 - sshift;  
           //有点类似与扰动函数,跟下面的参数配合使用实现 当前元素落到那个segment上面。
           this.segmentMask = ssize - 1; // 为了 取模 专用
           if (initialCapacity > MAXIMUM_CAPACITY) //不能大于 1< 30
               initialCapacity = MAXIMUM_CAPACITY;
   
           int c = initialCapacity / ssize; //总的数组大小 被 segment 分散后 需要多少个table
           if (c * ssize < initialCapacity)
               ++c; //确保向上取值
           int cap = MIN_SEGMENT_TABLE_CAPACITY; 
           // 每个table初始化大小为2
           while (cap < c) // 单独的一个segment[i] 对应的table 容量大小。
               cap <<= 1;
           // 将table的容量初始化为2的次幂
           Segment<K,V> s0 =
               new Segment<K,V>(loadFactor, (int)(cap * loadFactor), (HashEntry<K,V>[])new HashEntry[cap]);
               // 负载因子,阈值,每个segment的初始化大小。跟hashmap 初始值类似。
               // 并且segment的初始化是懒加载模式,刚开始只有一个s0,其余的在需要的时候才会增加。
           Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
           UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
           this.segments = ss;
       }
  1. hash
    不管是我们的get操作还是put操作要需要通过hash来对数据进行定位。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码   //  整体思想就是通过多次不同方式的位运算来努力将数据均匀的分不到目标table中,都是些扰动函数  
   private int hash(Object k) {
       int h = hashSeed;
       if ((0 != h) && (k instanceof String)) {
           return sun.misc.Hashing.stringHash32((String) k);
       }
       h ^= k.hashCode();
       // single-word Wang/Jenkins hash.
       h += (h <<  15) ^ 0xffffcd7d;
       h ^= (h >>> 10);
       h += (h <<   3);
       h ^= (h >>>  6);
       h += (h <<   2) + (h << 14);
       return h ^ (h >>> 16);
   }
  1. get
    相对来说比较简单,无非就是通过hash找到对应的segment,继续通过hash找到对应的table,然后就是遍历这个链表看是否可以找到,并且要注意 get的时候是==没有加锁==的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码   public V get(Object key) {  
       Segment<K,V> s;
       HashEntry<K,V>[] tab;
       int h = hash(key); // JDK7中标准的hash值获取算法
       long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE; // hash值如何映射到对应的segment上
       if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null && (tab = s.table) != null) {
           //  无非就是获得hash值对应的segment 是否存在,
           for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
                    (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
                e != null; e = e.next) {
               // 看下这个hash值对应的是segment(HashEntry)中的具体位置。然后遍历查询该链表
               K k;
               if ((k = e.key) == key || (e.hash == h && key.equals(k)))
                   return e.value;
           }
       }
       return null;
   }
  1. put
    相同的思路,先找到hash值对应的segment位置,然后看该segment位置是否初始化了(因为segment是==懒加载==模式)。选择性初始化,最终执行put操作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码   @SuppressWarnings("unchecked")  
   public V put(K key, V value) {
       Segment<K,V> s;
       if (value == null)
           throw new NullPointerException();
       int hash = hash(key);// 还是获得最终hash值
       int j = (hash >>> segmentShift) & segmentMask; // hash值位操作对应的segment数组位置
       if ((s = (Segment<K,V>)UNSAFE.getObject          
            (segments, (j << SSHIFT) + SBASE)) == null)
           s = ensureSegment(j); 
       // 初始化时候因为只有第一个segment,如果落在了其余的segment中 则需要现初始化。
       return s.put(key, hash, value, false);
       // 直接在数据中执行put操作。
   }

其中put操作基本思路跟HashMap几乎一样,只是在开始跟结束进行了加锁的操作tryLock and unlock,然后JDK7中都是先扩容再添加数据的,并且获得不到锁也会进行==自旋==的tryLock或者lock阻塞排队进行等待(同时获得锁前提前new出新数据)。

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
49
50
51
52
53
54
55
56
57
复制代码final V put(K key, int hash, V value, boolean onlyIfAbsent) {  
    // 在往该 segment 写入前,需要先获取该 segment 的独占锁,获取失败尝试获取自旋锁
    HashEntry<K,V> node = tryLock() ? null :
        scanAndLockForPut(key, hash, value);
    V oldValue;
    try {
        // segment 内部的数组
        HashEntry<K,V>[] tab = table;
        // 利用 hash 值,求应该放置的数组下标
        int index = (tab.length - 1) & hash;
        // first 是数组该位置处的链表的表头
        HashEntry<K,V> first = entryAt(tab, index);
 
        for (HashEntry<K,V> e = first;;) {
            if (e != null) {
                K k;
                if ((k = e.key) == key ||
                    (e.hash == hash && key.equals(k))) {
                    oldValue = e.value;
                    if (!onlyIfAbsent) {
                        // 覆盖旧值
                        e.value = value;
                        ++modCount;
                    }
                    break;
                }
                // 继续顺着链表走
                e = e.next;
            }
            else {
                // node 是不是 null,这个要看获取锁的过程。没获得锁的线程帮我们创建好了节点,直接头插法
                // 如果不为 null,那就直接将它设置为链表表头;如果是 null,初始化并设置为链表表头。
                if (node != null)
                    node.setNext(first);
                else
                    node = new HashEntry<K,V>(hash, key, value, first);
 
                int c = count + 1;
                // 如果超过了该 segment 的阈值,这个 segment 需要扩容
                if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                    rehash(node); // 扩容
                else
                    // 没有达到阈值,将 node 放到数组 tab 的 index 位置,
                    // 将新的结点设置成原链表的表头
                    setEntryAt(tab, index, node);
                ++modCount;
                count = c;
                oldValue = null;
                break;
            }
        }
    } finally {
        // 解锁
        unlock();
    }
    return oldValue;
}

如果加锁失败了调用scanAndLockForPut,完成查找或新建节点的工作。当获取到锁后直接将该节点加入链表即可,提升了put操作的性能,这里涉及到==自旋==。大致过程:

  1. 在我获取不到锁的时候我进行tryLock,准备好new的数据,同时还有一定的次数限制,还要考虑别的已经获得线程的节点修改该头节点。
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
复制代码private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {  
    HashEntry<K,V> first = entryForHash(this, hash);
    HashEntry<K,V> e = first;
    HashEntry<K,V> node = null;
    int retries = -1; // negative while locating node
 
    // 循环获取锁
    while (!tryLock()) {
        HashEntry<K,V> f; // to recheck first below
        if (retries < 0) {
            if (e == null) {
                if (node == null) // speculatively create node
              // 进到这里说明数组该位置的链表是空的,没有任何元素
             // 当然,进到这里的另一个原因是 tryLock() 失败,所以该槽存在并发,不一定是该位置
                    node = new HashEntry<K,V>(hash, key, value, null);
                retries = 0;
            }
            else if (key.equals(e.key))
                retries = 0;
            else
                // 顺着链表往下走
                e = e.next;
        }
    // 重试次数如果超过 MAX_SCAN_RETRIES(单核 1 次多核 64 次),那么不抢了,进入到阻塞队列等待锁
    //    lock() 是阻塞方法,直到获取锁后返回
        else if (++retries > MAX_SCAN_RETRIES) {
            lock();
            break;
        }
        else if ((retries & 1) == 0 &&
                 // 进入这里,说明有新的元素进到了链表,并且成为了新的表头
                 // 这边的策略是,重新执行 scanAndLockForPut 方法
                 (f = entryForHash(this, hash)) != first) {
            e = first = f; // re-traverse if entry changed
            retries = -1;
        }
    }
    return node;
}
  1. Size

这个size方法比较有趣,他是先无锁的统计下所有的数据量看下前后两次是否数据一样,如果一样则返回数据,如果不一样则要把全部的segment进行加锁,统计,解锁。并且size方法只是返回一个统计性的数字,因此size谨慎使用哦。

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
复制代码public int size() {  
       // Try a few times to get accurate count. On failure due to
       // continuous async changes in table, resort to locking.
       final Segment<K,V>[] segments = this.segments;
       int size;
       boolean overflow; // true if size overflows 32 bits
       long sum;         // sum of modCounts
       long last = 0L;   // previous sum
       int retries = -1; // first iteration isn't retry
       try {
           for (;;) {
               if (retries++ == RETRIES_BEFORE_LOCK) {  //  超过2次则全部加锁
                   for (int j = 0; j < segments.length; ++j)
                       ensureSegment(j).lock(); // 直接对全部segment加锁消耗性太大
               }
               sum = 0L;
               size = 0;
               overflow = false;
               for (int j = 0; j < segments.length; ++j) {
                   Segment<K,V> seg = segmentAt(segments, j);
                   if (seg != null) {
                       sum += seg.modCount; // 统计的是modCount,涉及到增删该都会加1
                       int c = seg.count;
                       if (c < 0 || (size += c) < 0)
                           overflow = true;
                   }
               }
               if (sum == last) // 每一个前后的修改次数一样 则认为一样,但凡有一个不一样则直接break。
                   break;
               last = sum;
           }
       } finally {
           if (retries > RETRIES_BEFORE_LOCK) {
               for (int j = 0; j < segments.length; ++j)
                   segmentAt(segments, j).unlock();
           }
       }
       return overflow ? Integer.MAX_VALUE : size;
   }
  1. rehash
    segment 数组初始化后就不可变了,也就是说并发性不可变,不过segment里的table可以扩容为2倍,该方法没有考虑并发,因为执行该方法之前已经获取了锁。其中JDK7中的rehash思路跟JDK8 中扩容后处理链表的思路一样,个人不过感觉没有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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
复制代码// 方法参数上的 node 是这次扩容后,需要添加到新的数组中的数据。  
private void rehash(HashEntry<K,V> node) {
    HashEntry<K,V>[] oldTable = table;
    int oldCapacity = oldTable.length;
    // 2 倍
    int newCapacity = oldCapacity << 1;
    threshold = (int)(newCapacity * loadFactor);
    // 创建新数组
    HashEntry<K,V>[] newTable =
        (HashEntry<K,V>[]) new HashEntry[newCapacity];
    // 新的掩码,如从 16 扩容到 32,那么 sizeMask 为 31,对应二进制 ‘000...00011111’
    int sizeMask = newCapacity - 1;
    // 遍历原数组,将原数组位置 i 处的链表拆分到 新数组位置 i 和 i+oldCap 两个位置
    for (int i = 0; i < oldCapacity ; i++) {
        // e 是链表的第一个元素
        HashEntry<K,V> e = oldTable[i];
        if (e != null) {
            HashEntry<K,V> next = e.next;
            // 计算应该放置在新数组中的位置,
            // 假设原数组长度为 16,e 在 oldTable[3] 处,那么 idx 只可能是 3 或者是 3 + 16 = 19
            int idx = e.hash & sizeMask; // 新位置
            if (next == null)   // 该位置处只有一个元素
                newTable[idx] = e;
            else { // Reuse consecutive sequence at same slot
                // e 是链表表头
                HashEntry<K,V> lastRun = e;
                // idx 是当前链表的头结点 e 的新位置
                int lastIdx = idx;
                // for 循环找到一个 lastRun 结点,这个结点之后的所有元素是将要放到一起的
                for (HashEntry<K,V> last = next;
                     last != null;
                     last = last.next) {
                    int k = last.hash & sizeMask;
                    if (k != lastIdx) {
                        lastIdx = k;
                        lastRun = last;
                    }
                }
                // 将 lastRun 及其之后的所有结点组成的这个链表放到 lastIdx 这个位置
                newTable[lastIdx] = lastRun;
                // 下面的操作是处理 lastRun 之前的结点,
                //这些结点可能分配在另一个链表中,也可能分配到上面的那个链表中
                for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
                    V v = p.value;
                    int h = p.hash;
                    int k = h & sizeMask;
                    HashEntry<K,V> n = newTable[k];
                    newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
                }
            }
        }
    }
    // 将新来的 node 放到新数组中刚刚的 两个链表之一 的 头部
    int nodeIndex = node.hash & sizeMask; // add the new node
    node.setNext(newTable[nodeIndex]);
    newTable[nodeIndex] = node;
    table = newTable;
}
  1. CAS操作
    在JDK7里在ConcurrentHashMap中通过原子操作sun.misc.Unsafe查找元素、替换元素和设置元素。通过这样的硬件级别获得数据可以保证及时是多线程我也每次获得的数据是最新的。这些原子操作起着非常关键的作用,你可以在所有ConcurrentHashMap的基本功能中看到,随机距离如下:
1
2
3
4
5
6
7
8
9
10
11
12
复制代码     final void setNext(HashEntry<K,V> n) {  
            UNSAFE.putOrderedObject(this, nextOffset, n);
        }
    static final <K,V> HashEntry<K,V> entryAt(HashEntry<K,V>[] tab, int i) {
        return (tab == null) ? null :
            (HashEntry<K,V>) UNSAFE.getObjectVolatile
            (tab, ((long)i << TSHIFT) + TBASE);
    }
   static final <K,V> void setEntryAt(HashEntry<K,V>[] tab, int i,
                                       HashEntry<K,V> e) {
        UNSAFE.putOrderedObject(tab, ((long)i << TSHIFT) + TBASE, e);
    }

常见问题

  1. ConcurrentHashMap实现原理是怎么样的或者ConcurrentHashMap如何在保证高并发下线程安全的同时实现了性能提升?

ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了==锁分离==技术。它使用了多个锁来控制对hash表的不同部分进行的修改。内部使用段(Segment)来表示这些不同的部分,每个段其实就是一个小的HashTable,只要多个修改操作发生在不同的段上,它们就可以并发进行。

  1. 在高并发下的情况下如何保证取得的元素是最新的?

用于存储键值对数据的HashEntry,在设计上它的成员变量value跟next都是volatile类型的,这样就保证别的线程对value值的修改,get方法可以马上看到。

  1. ConcurrentHashMap的弱一致性体现在迭代器,clear和get方法,原因在于没有加锁。
  1. 比如迭代器在遍历数据的时候是一个Segment一个Segment去遍历的,如果在遍历完一个Segment时正好有一个线程在刚遍历完的Segment上插入数据,就会体现出不一致性。clear也是一样。
    1. get方法和containsKey方法都是遍历对应索引位上所有节点,都是不加锁来判断的,如果是修改性质的因为可见性的存在可以直接获得最新值,不过如果是新添加值则无法保持一致性。

JDK8

JDK8相比与JDK7主要区别如下:

  1. 取消了segment数组,直接用table保存数据,锁的粒度更小,减少并发冲突的概率。采用table数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率,并发控制使用Synchronized和CAS来操作。
  2. 存储数据时采用了数组+ 链表+红黑树的形式。
  1. CurrentHashMap重要参数:

private static final int MAXIMUM_CAPACITY = 1 << 30; // 数组的最大值
private static final int DEFAULT_CAPACITY = 16; // 默认数组长度
static final int TREEIFY_THRESHOLD = 8; // 链表转红黑树的一个条件
static final int UNTREEIFY_THRESHOLD = 6; // 红黑树转链表的一个条件
static final int MIN_TREEIFY_CAPACITY = 64; // 链表转红黑树的另一个条件
static final int MOVED = -1; // 表示正在扩容转移
static final int TREEBIN = -2; // 表示已经转换成树
static final int RESERVED = -3; // hash for transient reservations
static final int HASH_BITS = 0x7fffffff; // 获得hash值的辅助参数
transient volatile Node<K,V>[] table;// 默认没初始化的数组,用来保存元素
private transient volatile Node<K,V>[] nextTable; // 转移的时候用的数组
static final int NCPU = Runtime.getRuntime().availableProcessors();// 获取可用的CPU个数
private transient volatile Node<K,V>[] nextTable; // 连接表,用于哈希表扩容,扩容完成后会被重置为 null
private transient volatile long baseCount;保存着整个哈希表中存储的所有的结点的个数总和,有点类似于 HashMap 的 size 属性。
private transient volatile int sizeCtl;
负数:表示进行初始化或者扩容,-1:表示正在初始化,-N:表示有 N-1 个线程正在进行扩容
正数:0 表示还没有被初始化,> 0的数:初始化或者是下一次进行扩容的阈值,有点类似HashMap中的threshold,不过功能更强大。

  1. 若干重要类
  • 构成每个元素的基本类 Node
1
2
3
4
5
6
7
复制代码      static class Node<K,V> implements Map.Entry<K,V> {  
              final int hash;    // key的hash值
              final K key;       // key
              volatile V val;    // value
              volatile Node<K,V> next; 
               //表示链表中的下一个节点
      }
  • TreeNode继承于Node,用来存储红黑树节点
1
2
3
4
5
6
7
8
9
10
11
12
复制代码      static final class TreeNode<K,V> extends Node<K,V> {  
              TreeNode<K,V> parent;  
              // 红黑树的父亲节点
              TreeNode<K,V> left;
              // 左节点
              TreeNode<K,V> right;
             // 右节点
              TreeNode<K,V> prev;    
             // 前节点
              boolean red;
             // 是否为红点
      }
  • ForwardingNode
    在 Node 的子类 ForwardingNode 的构造方法中,可以看到此变量的hash = -1 ,类中还存储nextTable的引用。该初始化方法只在 transfer方法被调用,如果一个类被设置成此种情况并且hash = -1 则说明该节点不需要resize了。
1
2
3
4
5
6
7
8
9
复制代码static final class ForwardingNode<K,V> extends Node<K,V> {  
        final Node<K,V>[] nextTable;
        ForwardingNode(Node<K,V>[] tab) {
            //注意这里
            super(MOVED, null, null, null);
            this.nextTable = tab;
        }
 //.....
}
  • TreeBin
    TreeBin从字面含义中可以理解为存储树形结构的容器,而树形结构就是指TreeNode,所以TreeBin就是封装TreeNode的容器,它提供转换黑红树的一些条件和锁的控制.
1
2
3
4
5
6
7
8
9
10
复制代码static final class TreeBin<K,V> extends Node<K,V> {  
        TreeNode<K,V> root;
        volatile TreeNode<K,V> first;
        volatile Thread waiter;
        volatile int lockState;
        // values for lockState
        static final int WRITER = 1; // set while holding write lock
        static final int WAITER = 2; // set when waiting for write lock
        static final int READER = 4; // increment value for setting read lock
}

构造函数

整体的构造情况基本跟HashMap类似,并且为了跟原来的JDK7中的兼容性还可以传入并发度。不过JDK8中并发度已经有table的具体长度来控制了。

  1. ConcurrentHashMap(): 创建一个带有默认初始容量 (16)、加载因子 (0.75) 和 concurrencyLevel (16) 的新的空映射
  2. ConcurrentHashMap(int):创建一个带有指定初始容量tableSizeFor、默认加载因子 (0.75) 和 concurrencyLevel (16) 的新的空映射
  3. ConcurrentHashMap(Map<? extends K, ? extends V> m):构造一个与给定映射具有相同映射关系的新映射
  4. ConcurrentHashMap(int initialCapacity, float loadFactor):创建一个带有指定初始容量、加载因子和默认 concurrencyLevel (1) 的新的空映射
  5. ConcurrentHashMap(int, float, int):创建一个带有指定初始容量、加载因子和并发级别的新的空映射。

put

假设table已经初始化完成,put操作采用 CAS + synchronized 实现并发插入或更新操作,具体实现如下。

  1. 做一些边界处理,然后获得hash值。
  2. 没初始化就初始化,初始化后看下对应的桶是否为空,为空就原子性的尝试插入。
  3. 如果当前节点正在扩容还要去帮忙扩容,骚操作。
  4. 用syn来加锁当前节点,然后操作几乎跟就跟hashmap一样了。
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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
复制代码  
// Node 节点的 hash值在HashMap中存储的就是hash值,在currenthashmap中可能有多种情况哦!
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException(); //边界处理
    int hash = spread(key.hashCode());// 最终hash值计算
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) { //循环表
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable(); // 初始化表 如果为空,懒汉式
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
        // 如果对应桶位置为空
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) 
                         // CAS 原子性的尝试插入
                break;
        } 
        else if ((fh = f.hash) == MOVED) 
        // 如果当前节点正在扩容。还要帮着去扩容。
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            synchronized (f) { //  桶存在数据 加锁操作进行处理
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) { // 如果存储的是链表 存储的是节点的hash值
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            // 遍历链表去查找,如果找到key一样则选择性
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                 (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            Node<K,V> pred = e;
                            if ((e = e.next) == null) {// 找到尾部插入
                                pred.next = new Node<K,V>(hash, key,
                                                          value, null);
                                break;
                            }
                        }
                    }
                    else if (f instanceof TreeBin) {// 如果桶节点类型为TreeBin
                        Node<K,V> p;
                        binCount = 2;
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                       value)) != null) { 
                             // 尝试红黑树插入,同时也要防止节点本来就有,选择性覆盖
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
            if (binCount != 0) { // 如果链表数量
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i); //  链表转红黑树哦!
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount); // 统计大小 并且检查是否要扩容。
    return null;
}

涉及到重要函数initTable、tabAt、casTabAt、helpTransfer、putTreeVal、treeifyBin、addCount函数。

initTable

只允许一个线程对表进行初始化,如果不巧有其他线程进来了,那么会让其他线程交出 CPU 等待下次系统调度Thread.yield。这样,保证了表同时只会被一个线程初始化,对于table的大小,会根据sizeCtl的值进行设置,如果没有设置szieCtl的值,那么默认生成的table大小为16,否则,会根据sizeCtl的大小设置table大小。

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
复制代码// 容器初始化 操作  
private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        if ((sc = sizeCtl) < 0) // 如果正在初始化-1,-N 正在扩容。
            Thread.yield(); // 进行线程让步等待
     // 让掉当前线程 CPU 的时间片,使正在运行中的线程重新变成就绪状态,并重新竞争 CPU 的调度权。
     // 它可能会获取到,也有可能被其他线程获取到。
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { 
          //  比较sizeCtl的值与sc是否相等,相等则用 -1 替换,这表明我这个线程在进行初始化了!
            try {
                if ((tab = table) == null || tab.length == 0) {
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY; // 默认为16
                    @SuppressWarnings("unchecked")
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    sc = n - (n >>> 2); // sc = 0.75n
                }
            } finally {
                sizeCtl = sc; //设置sizeCtl 类似threshold
            }
            break;
        }
    }
    return tab;
}

unsafe

在ConcurrentHashMap中使用了unSafe方法,通过直接操作内存的方式来保证并发处理的安全性,使用的是==硬件==的安全机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码 // 用来返回节点数组的指定位置的节点的原子操作  
@SuppressWarnings("unchecked")
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
    return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}

// cas原子操作,在指定位置设定值
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                    Node<K,V> c, Node<K,V> v) {
    return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}

// 原子操作,在指定位置设定值
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
    U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}

// 比较table数组下标为i的结点是否为c,若为c,则用v交换操作。否则,不进行交换操作。
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                    Node<K,V> c, Node<K,V> v) {
    return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}

可以看到获得table[i]数据是通过Unsafe对象通过反射获取的,取数据直接table[index]不可以么,为什么要这么复杂?在java内存模型中,我们已经知道每个线程都有一个工作内存,里面存储着table的副本,虽然table是volatile修饰的,但不能保证线程每次都拿到table中的最新元素,Unsafe.getObjectVolatile可以直接获取指定内存的数据,保证了每次拿到数据都是最新的。

helpTransfer

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
复制代码// 可能有多个线程在同时帮忙运行helpTransfer  
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
    Node<K,V>[] nextTab; int sc;
    if (tab != null && (f instanceof ForwardingNode) && (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
        // table不是空  且 node节点是转移类型,并且转移类型的nextTable 不是空 说明还在扩容ing
        int rs = resizeStamp(tab.length); 
        // 根据 length 得到一个前16位的标识符,数组容量大小。
        // 确定新table指向没有变,老table数据也没变,并且此时 sizeCtl小于0 还在扩容ing
        while (nextTab == nextTable && table == tab && (sc = sizeCtl) < 0) {
            if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || transferIndex <= 0)
            // 1. sizeCtl 无符号右移16位获得高16位如果不等 rs 标识符变了
            // 2. 如果扩容结束了 这里可以看 trePresize 函数第一次扩容操作:
            // 默认第一个线程设置 sc = rs 左移 16 位 + 2,当第一个线程结束扩容了,
            // 就会将 sc 减一。这个时候,sc 就等于 rs + 1。
            // 3. 如果达到了最大帮助线程个数 65535个
            // 4. 如果转移下标调整ing 扩容已经结束了
                break;
            if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
            // 如果以上都不是, 将 sizeCtl + 1,增加一个线程来扩容
                transfer(tab, nextTab); // 进行转移
                break;// 结束循环
            }
        }
        return nextTab;
    }
    return table;
}
  • Integer.numberOfLeadingZeros(n)

该方法的作用是返回无符号整型i的最高非零位前面的0的个数,包括符号位在内;
如果i为负数,这个方法将会返回0,符号位为1.
比如说,10的二进制表示为 0000 0000 0000 0000 0000 0000 0000 1010
java的整型长度为32位。那么这个方法返回的就是28

  • resizeStamp
    主要用来获得标识符,可以简单理解是对当前系统容量大小的一种监控。
1
2
3
4
复制代码static final int resizeStamp(int n) {  
   return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1)); 
   //RESIZE_STAMP_BITS = 16
}

addCount

主要就2件事:一是更新 baseCount,二是判断是否需要扩容。

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
复制代码private final void addCount(long x, int check) {  
 CounterCell[] as; long b, s;
 // 首先如果没有并发 此时countCells is null, 此时尝试CAS设置数据值。
 if ((as = counterCells) != null || !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
     // 如果 counterCells不为空以为此时有并发的设置 或者 CAS设置 baseCount 失败了
     CounterCell a; long v; int m;
     boolean uncontended = true;
     if (as == null || (m = as.length - 1) < 0 || (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
         !(uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
         // 1. 如果没出现并发 此时计数盒子为 null
         // 2. 随机取出一个数组位置发现为空
         // 3. 出现并发后修改这个cellvalue 失败了
         // 执行funAddCount
         fullAddCount(x, uncontended);// 死循环操作
         return;
     }
     if (check <= 1)
         return;
     s = sumCount(); // 吧counterCells数组中的每一个数据进行累加给baseCount。
 }
 // 如果需要扩容
 if (check >= 0) {
  Node<K,V>[] tab, nt; int n, sc;
  while (s >= (long)(sc = sizeCtl) && (tab = table) != null && (n = tab.length) < MAXIMUM_CAPACITY) {
   int rs = resizeStamp(n);// 获得高位标识符
   if (sc < 0) { // 是否需要帮忙去扩容
    if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
     sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0)
     break;
    if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
     transfer(tab, nt);
   } // 第一次扩容
   else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2))
    transfer(tab, null);
   s = sumCount();
  }
 }
}
  1. baseCount添加
    ConcurrentHashMap提供了baseCount、counterCells 两个辅助变量和一个 CounterCell辅助内部类。sumCount() 就是迭代 counterCells来统计 sum 的过程。 put 操作时,肯定会影响 size(),在 put() 方法最后会调用 addCount()方法。整体的思维方法跟LongAdder类似,用的思维就是借鉴的ConcurrentHashMap。每一个Cell都用Contended修饰来避免伪共享。
  1. JDK1.7 和 JDK1.8 对 size 的计算是不一样的。 1.7 中是先不加锁计算三次,如果三次结果不一样在加锁。
  2. JDK1.8 size 是通过对 baseCount 和 counterCell 进行 CAS 计算,最终通过 baseCount 和 遍历 CounterCell 数组得出 size。
  3. JDK 8 推荐使用mappingCount 方法,因为这个方法的返回值是 long 类型,不会因为 size 方法是 int 类型限制最大值。
  1. 关于扩容
    在这里插入图片描述
    在addCount第一次扩容时候会有骚操作sc=rs << RESIZE_STAMP_SHIFT) + 2)其中rs = resizeStamp(n)。这里需要核心说一点,

在这里插入图片描述
如果不是第一次扩容则直接将低16位的数字 +1 即可。

putTreeVal

这个操作几乎跟HashMap的操作完全一样,核心思想就是一定要决定向左还是向右然后最终尝试放置新数据,然后balance。不同点就是有锁的考虑。

treeifyBin

这里的基本思路跟HashMap几乎一样,不同点就是先变成TreeNode,然后是单向链表串联。

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
复制代码private final void treeifyBin(Node<K,V>[] tab, int index) {  
    Node<K,V> b; int n, sc;
    if (tab != null) {
        //如果整个table的数量小于64,就扩容至原来的一倍,不转红黑树了
        //因为这个阈值扩容可以减少hash冲突,不必要去转红黑树
        if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
            tryPresize(n << 1);
        else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
            synchronized (b) { //锁定当前桶
                if (tabAt(tab, index) == b) {
                    TreeNode<K,V> hd = null, tl = null;
                    for (Node<K,V> e = b; e != null; e = e.next) {
                        //遍历这个链表然后将每个节点封装成TreeNode,最终单链表串联起来,
                        // 最终 调用setTabAt 放置红黑树
                        TreeNode<K,V> p =
                            new TreeNode<K,V>(e.hash, e.key, e.val,
                                              null, null);
                        if ((p.prev = tl) == null)
                            hd = p;
                        else
                            tl.next = p;
                        tl = p;
                    }
                    //通过TreeBin对象对TreeNode转换成红黑树
                    setTabAt(tab, index, new TreeBin<K,V>(hd));
                }
            }
        }
    }
}

TreeBin

主要功能就是链表变化为红黑树,这个红黑树用TreeBin来包装。并且要注意 转成红黑树以后以前链表的结构信息还是有的,最终信息如下:

  1. TreeBin.first = 链表中第一个节点。
  2. TreeBin.root = 红黑树中的root节点。
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
49
复制代码TreeBin(TreeNode<K,V> b) {  
            super(TREEBIN, null, null, null);   
            //创建空节点 hash = -2 
            this.first = b;
            TreeNode<K,V> r = null; // root 节点
            for (TreeNode<K,V> x = b, next; x != null; x = next) {
                next = (TreeNode<K,V>)x.next;
                x.left = x.right = null;
                if (r == null) {
                    x.parent = null;
                    x.red = false;
                    r = x; // root 节点设置为x 
                }
                else {
                    K k = x.key;
                    int h = x.hash;
                    Class<?> kc = null;
                    for (TreeNode<K,V> p = r;;) {
                   // x代表的是转换为树之前的顺序遍历到链表的位置的节点,r代表的是根节点
                        int dir, ph;
                        K pk = p.key;
                        if ((ph = p.hash) > h)
                            dir = -1;
                        else if (ph < h)
                            dir = 1;
                        else if ((kc == null &&
                                  (kc = comparableClassFor(k)) == null) ||
                                 (dir = compareComparables(kc, k, pk)) == 0)
                            dir = tieBreakOrder(k, pk);    
                            // 当key不可以比较,或者相等的时候采取的一种排序措施
                            TreeNode<K,V> xp = p;
                        // 放一定是放在叶子节点上,如果还没找到叶子节点则进行循环往下找。
                        // 找到了目前叶子节点才会进入 再放置数据
                        if ((p = (dir <= 0) ? p.left : p.right) == null) {
                            x.parent = xp;
                            if (dir <= 0)
                                xp.left = x;
                            else
                                xp.right = x;
                            r = balanceInsertion(r, x); 
                     // 每次插入一个元素的时候都调用 balanceInsertion 来保持红黑树的平衡
                            break;
                        }
                    }
                }
            }
            this.root = r;
            assert checkInvariants(root);
        }

tryPresize

当数组长度小于64的时候,扩张数组长度一倍,调用此函数。扩容后容量大小的核对,可能涉及到初始化容器大小。并且扩容的时候又跟2的次幂联系上了!,其中初始化时候传入map会调用putAll方法直接put一个map的话,在putAll方法中没有调用initTable方法去初始化table,而是直接调用了tryPresize方法,所以这里需要做一个是不是需要初始化table的判断。

PS: 默认第一个线程设置 sc = rs 左移 16 位 + 2,当第一个线程结束扩容了,就会将 sc 减一。这个时候,sc 就等于 rs + 1,这个时候说明扩容完毕了。

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
49
50
51
52
53
54
55
56
复制代码     /**  
     * 扩容表为指可以容纳指定个数的大小(总是2的N次方)
     * 假设原来的数组长度为16,则在调用tryPresize的时候,size参数的值为16<<1(32),此时sizeCtl的值为12
     * 计算出来c的值为64, 则要扩容到 sizeCtl ≥ c
     *  第一次扩容之后 数组长:32 sizeCtl:24
     *  第三次扩容之后 数组长:128  sizeCtl:96 退出
     */
    private final void tryPresize(int size) {
        int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
            tableSizeFor(size + (size >>> 1) + 1); // 合理范围
        int sc;
        while ((sc = sizeCtl) >= 0) {
            Node<K,V>[] tab = table; int n;
                if (tab == null || (n = tab.length) == 0) {
                // 初始化传入map,今天putAll会直接调用这个。
                n = (sc > c) ? sc : c;
                if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {    
                //初始化tab的时候,把 sizeCtl 设为 -1
                    try {
                        if (table == tab) {
                            @SuppressWarnings("unchecked")
                            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                            table = nt;
                            sc = n - (n >>> 2); // sc=sizeCtl = 0.75n
                        }
                    } finally {
                        sizeCtl = sc;
                    }
                }
            }
             // 初始化时候如果  数组容量<=sizeCtl 或 容量已经最大化了则退出
            else if (c <= sc || n >= MAXIMUM_CAPACITY) {
                    break;//退出扩张
            }
            else if (tab == table) {
                int rs = resizeStamp(n);

                if (sc < 0) { // sc = siztCtl 如果正在扩容Table的话,则帮助扩容
                    Node<K,V>[] nt;
                    if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                        sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                        transferIndex <= 0)
                        break; // 各种条件判断是否需要加入扩容工作。
                     // 帮助转移数据的线程数 + 1
                    if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                        transfer(tab, nt);
                }
                 // 没有在初始化或扩容,则开始扩容
                 // 此处切记第一次扩容 直接 +2 
                else if (U.compareAndSwapInt(this, SIZECTL, sc,
                      (rs << RESIZE_STAMP_SHIFT) + 2)) {
                        transfer(tab, null);
                }
            }
        }
    }

transfer

这里代码量比较大主要分文三部分,并且感觉思路很精髓,尤其是其他线程帮着去扩容的骚操作。

  1. 主要是 单个线程能处理的最少桶结点个数的计算和一些属性的初始化操作。
  2. 每个线程进来会先领取自己的任务区间[bound,i],然后开始 –i 来遍历自己的任务区间,对每个桶进行处理。如果遇到桶的头结点是空的,那么使用 ForwardingNode标识旧table中该桶已经被处理完成了。如果遇到已经处理完成的桶,直接跳过进行下一个桶的处理。如果是正常的桶,对桶首节点加锁,正常的迁移即可(跟HashMap第三部分一样思路),迁移结束后依然会将原表的该位置标识位已经处理。

该函数中的finish= true 则说明整张表的迁移操作已经全部完成了,我们只需要重置 table的引用并将 nextTable 赋为空即可。否则,CAS 式的将 sizeCtl减一,表示当前线程已经完成了任务,退出扩容操作。如果退出成功,那么需要进一步判断当前线程是否就是最后一个在执行扩容的。

1
2
复制代码f ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)  
   return;

第一次扩容时在addCount中有写到(resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2 表示当前只有一个线程正在工作,相对应的,如果 (sc - 2) == resizeStamp(n) << RESIZE_STAMP_SHIFT,说明当前线程就是==最后一个==还在扩容的线程,那么会将 finishing 标识为 true,并在下一次循环中退出扩容方法。

  1. 几乎跟HashMap大致思路类似的遍历链表/红黑树然后扩容操作。
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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
复制代码private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {  
    int n = tab.length, stride;
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)    //MIN_TRANSFER_STRIDE 用来控制不要占用太多CPU
        stride = MIN_TRANSFER_STRIDE; // subdivide range    //MIN_TRANSFER_STRIDE=16 每个CPU处理最小长度个数

    if (nextTab == null) { // 新表格为空则直接新建二倍,别的辅助线程来帮忙扩容则不会进入此if条件
        try {
            @SuppressWarnings("unchecked")
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
            nextTab = nt;
        } catch (Throwable ex) {      // try to cope with OOME
            sizeCtl = Integer.MAX_VALUE;
            return;
        }
        nextTable = nextTab;
        transferIndex = n; // transferIndex 指向最后一个桶,方便从后向前遍历
    }
    int nextn = nextTab.length; // 新表长度
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab); // 创建一个fwd节点,这个是用来控制并发的,当一个节点为空或已经被转移之后,就设置为fwd节点
    boolean advance = true;    //是否继续向前查找的标志位
    boolean finishing = false; // to ensure sweep(清扫) before committing nextTab,在完成之前重新在扫描一遍数组,看看有没完成的没
     // 第一部分
    // i 指向当前桶, bound 指向当前线程需要处理的桶结点的区间下限【bound,i】 这样来跟线程划分任务。
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;
       // 这个 while 循环的目的就是通过 --i 遍历当前线程所分配到的桶结点
       // 一个桶一个桶的处理
        while (advance) {//  每一次成功处理操作都会将advance设置为true,然里来处理区间的上一个数据
            int nextIndex, nextBound;
            if (--i >= bound || finishing) { //通过此处进行任务区间的遍历
                advance = false;
            }
            else if ((nextIndex = transferIndex) <= 0) {
                i = -1;// 任务分配完了
                advance = false;
            }
            // 更新 transferIndex
           // 为当前线程分配任务,处理的桶结点区间为(nextBound,nextIndex)
            else if (U.compareAndSwapInt(this, TRANSFERINDEX, nextIndex,nextBound = (nextIndex > stride ? nextIndex - stride : 0))) {
               // nextIndex本来等于末尾数字,
                bound = nextBound;
                i = nextIndex - 1;
                advance = false;
            }
        }
        // 当前线程所有任务完成 
        if (i < 0 || i >= n || i + n >= nextn) {
            int sc;
            if (finishing) {  // 已经完成转移 则直接赋值操作
                nextTable = null;
                table = nextTab;
                sizeCtl = (n << 1) - (n >>> 1);    //设置sizeCtl为扩容后的0.75
                return;
            }
            if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) { // sizeCtl-1 表示当前线程任务完成。
                if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT) { 
                // 判断当前线程完成的线程是不是最后一个在扩容的,思路精髓
                        return;
                }
                finishing = advance = true;// 如果是则相应的设置参数
                i = n; 
            }
        }
        else if ((f = tabAt(tab, i)) == null) // 数组中把null的元素设置为ForwardingNode节点(hash值为MOVED[-1])
            advance = casTabAt(tab, i, null, fwd); // 如果老节点数据是空的则直接进行CAS设置为fwd
        else if ((fh = f.hash) == MOVED) //已经是个fwd了,因为是多线程操作 可能别人已经给你弄好了,
            advance = true; // already processed
        else {
            synchronized (f) { //加锁操作
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn;
                    if (fh >= 0) { //该节点的hash值大于等于0,说明是一个Node节点
                    // 关于链表的操作整体跟HashMap类似不过 感觉好像更扰一些。
                        int runBit = fh & n; // fh= f.hash first hash的意思,看第一个点 放老位置还是新位置
                        Node<K,V> lastRun = f;

                        for (Node<K,V> p = f.next; p != null; p = p.next) {
                            int b = p.hash & n;    //n的值为扩张前的数组的长度
                            if (b != runBit) {
                                runBit = b;
                                lastRun = p;//最后导致发生变化的节点
                            }
                        }
                        if (runBit == 0) { //看最后一个变化点是新还是旧 旧
                            ln = lastRun;
                            hn = null;
                        }
                        else {
                            hn = lastRun; //看最后一个变化点是新还是旧 旧
                            ln = null;
                        }
                        /*
                         * 构造两个链表,顺序大部分和原来是反的,不过顺序也有差异
                         * 分别放到原来的位置和新增加的长度的相同位置(i/n+i)
                         */
                        for (Node<K,V> p = f; p != lastRun; p = p.next) {
                            int ph = p.hash; K pk = p.key; V pv = p.val;
                            if ((ph & n) == 0)
                                    /*
                                     * 假设runBit的值为0,
                                     * 则第一次进入这个设置的时候相当于把旧的序列的最后一次发生hash变化的节点(该节点后面可能还有hash计算后同为0的节点)设置到旧的table的第一个hash计算后为0的节点下一个节点
                                     * 并且把自己返回,然后在下次进来的时候把它自己设置为后面节点的下一个节点
                                     */
                                ln = new Node<K,V>(ph, pk, pv, ln);
                            else
                                    /*
                                     * 假设runBit的值不为0,
                                     * 则第一次进入这个设置的时候相当于把旧的序列的最后一次发生hash变化的节点(该节点后面可能还有hash计算后同不为0的节点)设置到旧的table的第一个hash计算后不为0的节点下一个节点
                                     * 并且把自己返回,然后在下次进来的时候把它自己设置为后面节点的下一个节点
                                     */
                                hn = new Node<K,V>(ph, pk, pv, hn);    
                        }
                        setTabAt(nextTab, i, ln);    
                        setTabAt(nextTab, i + n, hn);
                        setTabAt(tab, i, fwd);
                        advance = true;
                    }
                    else if (f instanceof TreeBin) { // 该节点hash值是个负数否则的话是一个树节点
                        TreeBin<K,V> t = (TreeBin<K,V>)f;
                        TreeNode<K,V> lo = null, loTail = null; // 旧 头尾
                        TreeNode<K,V> hi = null, hiTail = null; //新头围
                        int lc = 0, hc = 0;
                        for (Node<K,V> e = t.first; e != null; e = e.next) {
                            int h = e.hash;
                            TreeNode<K,V> p = new TreeNode<K,V>
                                (h, e.key, e.val, null, null);
                            if ((h & n) == 0) {
                                if ((p.prev = loTail) == null)
                                    lo = p;
                                else
                                    loTail.next = p; //旧头尾设置
                                loTail = p;
                                ++lc;
                            }
                            else { // 新头围设置
                                if ((p.prev = hiTail) == null)
                                    hi = p;
                                else
                                    hiTail.next = p;
                                hiTail = p;
                                ++hc;
                            }
                        }
                         //ln  如果老位置数字<=6 则要对老位置链表进行红黑树降级到链表,否则就看是否还需要对老位置数据进行新建红黑树
                        ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
                            (hc != 0) ? new TreeBin<K,V>(lo) : t;
                        hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
                            (lc != 0) ? new TreeBin<K,V>(hi) : t;
                        setTabAt(nextTab, i, ln);
                        setTabAt(nextTab, i + n, hn);
                        setTabAt(tab, i, fwd); //老表中i位置节点设置下
                        advance = true;
                    }
                }
            }
        }
    }
}

get

这个就很简单了,获得hash值,然后判断存在与否,遍历链表即可,注意get没有任何锁操作!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码    public V get(Object key) {  
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        // 计算key的hash值
        int h = spread(key.hashCode()); 
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) { // 表不为空并且表的长度大于0并且key所在的桶不为空
            if ((eh = e.hash) == h) { // 表中的元素的hash值与key的hash值相等
                if ((ek = e.key) == key || (ek != null && key.equals(ek))) // 键相等
                    // 返回值
                    return e.val;
            }
            else if (eh < 0) // 是个TreeBin hash = -2 
                // 在红黑树中查找,因为红黑树中也保存这一个链表顺序
                return (p = e.find(h, key)) != null ? p.val : null;
            while ((e = e.next) != null) { // 对于结点hash值大于0的情况链表
                if (e.hash == h &&
                    ((ek = e.key) == key || (ek != null && key.equals(ek))))
                    return e.val;
            }
        }
        return null;
    }

clear

关于清空也相对简单 ,无非就是遍历桶数组,然后通过CAS来置空。

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
复制代码public void clear() {  
    long delta = 0L;
    int i = 0;
    Node<K,V>[] tab = table;
    while (tab != null && i < tab.length) {
        int fh;
        Node<K,V> f = tabAt(tab, i);
        if (f == null)
            ++i; //这个桶是空的直接跳过
        else if ((fh = f.hash) == MOVED) { // 这个桶的数据还在扩容中,要去扩容同时等待。
            tab = helpTransfer(tab, f);
            i = 0; // restart
        }
        else {
            synchronized (f) { // 真正的删除
                if (tabAt(tab, i) == f) {
                    Node<K,V> p = (fh >= 0 ? f :(f instanceof TreeBin) ?((TreeBin<K,V>)f).first : null);
                        //循环到链表/者红黑树的尾部
                        while (p != null) {
                            --delta; // 记录删除了多少个
                            p = p.next;
                        } 
                        //利用CAS无锁置null  
                        setTabAt(tab, i++, null);
                    }
                }
            }
        }
        if (delta != 0L)
            addCount(delta, -1); //调整count
    }

end

ConcurrentHashMap是如果来做到并发安全,又是如何做到高效的并发的呢?

  1. 首先是读操作,读源码发现get方法中根本没有使用同步机制,也没有使用unsafe方法,所以读操作是支持并发操作的。
  2. 写操作
  • . 数据扩容函数是transfer,该方法的只有addCount,helpTransfer和tryPresize这三个方法来调用。
  1. addCount是在当对数组进行操作,使得数组中存储的元素个数发生了变化的时候会调用的方法。
  2. helpTransfer是在当一个线程要对table中元素进行操作的时候,如果检测到节点的·hash·= MOVED 的时候,就会调用helpTransfer方法,在helpTransfer中再调用transfer方法来帮助完成数组的扩容
  1. tryPresize是在treeIfybin和putAll方法中调用,treeIfybin主要是在put添加元素完之后,判断该数组节点相关元素是不是已经超过8个的时候,如果超过则会调用这个方法来扩容数组或者把链表转为树。注意putAll在初始化传入一个大map的时候会调用。·

总结扩容情况发生:

  1. 在往map中添加元素的时候,在某一个节点的数目已经超过了8个,同时数组的长度又小于64的时候,才会触发数组的扩容。
  2. 当数组中元素达到了sizeCtl的数量的时候,则会调用transfer方法来进行扩容
  1. 扩容时候是否可以进行读写。

对于读操作,因为是没有加锁的所以可以的.
对于写操作,JDK8中已经将锁的范围细腻到table[i]l了,当在进行数组扩容的时候,如果当前节点还没有被处理(也就是说还没有设置为==fwd==节点),那就可以进行设置操作。如果该节点已经被处理了,则当前线程也会==加入==到扩容的操作中去。

  1. 多个线程又是如何同步处理的
    在ConcurrentHashMap中,同步处理主要是通过Synchronized和unsafe的硬件级别原子性 这两种方式来完成的。
  1. 在取得sizeCtl跟某个位置的Node的时候,使用的都是unsafe的方法,来达到并发安全的目的
  2. 当需要在某个位置设置节点的时候,则会通过Synchronized的同步机制来锁定该位置的节点。
  3. 在数组扩容的时候,则通过处理的步长和fwd节点来达到并发安全的目的,通过设置hash值为MOVED=-1。
  4. 当把某个位置的节点复制到扩张后的table的时候,也通过Synchronized的同步机制来保证线程安全

套路

  1. 谈谈你理解的 HashMap,讲讲其中的 get put 过程。
  2. 1.8 做了什么优化?
  3. 是线程安全的嘛?
  4. 不安全会导致哪些问题?
  5. 如何解决?有没有线程安全的并发容器?
  6. ConcurrentHashMap 是如何实现的? 1.7、1.8 实现有何不同,为什么这么做。
  7. 1.8中ConcurrentHashMap的sizeCtl作用,大致说下协助扩容跟标志位。
  8. HashMap 为什么不用跳表替换红黑树呢?

参考

CurrentHashMap之transfer
CurrentHashMap详细
LongAdder原理解析

本文使用 mdnice 排版

本文转载自: 掘金

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

如何解决八皇后问题

发表于 2020-04-24

问题

八皇后问题指的是在 8*8 的棋盘上,放入 8 个皇后,并且保证在每一行、每一列、以及对角线上都不会同时出现两个皇后(国际象棋的规则里面,皇后的攻击范围是其所在的横竖两条线以及所在的两条对角线),那么该如何摆放这 8 个皇后呢?一共有多少种摆放方法?

思路

第一种思路,8*8 的棋盘上一共有 64 个格子,现在要将 8 个皇后放入到这 64 个格子当中,就是数学里面的组合数 ,然后从这些组合里面挑选出符合条件的摆放方法。这种做法虽然没错,但是 这个组合数的计算结果太大了,一共有 4426165368 种组合,计算量偏大。

第二种思路,由于每个皇后不能在同一行、同一列上,我们在放入第一个皇后时,随便选择一行,我们有 8 种选择;再放入第二个皇时,此时我们肯定不能放在第一个皇后所在的行和列了,所以只有 7 种选择了;再接着我们放入第 3 个皇后,同样的道理,只能有 6 种选择了;以此类推,当我们放入第 8 个皇后的时候,就只有 1 种选择了。所以这种思路,我们一共有 8 的阶乘种选择,即 40320 种选择,然后我们再从这 4 万多种组合里面选择出符合条件的摆放方式,看上去 4 万多相比前面的 44 亿,小了很多,也算是一种不错的解法了。

第三种思路:在第二种思路的基础上,我们在放入第二个皇后的时候,只考虑了与第一个皇后不同列、不同行,没有考虑对角线的问题,所以认为第 3 个皇后在放入的时候有 7 中选择,那如果我们把对角线的问题考虑进去,那第二个皇后的选择就少了一点。同样,在放第 3 个皇后的时候,我们再把前面两个皇后的对角线考虑进去,第三个皇后的选择也少了,以此类推,到放第 8 个皇后的时候,选择就更少了,甚至在放第 5 个、第 6 个的时候,就出现了没有任何选择可以选的情况了,也就是死路了。这样总体下来,出现的组合情况肯定是远远小于第二种思路的 4 万多种组合。

对于第三种思路,如果我们出现了死路怎么办?假如我们在放第 5 个皇后的时候,出现了无论怎么放第五个皇后,都会不满足要求,这个时候就说明,前面 4 个皇后的摆放有问题。那怎么呢?我们可以先回退到第 4 步,修改第 4 个皇后的摆放位置,然后再继续尝试摆放第 5 个皇后。如果第 5 个皇后仍然无法摆放,那再回到第 4 步,再修改第 4 个皇后的位置。如果出现第 4 个皇后可以摆的地方全都尝试了,第 5 个皇后还是没有地方可放,那这个时候就说明,第 3 个皇后的位置摆放出了问题,所以重新回到第 3 步,如果后面依旧出现死路,那就依次往后回退,直到找到合适的摆法。

熟悉数据结构与算法的同学这个时候肯定想到了,这种思路就是回溯思想,先从某一条路开始走,一条路走到黑,出现死胡同了,就回到上一个路口(回溯),从另一个方向再次出发,又出现死胡同了,就再返回刚刚的路口,直到将该路口的所有岔道走遍了,如果还是走不通,就继续向前回溯,回到上上个路口,直到找到出路为止。

回溯的思想很简单,那么代码该如何实现呢?实现回溯法最常用的方式就是使用递归了,下面使用回溯思想,用递归代码来实现上面 8 个皇后的摆放问题。

回溯法代码实现

首先我们定义一个长度为 8 的数组:int[] result,用来存放每个皇后摆放在哪个地方,数组的下标表示存放的是第几行的皇后,元素的值表示的是该行的皇后摆放在哪一列。例如:result[0] = 4 表示第 0 行中的皇后放在第 4 列。

另外再定义一个方法 putQueen(row),含义是往第 row 行中放入一个皇后,代码的骨架如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码// 用一个数组存放每一行的皇后的位置,数组的下标表示的是行,元素的值表示的是该行的皇后摆放在哪一列  
public int[] result = new int[8];

/**
 * 往第row行中放入皇后,row最开始从0开始
 * @param row 第几行
 */
public void putQueen(int row) {
    if (row == 8) {   // 如果等于8表示8个皇后都摆放完了,直接返回即可
        printQueen();   // 打印
        return;
    }
    for (int column = 0; column < 8; column++) {    // 尝试分别将皇后放在第0-7列中
        if (isAccessible(row, column)) {   // 在真正将皇后放进棋盘之前,先判断这个位置能不能摆放皇后
            result[row] = column;   // 放入皇后
            putQueen(row + 1);    // 在下一行中放入皇后
        }
    }
}

在每次往某一行的某一列中放入皇后之前,我们需要判断一下,该行该列是不是在前面几个皇后的攻击范围之内(行、列、对角线)。所以定义了一个方法 isAccessible(row, column),该方法就是来判断该行该列能不能放入皇后。判断逻辑是什么呢?就是从当前行依次向上遍历,判断前面几行的皇后的攻击范围是不是会覆盖到第 row 行第 column 列,具体代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码/**  
 * 判断第row行,第column列能不能摆放皇后
 * @return
 */
private boolean isAccessible(int row, int column) {
    int left = column - 1;  // 对角线左上
    int right = column + 1; // 对角线右上
    // 从当前行的上一行开始,向上遍历 (没有必要判断当前行的下面几行了,因为下面几行肯定没有放皇后啊)
    for (int i = row - 1; i >= 0; i--) {
        if (result[i] == column) return false;   // 当前列上不能有皇后
        if (left >= 0 && result[i] == left) return false;  // 左上对角线上不能有皇后
        if (right < 8 && result[i] == right) return false; // 右上对角线上不能有皇后
        left--;
        right++;
    }
    return true;
}

最后为了方便显示皇后的摆放位置,写了一个打印 8*8 棋盘的方法。

1
2
3
4
5
6
7
8
9
10
复制代码private void printQueen() {  
    for (int row = 0; row < 8; row++) {
        for (int column = 0; column < 8; column++) {
            if (result[row] == column) System.out.print("Q ");
            else System.out.print("* ");
        }
        System.out.println();
    }
    System.out.println("=========================");
}

最终运行结果一共有 92 中摆放方法。

总结

回溯法的思想很简单,就是在岔口上先选择一条路走下去,走不通了,就回退到上一步,继续走,直到尝试所有的选择为止。虽然思路简单,但实现回溯法的代码相对而言,不是那么好写,经常出现一看就会,一些就错,笔者作为一名菜鸟就是如此,经常写错,尤其是边界值的地方,因此也建议大家多亲自动手敲敲代码。

其他

  • redo log —— MySQL宕机时数据不丢失的原理
  • 索引数据结构之B-Tree与B+Tree(上篇)
  • 索引数据结构之B-Tree与B+Tree(下篇)

微信公众号

本文转载自: 掘金

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

面试官:兄弟,说说Java到底是值传递还是引用传递

发表于 2020-04-23

二哥,好久没更新面试官系列的文章了啊,真的是把我等着急了,所以特意过来催催。我最近一段时间在找工作,能从二哥的文章中学到一点就多一点信心啊!

说句实在话,离读者 trust you 发给我这段信息已经过去 1 周时间了。不是我怠慢,确实是可更新的内容实在是太多了。这不,又有两个读者不约而同地要求我更新一下 Java 到底是值传递还是引用传递方面的文章——其实这个问题我之前是写过的,但现在看起来答案似乎不够尽善尽美,所以打算以面试的角度重写一篇。

七年前,我从温和湿润的苏州回到古色古香的洛阳,抱着一幅“天下我有”的心态“约谈”了几位面试官。其中有一位叫老马,让我印象深刻。因为他当时扔了一个面试题把我砸懵了:说说 Java 到底是值传递还是引用传递吧。

我当时年轻气盛,自认为所有的面试题都能对答如流,没想到被老马“刁难”了——原来洛阳这块互联网的荒漠也有技术专家啊。现在回想起来,脸上不自觉地就泛起了羞愧的红晕:当时真菜!不管怎么说,是时候写篇文章剖析一下值传递还是引用传递的区别了。

将参数传递给方法有两种常见的方式,一种是“值传递”,一种是“引用传递”。C 语言本身只支持值传递,它的衍生品 C++ 既支持值传递,也支持引用传递,而 Java 只支持值传递。

01、值传递 VS 引用传递

首先,我们必须要搞清楚,到底什么是值传递,什么是引用传递,否则,讨论 Java 到底是值传递还是引用传递就显得毫无意义。

当一个参数按照值的方式在两个方法之间传递时,调用者和被调用者其实是用的两个不同的变量——被调用者中的变量(原始值)是调用者中变量的一份拷贝,对它们当中的任何一个变量修改都不会影响到另外一个变量。

而当一个参数按照引用传递的方式在两个方法之间传递时,调用者和被调用者其实用的是同一个变量,当该变量被修改时,双方都是可见的。

Java 程序员之所以容易搞混值传递和引用传递,主要是因为 Java 有两种数据类型,一种是基本类型,比如说 int,另外一种是引用类型,比如说 String。

基本类型的变量存储的都是实际的值,而引用类型的变量存储的是对象的引用——指向了对象在内存中的地址。值和引用存储在 stack(栈)中,而对象存储在 heap(堆)中。

之所以有这个区别,是因为:

  • 栈的优势是,存取速度比堆要快,仅次于直接位于 CPU 中的寄存器。但缺点是,栈中的数据大小与生存周期必须是确定的。
  • 堆的优势是可以动态地分配内存大小,生存周期也不必事先告诉编译器,Java 的垃圾回收器会自动收走那些不再使用的数据。但由于要在运行时动态分配内存,存取速度较慢。

02、基本类型的参数传递

众所周知,Java 有 8 种基本数据类型,分别是 int、long、byte、short、float、double 、char 和 boolean。它们的值直接存储在栈中,每当作为参数传递时,都会将原始值(实参)复制一份新的出来,给形参用。形参将会在被调用方法结束时从栈中清除。

来看下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
复制代码public class PrimitiveTypeDemo {
public static void main(String[] args) {
int age = 18;
modify(age);
System.out.println(age);
}

private static void modify(int age1) {
age1 = 30;
}
}

1)main 方法中的 age 是基本类型,所以它的值 18 直接存储在栈中。

2)调用 modify() 方法的时候,将为实参 age 创建一个副本(形参 age1),它的值也为 18,不过是在栈中的其他位置。

3)对形参 age 的任何修改都只会影响它自身而不会影响实参。

03、引用类型的参数传递

来看一段创建引用类型变量的代码:

1
复制代码Writer writer = new Writer(18, "沉默王二");

writer 是对象吗?还是对象的引用?为了搞清楚这个问题,我们可以把上面的代码拆分为两行代码:

1
2
复制代码Writer writer;
writer = new Writer(18, "沉默王二");

假如 writer 是对象的话,就不需要通过 new 关键字创建对象了,对吧?那也就是说,writer 并不是对象,在“=”操作符执行之前,它仅仅是一个变量。那谁是对象呢?new Writer(18, "沉默王二"),它是对象,存储于堆中;然后,“=”操作符将对象的引用赋值给了 writer 变量,于是 writer 此时应该叫对象引用,它存储在栈中,保存了对象在堆中的地址。

每当引用类型作为参数传递时,都会创建一个对象引用(实参)的副本(形参),该形参保存的地址和实参一样。

来看下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码public class ReferenceTypeDemo {
public static void main(String[] args) {
Writer a = new Writer(18);
Writer b = new Writer(18);
modify(a, b);

System.out.println(a.getAge());
System.out.println(b.getAge());
}

private static void modify(Writer a1, Writer b1) {
a1.setAge(30);

b1 = new Writer(18);
b1.setAge(30);
}
}

1)在调用 modify() 方法之前,实参 a 和 b 指向的对象是不一样的,尽管 age 都为 18。

2)在调用 modify() 方法时,实参 a 和 b 都在栈中创建了一个新的副本,分别是 a1 和 b1,但指向的对象是一致的(a 和 a1 指向对象 a,b 和 b1 指向对象 b)。

3)在 modify() 方法中,修改了形参 a1 的 age 为 30,意味着对象 a 的 age 从 18 变成了 30,而实参 a 指向的也是对象 a,所以 a 的 age 也变成了 30;形参 b1 指向了一个新的对象,随后 b1 的 age 被修改为 30。

修改 a1 的 age,意味着同时修改了 a 的 age,因为它们指向的对象是一个;修改 b1 的 age,对 b 却没有影响,因为它们指向的对象是两个。

程序输出的结果如下所示:

1
2
复制代码30
18

果然和我们的分析是吻合的。

04、最后

好了,我亲爱的读者朋友,以上就是本文的全部内容了。看完之后,再遇到面试官问 Java 到底是值传递还是引用传递时,就不用担心被刁难了。我是沉默王二,一枚有趣的程序员。原创不易,莫要白票,请你为本文点赞个吧,这将是我写作更多优质文章的最强动力。

如果觉得文章对你有点帮助,请微信搜索「 沉默王二 」第一时间阅读,回复【666】更有我为你精心准备的 500G 高清教学视频(已分门别类)。本文 GitHub 已经收录,有大厂面试完整考点,欢迎 Star。

本文转载自: 掘金

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

深度探索 Gradle 自动化构建技术(四、自定义 Grad

发表于 2020-04-23

前言

成为一名优秀的Android开发,需要一份完备的知识体系,在这里,让我们一起成长为自己所想的那样~。

一、Gradle 插件概述

自定义 Gradle 插件的本质就是把逻辑独立的代码进行抽取和封装,以便于我们更高效地通过插件依赖这一方式进行功能复用。

而在 Android 下的 gradle 插件共分为 两大类,如下所示:

  • 1、脚本插件:同普通的 gradle 脚本编写形式一样,通过 apply from: ‘JsonChao.gradle’ 引用。
  • 2、对象插件:通过插件全路径类名或 id 引用,它主要有 三种编写形式,如下所示:
    • 1)、在当前构建脚本下直接编写。
    • 2)、在 buildSrc 目录下编写。
    • 3)、在完全独立的项目中编写。

下面👇,我们就先来看看如何编写一个脚本插件。

二、脚本插件

同普通的 gradle 脚本编写形式一样,我们既可以写在 build.gradle 里面,也可以自己新建一个 gradle 脚本文件进行编写。

1
2
3
4
5
6
7
groovy复制代码class PluginDemo implements Plugin<Project> {

    @Override
    void apply(Project target) { 
        println 'Hello author!'
    } 
}

然后,在需要使用的 gradle 脚本中通过 apply plugin: pluginName 的方式即可引用对应的插件。

1
groovy复制代码apply plugin: PluginDemo

三、运用 buildSrc 默认插件目录

在完成几个自定义 Gradle 插件之后,我发现在 buildSrc 目录下编写插件的方式是开发效率最高的,首先,buildSrc 是默认的插件目录,其次,在 buildSrc 目录下与独立工程的插件工程一样,也能够发布插件,这里仅仅只需对某些配置做一些调整即可,下面,我们就来看看如何来创建一个自定义 Gradle 插件。

1、创建一个能运行起来的空 Plugin

首先需要了解的是,buildSrc 目录是 gradle 默认的构建目录之一,该目录下的代码会在构建时自动地进行编译打包,然后它会被添加到 buildScript 中的 classpath 下,所以不需要任何额外的配置,就可以直接被其他模块中的 gradle 脚本引用。此外,关于 buildSrc,我们还需要注意以下 两点:

  • 1)、buildSrc 的执行时机不仅早于任何⼀个 project(build.gradle),而且也早于 settings.gradle。
  • 2)、settings.gradle 中如果配置了 ‘:buildSrc’ ,buildSrc ⽬录就会被当做是子 Project , 因会它会被执行两遍。所以在 settings.gradle 里面应该删掉 ‘:buildSrc’ 的配置。

插件 moudle 创建三部曲

  • 1)、新建一个 module,并将其命名为 buildSrc。这样,Gradle 默认会将其识别会工程的插件目录。
  • 2)、src 目录下删除仅保留一个空的 main 目录,并在 main 目录下新建 1 个 groovy 目录与 1 个 resources 目录。
  • 3)、将 buildSrc 中的 build.gradle 中的所有配置删去,并配置 groovy、resources 为源码目录与相关依赖即可。配置代码如下所示:
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
groovy复制代码apply plugin: 'groovy'

repositories {
    google()
    mavenCentral()
    jcenter()
}

dependencies {
    // Groovy DSL
    implementation localGroovy()
    // Gradle DSL
    implementation gradleApi()

    // Android DSL
    implementation 'com.android.tools.build:gradle:3.6.2'

    // ASM V7.1
    implementation group: 'org.ow2.asm', name: 'asm', version: '7.1'
    implementation group: 'org.ow2.asm', name: 'asm-commons', version: '7.1'

}

sourceSets {
    main {
        groovy {
            srcDir 'src/main/groovy'
        }

        resources {
            srcDir 'src/main/resources'
        }
    }
}

插件创建二部曲

  • 1)、首先,在我的 main 目录下创建一个递归文件夹 “com.json.chao.study”,里面直接新建一个名为 CustomGradlePlugin 的普通文件。然后,在文件中写入 ‘class CustomGradlePlugin’ ,这时 CustomGradlePlugin 会被自动识别为类,接着将其实现 Plugin 接口,其中的 apply 方法就是插件被引入时要执行的方法,这样,自定义插件类就基本完成了,CustomGradlePlugin 类的代码如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
groovy复制代码/**
 * 自定义插件
 */
class CustomGradlePlugin implements Plugin<Project> {

    /**
     * 插件被引入时要执行的方法
     * @param project 引入当前插件的 project
     */
    @Override
    void apply(Project project) {
        println "Hello plugin..." + project.name
    }
}
  • 2)、接着,在 resources 目录下创建一个 META-INF.gradle-plugins 的递归目录,里面新建一个 “com.json.chao.study.properties” 文件,其中 ‘.properties’ 前面的名字即为 自定义插件的名字,在该文件中,我们需要标识该插件对应的插件实现类,代码如下所示:
    implementation-class=com.json.chao.study.CustomGradlePlugin
    这样,一个最简单的自定义插件就完成了。接着,我们直接在 app moudle 下的 build.gradle 文件中使用 ‘apply plugin: ‘com.json.chao.study’ 引入我们定义好的插件然后同步工程即可看到如下输出:
1
2
3
4
groovy复制代码...
> Configure project :app
Hello plugin...app
...

可以看到,通过 id 引用的方式,我们可以隐藏类名等细节,使得插件的引用变得更加容易。

2、使用自定义 Extension 与 Task

1、自定义 Extension

在 深度探索 Gradle 自动化构建技术(三、Gradle 核心解密) 一文中我们讲解了如何创建一个版本信息管理的 task,这里我们就可以直接将它接入到 gradle 的构建流程之中。为了能让 App 传入相关的版本信息和生成的版本信息文件路径,我们需要一个用于配置版本信息的 Extension,其实质就是一个实体类,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
groovy复制代码/*
 * Description: 负责 Release 版本管理的扩展属性区域
 *
 * @author quchao
 */
class ReleaseInfoExtension {

    String versionName;
    String versionCode;
    String versionInfo;
    String fileName;
}

然后,在我们的 CustomGradlePlugin 的 apply 方法中加入下面代码去创建用于设置版本信息的扩展属性,如下所示:

1
2
groovy复制代码// 创建用于设置版本信息的扩展属性
project.extensions.create("releaseInfo", ReleaseInfoExtension.class)

在 project.extensions.create 方法的内部其实质是 通过 project.extensions.create() 方法来获取在 releaseInfo 闭包中定义的内容并通过反射将闭包的内容转换成一个 ReleaseInfoExtension 对象。

最后,我们就可以在 app moudle 的 build.gradle 脚本中使用 releaseInfo 去配置扩展属性,代码如下所示:

1
2
3
4
5
6
groovy复制代码releaseInfo {
    versionCode = "1"
    versionName = "1.0.0"
    versionInfo = "第一个版本~"
    fileName = "releases.xml"
}

2、自定义 Task

使用自定义扩展属性 Extension 仅仅是为了让使用插件者有配置插件的能力。而插件还得借助自定义 Task 来实现相应的功能,这里我们需要创建一个更新版本信息的 Task,我们将其命名为 ReleaseInfoTask,其具体实现代码如下所示:

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
groovy复制代码/**
 * 更新版本信息的 Task
 */
class ReleaseInfoTask extends DefaultTask {

    ReleaseInfoTask() {
        // 1、在构造器中配置了该 Task 对应的 Task group,即 Task 组,并为其添加上了对应的描述信息。
        group = 'version_manager'
        description = 'release info update'
    }

    // 2、在 gradle 执行阶段执行
    @TaskAction
    void doAction() {
        updateVersionInfo();
    }

    private void updateVersionInfo() {
        // 3、从 realeaseInfo Extension 属性中获取相应的版本信息
        def versionCodeMsg = project.extensions.releaseInfo.versionCode;
        def versionNameMsg = project.extensions.releaseInfo.versionName;
        def versionInfoMsg = project.extensions.releaseInfo.versionInfo;
        def fileName = project.extensions.releaseInfo.fileName;
        def file = project.file(fileName)
        // 4、将实体对象写入到 xml 文件中
        def sw = new StringWriter()
        def xmlBuilder = new MarkupBuilder(sw)
        if (file.text != null && file.text.size() <= 0) {
            //没有内容
            xmlBuilder.releases {
                release {
                    versionCode(versionCodeMsg)
                    versionName(versionNameMsg)
                    versionInfo(versionInfoMsg)
                }
            }
            //直接写入
            file.withWriter { writer -> writer.append(sw.toString())
            }
        } else {
            //已有其它版本内容
            xmlBuilder.release {
                versionCode(versionCodeMsg)
                versionName(versionNameMsg)
                versionInfo(versionInfoMsg)
            }
            //插入到最后一行前面
            def lines = file.readLines()
            def lengths = lines.size() - 1
            file.withWriter { writer ->
                lines.eachWithIndex { line, index ->
                    if (index != lengths) {
                        writer.append(line + '\r\n')
                    } else if (index == lengths) {
                        writer.append('\r\r\n' + sw.toString() + '\r\n')
                        writer.append(lines.get(tlengths))
                    }
                }
            }
        }
    }
}

首先,在注释1处,我们 在构造器中配置了该 Task 对应的 Task group,即 Task 组,并为其添加上了对应的描述信息。接着,在注释2处,我们 使用了 @TaskAction 注解标注了 doAction 方法,这样它就会在 gradle 执行阶段执行。在注释3处,我们 使用了 project.extensions.releaseInfo.xxx 一系列 API 从 realeaseInfo Extension 属性中了获取相应的版本信息。最后,注释4处,就是用来 实现该 task 的核心功能,即将实体对象写入到 xml 文件中。

可以看到,一般的插件 task 都会遵循前三个步骤,最后一个步骤就是用来实现插件的核心功能。
当然,最后别忘了在我们的 CustomGradlePlugin 的 apply 方法中加入下面代码去创建 ReleaseInfoTask 实例,代码如下所示:

1
2
groovy复制代码// 创建用于更新版本信息的 task
project.tasks.create("releaseInfoTask", ReleaseInfoTask.class)

四、变体(Variants)的作用

要理解 Variants 的作用,就必须先了解 flavor、dimension 与 variant 这三者之间的关系。在 android gradle plugin V3.x 之后,每个 flavor 必须对应一个 dimension,可以理解为 flavor 的分组,然后不同 dimension 里的 flavor 会组合成一个 variant。示例代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
groovy复制代码flavorDimensions "size", "color"

productFlavors {
    JsonChao {
        dimension "size"
    }
    small {
        dimension "size"
    }
    blue {
        dimension "color"
    }
    red {
        dimension "color"
    }
}

在 Android 对 Gradle 插件的扩展支持之中,其中最常用的便是 利用变体(Variants)来对构建过程中的各个默认的 task 进行 hook。关于 Variants 共有 三种类型,如下所示:

  • 1)、applicationVariants:只适用于 app plugin。
  • 2)、libraryVariants:只适用于 library plugin。
  • 3)、testVariants:在 app plugin 与 libarary plugin 中都适用。

1、使用 applicationVariants

为了讲解 applicationVariants 的作用,我们需要先在 app moudle 的 build.gradle 文件中配置几个 flavor,代码如下所示:

1
2
3
4
5
groovy复制代码productFlavors {
    douyin {}
    weixin {}
    google {}
}

1)、使用 applicationVariants.all 在配置阶段之后去获取所有 variant 的 name 与 baseName

然后,我们可以 使用 applicationVariants.all 在配置阶段之后去获取所有 variant 的 name 与 baseName。代码如下所示:

1
2
3
4
5
6
7
groovy复制代码this.afterEvaluate {
    this.android.applicationVariants.all { variant ->
        def name = variant.name
        def baseName = variant.baseName
        println "name: $name, baseName: $baseName"
    }
}

最后,执行 gradle clean task,其输出信息如下所示:

1
2
3
4
5
6
7
8
gradle复制代码> Configure project :app
name: douyinDebug, baseName: douyin-debug
name: douyinRelease, baseName: douyin-release
name: weixinDebug, baseName: weixin-debug
name: weixinRelease, baseName: weixin-release
name: googleDebug, baseName: google-debug
name: googleRelease, baseName: google-release
可以看到,name 与 baseName 的区别:baiduDebug 与 baidu-debug 。

2)、使用 applicationVariants.all 在配置阶段之后去修改输出的 APK 名称

1
2
3
4
5
6
7
8
9
10
11
groovy复制代码this.afterEvaluate {
    this.android.applicationVariants.all { variant ->
        variant.outputs.each {
            // 由于我们当前的变体是 application 类型的,所以
            // 这个 output 就是我们 APK 文件的输出路径,我们
            // 可以通过重命名这个文件来修改我们最终输出的 APK 文件
            outputFileName = "app-${variant.baseName}-${variant.versionName}.apk"
            println outputFileName
        }
    }
}

执行 gradle clean task,其输出信息如下所示:

1
2
3
groovy复制代码> Configure project :app
app-debug-1.0.apk
app-release-1.0.apk

3)、对 applicationVariants 中的 Task 进行 Hook

我们可以在 android.applicationVariants.all 的闭包中通过 variant.task 来获取相应的 Task。代码如下所示:

1
2
3
4
5
6
groovy复制代码this.afterEvaluate {
    this.android.applicationVariants.all { variant ->
        def task = variant.checkManifest
        println task.name
    }
}

然后,执行 gradle clean task,其输出信息如下所示:

1
2
gradle复制代码checkDebugManifest
checkReleaseManifest

既然可以获取到变体中的 Task,我们就可以根据不同的 Task 类型来做特殊处理。例如,我们可以利用 variants 去解决插件化开发中的痛点:编写一个对插件化项目中的各个插件自动更新的脚本,其核心代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
groovy复制代码this.afterEvaluate {
    this.android.applicationVariants.all { variant ->
        // checkManifest 这个 Task 在 Task 容器中
        // 靠前的位置,我们可以在这里预先更新插件。
        def checkTask = variant.checkManifest
        checkTask.doFirst {
            def bt = variant.buildType.name
            if (bt == 'qa' || bt == 'preview'
                    || bt == 'release') {
                update_plugin(bt)
            }
        }
    }
}

至于 update_plugin 的实现,主要就是一些插件安全校验与下载的逻辑,这部分其实跟 Gradle 没有什么联系,如果有需要,可以在 Awesome-WanAndroid 项目下查看。

五、Transform

众所周知,Google 官方在 Android Gradle V1.5.0 版本以后提供了 Transfrom API, 允许第三方 Plugin 在打包成 .dex 文件之前的编译过程中操作 .class 文件,我们需要做的就是实现 Transform 来对 .class 文件遍历以拿到所有方法,修改完成后再对原文件进行替换即可。

总的来说,Gradle Transform 的功能就是把输入的 .class 文件转换为目标字节码文件。
下面,我们来了解一下 Transform 的两个基础概念。

1、TransformInput

TransformInput 可认为是所有输入文件的一个抽象,它主要包括两个部分,如下所示:

  • 1)、DirectoryInput 集合:表示以源码方式参与项目编译的所有目录结构与其目录下的源码文件。
  • 2)、JarInput 集合:表示以 jar 包方式参与项目编译的所有本地 jar 包和远程 jar 包。需要注意的是,这个 jar 所指也包括 aar。

2、TransformOutputProvider

表示 Transform 的输出,利用它我们可以获取输出路径等信息。

3、实现 Transform

1、首先,配置 Android DSL 相关的依赖:

1
2
3
4
5
6
7
8
9
10
groovy复制代码// 由于 buildSrc 的执行时机要早于任何一个 project,因此需要⾃⼰添加仓库 
repositories {
    google()
    jcenter() 
}

dependencies {
    // Android DSL
    implementation 'com.android.tools.build:gradle:3.6.2'
}

2、然后,继承 com.android.build.api.transform.Transform ,创建⼀个 Transform 的子类

其创建步骤可以细分为五步,如下所示:

  • 1)、重写 getName 方法:返回对应的 Task 名称。
  • 2)、重写 getInputTypes 方法:确定对那些类型的结果进行转换。
  • 3)、重写 getScopes 方法:指定插件的适用范围。
  • 4)、重写 isIncremental 方法:表示是否支持增量更新。
  • 5)、重写 transform 方法:进行具体的转换过程。

下面👇,我们来分别来进行详细讲解。

1)、重写 getName 方法:返回对应的 Task 名称

每一个 Transform 都有一个与之对应的 Transform task,这里便是返回的 task name。它会出现在 app/build/intermediates/transforms 目录下。其代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
groovy复制代码 /**
  * 每一个 Transform 都有一个与之对应的 Transform task,
  * 这里便是返回的 task name。它会出现在
  * app/build/intermediates/transforms 目录下
  *
  * @return Transform Name
  */
 @Override
 String getName() {
     return "MyCustomTransform"
 }

2)、重写 getInputTypes 方法:确定对那些类型的结果进行转换

getInputTypes 方法用于确定我们需要对哪些类型的结果进行转换:如字节码、资源⽂件等等。目前 ContentType 有六种枚举类型,通常我们使用比较频繁的有前两种,如下所示:

  • 1、CONTENT_CLASS:表示需要处理 java 的 class 文件。
  • 2、CONTENT_JARS:表示需要处理 java 的 class 与 资源文件。
  • 3、CONTENT_RESOURCES:表示需要处理 java 的资源文件。
  • 4、CONTENT_NATIVE_LIBS:表示需要处理 native 库的代码。
  • 5、CONTENT_DEX:表示需要处理 DEX 文件。
  • 6、CONTENT_DEX_WITH_RESOURCES:表示需要处理 DEX 与 java 的资源文件。

因为我们需要修改的是字节码,所以直接返回 TransformManager.CONTENT_CLASS 即可,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
groovy复制代码/**
 * 需要处理的数据类型,目前 ContentType
 * 有六种枚举类型,通常我们使用比较频繁的有前两种:
 *      1、CONTENT_CLASS:表示需要处理 java 的 class 文件。
 *      2、CONTENT_JARS:表示需要处理 java 的 class 与 资源文件。
 *      3、CONTENT_RESOURCES:表示需要处理 java 的资源文件。
 *      4、CONTENT_NATIVE_LIBS:表示需要处理 native 库的代码。
 *      5、CONTENT_DEX:表示需要处理 DEX 文件。
 *      6、CONTENT_DEX_WITH_RESOURCES:表示需要处理 DEX 与 java 的资源文件。 
 *
 * @return
 */
@Override
Set<QualifiedContent.ContentType> getInputTypes() {
    // 用于确定我们需要对哪些类型的结果进行转换:如字节码、资源⽂件等等。
    // return TransformManager.RESOURCES
    return TransformManager.CONTENT_CLASS
}

3)、重写 getScopes 方法:指定插件的适用范围

getScopes 方法则是用于确定插件的适用范围:目前 Scope 有 五种基本类型,如下所示:

  • 1、PROJECT:只有项目内容。
  • 2、SUB_PROJECTS:只有子项目。
  • 3、EXTERNAL_LIBRARIES:只有外部库,
  • 4、TESTED_CODE:由当前变体(包括依赖项)所测试的代码。
  • 5、PROVIDED_ONLY:只提供本地或远程依赖项。

此外,还有一些复合类型,它们是都是由这五种基本类型组成,以实现灵活确定自定义插件的范围,这里通常是指定整个 project,也可以指定其它范围,其代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
grooovy复制代码/**
 * 表示 Transform 要操作的内容范围,目前 Scope 有五种基本类型:
 *      1、PROJECT                   只有项目内容
 *      2、SUB_PROJECTS              只有子项目
 *      3、EXTERNAL_LIBRARIES        只有外部库
 *      4、TESTED_CODE               由当前变体(包括依赖项)所测试的代码
 *      5、PROVIDED_ONLY             只提供本地或远程依赖项
 *      SCOPE_FULL_PROJECT 是一个 Scope 集合,包含 Scope.PROJECT,
Scope.SUB_PROJECTS, Scope.EXTERNAL_LIBRARIES 这三项,即当前 Transform
的作用域包括当前项目、子项目以及外部的依赖库
 *
 * @return
 */
@Override
Set<? super QualifiedContent.Scope> getScopes() {
    // 适用范围:通常是指定整个 project,也可以指定其它范围
    return TransformManager.SCOPE_FULL_PROJECT
}

4)、重写 isIncremental 方法:表示是否支持增量更新

isIncremental 方法用于确定是否支持增量更新,如果返回 true,TransformInput 会包含一份修改的文件列表,如果返回 false,则会进行全量编译,并且会删除上一次的输出内容。

1
2
3
4
5
6
7
grooovy复制代码@Override
boolean isIncremental() {
    // 是否支持增量更新
    // 如果返回 true,TransformInput 会包含一份修改的文件列表
    // 如果返回 false,会进行全量编译,删除上一次的输出内容
    return false
}

5)、重写 transform 方法:进行具体的转换过程

在 transform 方法中,就是用来给我们进行具体的转换过程的。其实现代码如下所示:

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
groovy复制代码/**
 * 进行具体的转换过程
 *
 * @param transformInvocation
 */
@Override
void transform(TransformInvocation transformInvocation) throws
TransformException, InterruptedException, IOException {
    super.transform(transformInvocation)
    println '--------------- MyTransform visit start --------------- '
    def startTime = System.currentTimeMillis()
    def inputs = transformInvocation.inputs
    def outputProvider = transformInvocation.outputProvider
    // 1、删除之前的输出
    if (outputProvider != null)
        outputProvider.deleteAll()
    // Transform 的 inputs 有两种类型,一种是目录,一种是 jar
包,要分开遍历
    inputs.each { TransformInput input ->
        // 2、遍历 directoryInputs(本地 project 编译成的多个 class
⽂件存放的目录)
        input.directoryInputs.each { DirectoryInput directoryInput ->
            handleDirectory(directoryInput, outputProvider)
        }
        // 3、遍历 jarInputs(各个依赖所编译成的 jar 文件)
        input.jarInputs.each { JarInput jarInput ->
            handleJar(jarInput, outputProvider)
        }
    }
    def cost = (System.currentTimeMillis() - startTime) / 1000
    println '--------------- MyTransform visit end --------------- '
    println "MyTransform cost : $cost s"
}

这里我们主要是做了三步处理,如下所示:

  • 1)、删除之前的输出。
  • 2)、遍历 directoryInputs(本地 project 编译成的多个 class ⽂件存放的目录)。
  • 3)、遍历 jarInputs(各个依赖所编译成的 jar 文件)。

在 handleDirectory 与 handleJar 方法中则是进行了相应的 文件处理 && ASM 字节码修改。这里我直接放出 Transform 的通用模板代码,代码如下所示:

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
groovy复制代码class MyTransform extends Transform {

    
    /**
     * 每一个 Transform 都有一个与之对应的 Transform task,
     * 这里便是返回的 task name。它会出现在 app/build/intermediates/transforms 目录下
     *
     * @return Transform Name
     */
    @Override
    String getName() {
        return "MyCustomTransform"
    }

    /**
     * 需要处理的数据类型,目前 ContentType 有六种枚举类型,通常我们使用比较频繁的有前两种:
     *      1、CONTENT_CLASS:表示需要处理 java 的 class 文件。
     *      2、CONTENT_JARS:表示需要处理 java 的 class 与 资源文件。
     *      3、CONTENT_RESOURCES:表示需要处理 java 的资源文件。
     *      4、CONTENT_NATIVE_LIBS:表示需要处理 native 库的代码。
     *      5、CONTENT_DEX:表示需要处理 DEX 文件。
     *      6、CONTENT_DEX_WITH_RESOURCES:表示需要处理 DEX 与 java 的资源文件。
     *
     * @return
     */
    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        // 用于确定我们需要对哪些类型的结果进行转换:如字节码、资源⽂件等等。
        // return TransformManager.RESOURCES
        return TransformManager.CONTENT_CLASS
    }

    /**
     * 表示 Transform 要操作的内容范围,目前 Scope 有五种基本类型:
     *      1、PROJECT                   只有项目内容
     *      2、SUB_PROJECTS              只有子项目
     *      3、EXTERNAL_LIBRARIES        只有外部库
     *      4、TESTED_CODE               由当前变体(包括依赖项)所测试的代码
     *      5、PROVIDED_ONLY             只提供本地或远程依赖项
     *      SCOPE_FULL_PROJECT 是一个 Scope 集合,包含 Scope.PROJECT, Scope.SUB_PROJECTS, Scope.EXTERNAL_LIBRARIES 这三项,即当前 Transform 的作用域包括当前项目、子项目以及外部的依赖库
     *
     * @return
     */
    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        // 适用范围:通常是指定整个 project,也可以指定其它范围
        return TransformManager.SCOPE_FULL_PROJECT
    }

    @Override
    boolean isIncremental() {
        // 是否支持增量更新
        // 如果返回 true,TransformInput 会包含一份修改的文件列表
        // 如果返回 false,会进行全量编译,删除上一次的输出内容
        return false
    }
    
    /**
     * 进行具体的转换过程
     *
     * @param transformInvocation
     */
    @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation)
        println '--------------- MyTransform visit start --------------- '
        def startTime = System.currentTimeMillis()
        def inputs = transformInvocation.inputs
        def outputProvider = transformInvocation.outputProvider
        // 删除之前的输出
        if (outputProvider != null)
            outputProvider.deleteAll()

        // Transform 的 inputs 有两种类型,一种是目录,一种是 jar 包,要分开遍历
        inputs.each { TransformInput input ->
            // 遍历 directoryInputs(本地 project 编译成的多个 class ⽂件存放的目录)
            input.directoryInputs.each { DirectoryInput directoryInput ->
                handleDirectory(directoryInput, outputProvider)
            }

            // 遍历 jarInputs(各个依赖所编译成的 jar 文件)
            input.jarInputs.each { JarInput jarInput ->
                handleJar(jarInput, outputProvider)
            }
        }

        def cost = (System.currentTimeMillis() - startTime) / 1000
        println '--------------- MyTransform visit end --------------- '
        println "MyTransform cost : $cost s"
    }

    static void handleJar(JarInput jarInput, TransformOutputProvider outputProvider) {
        if (jarInput.file.getAbsolutePath().endsWith(".jar")) {
            // 截取文件路径的 md5 值重命名输出文件,避免同名导致覆盖的情况出现
            def jarName = jarInput.name
            def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
            if (jarName.endsWith(".jar")) {
                jarName = jarName.substring(0, jarName.length() - 4)
            }
            JarFile jarFile = new JarFile(jarInput.file)
            Enumeration enumeration = jarFile.entries()
            File tmpFile = new File(jarInput.file.getParent() + File.separator + "classes_temp.jar")
            // 避免上次的缓存被重复插入
            if (tmpFile.exists()) {
                tmpFile.delete()
            }
            JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(tmpFile))
            while (enumeration.hasMoreElements()) {
                JarEntry jarEntry = (JarEntry) enumeration.nextElement()
                String entryName = jarEntry.getName()
                ZipEntry zipEntry = new ZipEntry(entryName)
                InputStream inputStream = jarFile.getInputStream(jarEntry)
                if (checkClassFile(entryName)) {
                    // 使用 ASM 对 class 文件进行操控
                    println '----------- deal with "jar" class file <' + entryName + '> -----------'
                    jarOutputStream.putNextEntry(zipEntry)
                    ClassReader classReader = new ClassReader(IOUtils.toByteArray(inputStream))
                    ClassWriter classWriter = new ClassWriter(classReader, org.objectweb.asm.ClassWriter.COMPUTE_MAXS)
                    ClassVisitor cv = new MyCustomClassVisitor(classWriter)
                    classReader.accept(cv, EXPAND_FRAMES)
                    byte[] code = classWriter.toByteArray()
                    jarOutputStream.write(code)
                } else {
                    jarOutputStream.putNextEntry(zipEntry)
                    jarOutputStream.write(IOUtils.toByteArray(inputStream))
                }
                jarOutputStream.closeEntry()
            }
            jarOutputStream.close()
            jarFile.close()

            // 生成输出路径 dest:./app/build/intermediates/transforms/xxxTransform/...
            def dest = outputProvider.getContentLocation(jarName + md5Name,
                    jarInput.contentTypes, jarInput.scopes, Format.JAR)
            // 将 input 的目录复制到 output 指定目录
            FileUtils.copyFile(tmpFile, dest)
            tmpFile.delete()
        }
    }

    static void handleDirectory(DirectoryInput directoryInput, TransformOutputProvider outputProvider) {
        // 在增量模式下可以通过 directoryInput.changedFiles 方法获取修改的文件
//        directoryInput.changedFiles
        if (directoryInput.file.size() == 0)
            return
        if (directoryInput.file.isDirectory()) {
            /**遍历以某一扩展名结尾的文件*/
            directoryInput.file.traverse(type: FileType.FILES, nameFilter: ~/.*\.class/) {
                File classFile ->
                    def name = classFile.name
                    if (checkClassFile(name)) {
                        println '----------- deal with "class" file <' + name + '> -----------'
                        def classReader = new ClassReader(classFile.bytes)
                        def classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
                        def classVisitor = new MyCustomClassVisitor(classWriter)
                        classReader.accept(classVisitor, EXPAND_FRAMES)
                        byte[] codeBytes = classWriter.toByteArray()
                        FileOutputStream fileOutputStream = new FileOutputStream(
                                classFile.parentFile.absolutePath + File.separator + name
                        )
                        fileOutputStream.write(codeBytes)
                        fileOutputStream.close()
                    }
            }
        }
        /// 获取 output 目录 dest:./app/build/intermediates/transforms/hencoderTransform/
        def destFile = outputProvider.getContentLocation(
                directoryInput.name,
                directoryInput.contentTypes,
                directoryInput.scopes,
                Format.DIRECTORY
        )
        // 将 input 的目录复制到 output 指定目录
        FileUtils.copyDirectory(directoryInput.file, destFile)
    }

    /**
     * 检查 class 文件是否需要处理
     *
     * @param fileName
     * @return class 文件是否需要处理
     */
    static boolean checkClassFile(String name) {
        // 只处理需要的 class 文件
        return (name.endsWith(".class") && !name.startsWith("R\$")
                && "R.class" != name && "BuildConfig.class" != name
                && "android/support/v4/app/FragmentActivity.class" == name)
    }

编写完 Transform 的代码之后,我们就可以 在 CustomGradlePlugin 的 apply 方法中加入下面代码去注册 MyTransform 实例,代码如下所示:

1
2
3
groovy复制代码// 注册我们自定义的 Transform
def appExtension = project.extensions.findByType(AppExtension.class)
appExtension.registerTransform(new MyTransform());

上面的自定义 Transform 的代码就是一个标准的 Transorm + ASM 修改字节码的模板代码,在使用时,我们只需要编写我们自己的 MyClassVisitor 类去修改相应的字节码文件即可,关于 ASM 的使用可以参考我前面写的 深入探索编译插桩技术(四、ASM 探秘) 一文。

4、Transform 使用小结

我们可以自定义一个 Gradle Plugin,然后注册一个 Transform 对象,在 tranform 方法里,可以分别遍历目录和 jar 包,然后我们就可以遍历当前应用程序的所有 .class 文件,然后再利用 ASM 框架的 Core API 去加载相应的 .class 文件,并解析,就可以找到满足特定条件的 .class 文件和相关方法,最后去修改相应的方法以实现动态插入相应的字节码。

六、发布 Gradle 插件

发布插件可以分为 两种形式,如下所示:

  • 1)、发布插件到本地仓库。
  • 2)、发布插件到远程仓库。

下面,我们就来使用 mavenDeployer 插件来将插件分别发布在本地仓库和远程仓库。

1、发布插件到本地仓库

引入 maven 插件之后,我们在 uploadArchives 加入想要上传的仓库地址与相关配置即可,这样 Gradle 在执行 uploadArchives 时将生成和上传 pom.xml 文件,将插件上传至本地仓库的示例代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
groovy复制代码apply plugin: 'maven'

uploadArchives {
    repositories {
        mavenDeployer {
            // 上传到当前项目根目录下的本地 repo 目录中
            repository(url: uri('../repo'))

            pom.groupId = 'com.json.chao.study'
            pom.artifactId = 'custom-gradle-plugin'
            pom.version = '1.0.0'
        }
    }
}

可以看到,这里我们将本地仓库路径指定为了根目录下的 repo 文件夹。此外,我们需要配置插件中的一些属性信息,通常包含如下三种:

  • 1)、groupId:组织/公司名称。
  • 2)、artifactId:项目/模块名称。
  • 3)、version:项目/模块的当前版本号。

2、发布插件到远程仓库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
groovy复制代码apply plugin: 'maven'

uploadArchives {
    configuration = configurations.archives
    repositories {
        mavenDeployer {
            repository(url: MAVEN_REPO_RELEASE_URL) {
                authentication(userName: "JsonChao", password: "123456")
            }
            
            pom.groupId = 'com.json.chao.study'
            pom.artifactId = 'custom-gradle-plugin'
            pom.version = '1.0.0'
        }
    }
}

不同于发布插件到本地仓库的方式,发布插件到远程仓库仅仅是将 repository 中的 url 替换为 远程 maven 仓库的 url,并将需要认证的 userName 与 password 进行配置即可。将插件配置好了之后,我们就可以通过 ./gradlew uploadArchivers 来执行这个 task,实现将插件发布到本地/远程仓库。

七、调试 Gradle 插件

1、首先,我们需要在 AndroidStudio 中增加一个 Remote 配置,如下图所示:

最后,我们只需要输入插件的 Name 即可,我们这里的插件名字是 plugin-release。

2、在命令行输入如下命令开启 debug 调试相应的 Task(调试的 task 中比较多的是 assembleRelease 这个 Task,因为我们最常做的就是对打包流程进行 Hook),如下所示:

1
gradle复制代码./gradlew --no-daemon -Dorg.gradle.debug=true :app:assembleRelease

3、最后,我们在插件代码中打好相应的断点,选中我们上一步创建的 Remote 配置,点击 Debug 按钮即可开始调试我们的自定义插件代码了。

八、总结

在本文中,我们一起学习了如何自定义一个 Gradle 插件,如果你还没创建一个属于自己的自定义 Gradle 插件,我强烈建议你去尝试一下。当然,仅仅会制造一个自定义 Gradle 插件还远远不够,在下一篇文章中,我们会来全方位地深入剖析 Gradle 插件架构体系中涉及的核心原理,尽请期待~

公钟号同名,欢迎关注,关注后回复 Framework,我将分享给你一份我这两年持续总结、细化、沉淀出来的 Framework 体系化精品面试题,里面很多的核心题答案在面试的压力下,经过了反复的校正与升华,含金量极高~

参考链接:

  • 1、自定义 Gradle 插件 官方文档
  • 2、Android Plugin DSL
  • 3、Transform API
  • 4、一篇文章带你了解Gradle插件的所有创建方式
  • 5、写给 Android 开发者的 Gradle 系列(三)撰写 plugin
  • 6、Gradle Transform API 的基本使用
  • 7、如何开发一款高性能的 gradle transform
  • 8、gradle超详细解析
  • 9、【Android】函数插桩(Gradle + ASM)

本文转载自: 掘金

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

用批处理文件bat实现代码自动提交和项目部署,还不赶紧收藏起

发表于 2020-04-22

简介:

今天我们的主角是批处理bat脚本。一种简化的脚本语言,主要应用于Windows系统和Dos中。本文利用简单的几行代码,带你实现代码自动提交,项目轻松运行。希望能帮助到你。
知识整理不易,麻烦点个免费的赞,谢谢


需要

掌握简单的bat批处理语法

一台电脑


一:最终效果图

平常提交代码,没有冲突的情况下,你可能需要依次输入以下几行命令:

1
2
3
4
复制代码  git add test.txt
git commit -m '提交测试文件'
git pull
git push

有了批处理脚本,我们只要双击 FilePush.bat 批处理文件,即可完成自动提交。省去写重复的代码提交命令。

下面展示一下效果图:

代码自动提交


二:看看脚本怎么写的

FilePush.bat 文件代码如下:

1
2
3
4
5
6
7
8
9
10
11
复制代码@echo off
echo "-------Begin-------"
git status
set /p msg=请输入提交注释:
git add .
git commit -m %msg%
git pull
git push
echo 推送成功:【%msg%】
echo "--------End!--------"
pause

没错,只需要简简单单几行代码。

注意:git add .命令是将所有修改写到缓存区。想要参考此脚本的同学,需要看具体情况。如果有不需要仓库管理的文件,记得添加到 .gitignore 文件。

三:各行脚本解析

由于 git 命令不是本文主题,这里不介绍讲git命令。

1
2
3
4
5
6
7
8
9
10
11
复制代码@echo off                     #屏幕不显示bat文件中所有的命令行

echo "-------Begin-------" #输出字符,"-------Begin-------"

set /p msg=请输入提交注释: #接受输入的内容,以回车表示结束,赋值给变量 msg

echo 推送成功:【%msg%】 #输出字符,推送成功:【%msg%】,msg为输入的变量值

echo "--------End!--------" #输出字符,"--------End!--------"

pause #暂停,否则 dos 界面会一闪而过

四:其他应用场景

双击部署程序。例子:启动一个jar包并指定配置文件。如下图:

启动Jar包


五:延伸

第四步的例子是 eureka 启动脚本,大家可以去看看,eureka 也有一个部署的批处理文件。

文件命令如下:

1
2
复制代码
java -jar eureka.jar --spring.config.location=eureka-server.properties

我们可以用在自己的项目上,如:

1
2
复制代码
java -jar yourProject.jar --spring.config.location=application.properties

总结:

  • 最终效果图
  • 看看脚本怎么写的
  • 各行脚本解析
  • 其他应用场景
  • 延伸

注意:

本文分享的技巧需要根据实际情况调整脚本

这是我个人的经验和观点,如果有错误的地方,欢迎评论区讨论,一起学习改正。如果大家有更多批处理文件实例,欢迎分享。

点赞美三代,分享富一生。

本文转载自: 掘金

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

大白话布隆过滤器,又能和面试官扯皮了~

发表于 2020-04-22

前言

  • 文章首发于微信公众号【码猿技术专栏】
  • 近期在做推荐系统中已读内容去重的相关内容,刚好用到了布隆过滤器,于是写了一篇文章记录分享一下。
  • 文章的篇幅不是很长,主要讲了布隆过滤器的核心思想,目录如下:

什么是布隆过滤器?

  • 布隆过滤器是由一个长度为m比特的位**数组**与k个**哈希函数**组成的数据结构。比特数组均初始化为0,所有哈希函数都可以分别把输入数据尽量均匀地散列。
  • 当**插入**一个元素时,将其数据通过k个哈希函数转换成k个哈希值,这k个哈希值将作为比特数组的下标,并将数组中的对应下标的值置为1。
  • 当**查询**一个元素时,同样会将其数据通过k个哈希函数转换成k个哈希值(数组下标),查询数组中对应下标的值,如果有一个下标的值为0表明该元素一定不在集合中,如果全部下标的值都为1,表明该元素有可能在集合中。**至于为什么有可能在集合中?** 因为有可能某个或者多个下标的值为 1 是受到其他元素的影响,这就是所谓的假阳性,下文会详细讲述。
  • **无法删除一个元素**,为什么呢?因为你删除的元素的哈希值可能和集合中的某个元素的哈希值有相同的,一旦删除了这个元素会导致其他的元素也被删除。
  • 下图示出一个m=18, k=3的布隆过滤器示例。集合中的 x、y、z 三个元素通过 3 个不同的哈希函数散列到位数组中。当查询元素 w 时,因为有一个比特为 0,因此 w 不在该集合中。

假阳性概率的计算

  • 假阳性是布隆过滤器的一个痛点,因此需要不择一切手段来使假阳性的概率降低,此时就需要计算一下假阳性的概率了。

  • 假设我们的哈希函数选择位数组中的比特时,都是等概率的。当然在设计哈希函数时,也应该尽量满足均匀分布。

  • 在位数组长度m的布隆过滤器中插入一个元素,它的其中一个哈希函数会将某个特定的比特置为1。因此,在插入元素后,该比特仍然为 0 的概率是:

  • 现有k个哈希函数,并插入n个元素,自然就可以得到该比特仍然为 0 的概率是:

  • 反过来讲,它已经被置为1的概率就是:

  • 也就是说,如果在插入n个元素后,我们用一个不在集合中的元素来检测,那么被误报为存在于集合中的概率(也就是所有哈希函数对应的比特都为1的概率)为:

  • 当n比较大时,根据极限公式,可以近似得出假阳性率:

  • 所以,在哈希函数个数k一定的情况下有如下结论:

    1. 位数组长度 m 越大,假阳性率越低。
    2. 已插入元素的个数 n 越大,假阳性率越高。

优点

  • 用比特数组表示,不用存储数据本身,对空间的节省相比于传统方式占据绝对的优势。
  • 时间效率很高,无论是插入还是查询,只需要简单的经过哈希函数转换,时间复杂度均为O(k)。

缺点

  • 存在假阳性的概率,准确率要求高的场景不太适用。
  • 只能插入和查询,不能删除了元素。

应用场景

  • 布隆过滤器的用途很多,但是主要的作用就是去重,这里列举几个使用场景。

爬虫重复 URL 检测

  • 试想一下,百度是一个爬虫,它会定时搜集各大网站的信息,文章,那么它是如何保证爬取到文章信息不重复,它会将 URL 存放到布隆过滤器中,每次爬取之前先从布隆过滤器中判断这个 URL 是否存在,这样就避免了重复爬取。当然这种存在假阳性的可能,但是只要你的比特数组足够大,假阳性的概率会很低,另一方面,你认为百度会在意这种的误差吗,你的一篇文章可能因为假阳性概率没有收录到,对百度有影响吗?

抖音推荐功能

  • 读者朋友们应该没人没刷过抖音吧,每次刷的时候抖音给你的视频有重复的吗?他是如何保证推荐的内容不重复的呢?
  • 最容易想到的就是抖音会记录用户的历史观看记录,然后从历史记录中排除。这是一种解决办法,但是性能呢?不用多说了,有点常识的都知道这不可能。
  • 解决这种重复的问题,布隆过滤器有着绝对的优势,能够很轻松的解决。

防止缓存穿透

  • 缓存穿透是指查询一条数据库和缓存都没有的一条数据,就会一直查询数据库,对数据库的访问压力会一直增大。
  • 布隆过滤器在解决缓存穿透的问题效果也是很好,这里不再细说,后续文章会写。

如何实现布隆过滤器?

  • 了解布隆过滤器的设计思想之后,想要实现一个布隆过滤器其实很简单,陈某这里就不再搬门弄斧了,介绍一下现成的实现方式吧。

Redis 实现

  • Redis4.0 之后推出了插件的功能,下面用 docker 安装:
    docker pull redislabs/rebloom
    docker run -p6379:6379 redislabs/rebloom
  • 安装完成后连接 redis 即可,运行命令:
    redis-cli
  • 至于具体的使用这里不再演示了,直接看官方文档和教程,使用起来还是很简单的。

Guava 实现

  • guava 对应布隆过滤器的实现做出了支持,使用 guava 可以很轻松的实现一个布隆过滤器。

1. 创建布隆过滤器

  • 创建布隆过滤器,如下:
    BloomFilter filter = BloomFilter.create(
    Funnels.integerFunnel(),
    5000,

0.01);
//插入
IntStream.range(0, 100_000).forEach(filter::put);
//判断是否存在
boolean b = filter.mightContain(1);

  • arg1:用于将任意类型 T 的输入数据转化为 Java 基本类型的数据,这里转换为 byte
  • arg2:byte 字节数组的基数
  • arg3:期望的假阳性概率

2.估计最优 m 值和 k 值

  • guava 在底层对 byte 数组的基数(m)和哈希函数的个数 k 做了自己的算法,源码如下:
    //m值的计算
    static long optimalNumOfBits(long n, double p) {
    if (p == 0) {
    p = Double.MIN_VALUE;
    }
    return (long) (-n * Math.log(p) / (Math.log(2) * Math.log(2)));
    }

//k值的计算
static int optimalNumOfHashFunctions(long n, long m) {
// (m / n) * log(2), but avoid truncation due to division!
return Math.max(1, (int) Math.round((double) m / n * Math.log(2)));
}

  • 想要理解 guava 的计算原理,还要从的上面推导的过程继续。

  • 由假阳性率的近似计算方法可知,如果要使假阳性率尽量小,在 m 和 n 给定的情况下,k值应为:

  • 将 k 代入上一节的式子并化简,我们可以整理出期望假阳性率 p 与 m、n 的关系:

  • 换算而得:

  • 根据以上分析得出以下的结论:

    1. 如果指定期望假阳性率 p,那么最优的 m 值与期望元素数 n 呈线性关系。
    2. 最优的 k 值实际上只与 p 有关,与 m 和 n 都无关,即:
    3. 综上两个结论,在创建布隆过滤器的时候,确定p值和m值很重要。

总结

  • 至此,布隆过滤器的知识介绍到这里,如果觉得陈某写得不错的,转发在看点一波,读者的一份支持将会是我莫大的鼓励。
  • **另外想和陈某私聊或者想要加交流群的朋友,公众号回复关键词加群加陈某微信,陈某会第一时间拉你进群。**

巨人的肩膀

  • https://blog.csdn.net/u012422440/article/details/94088166
  • https://blog.csdn.net/Revivedsun/article/details/94992323

本文转载自: 掘金

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

部门老大:redis 分布式锁再这么用,我就劝退你

发表于 2020-04-22

-如有不严谨或者错误之处,还望不吝赐教,轻点怼,人家还是个孩子,嘤嘤嘤~

引言

最近项目上线的频率颇高,连着几天加班熬夜,身体有点吃不消精神也有些萎靡,无奈业务方催的紧,工期就在眼前只能硬着头皮上了。脑子浑浑噩噩的时候,写的就不能叫代码,可以直接叫做Bug。我就熬夜写了一个bug被骂惨了。

由于是做商城业务,要频繁的对商品库存进行扣减,应用是集群部署,为避免并发造成库存超买超卖等问题,采用 redis 分布式锁加以控制。本以为给扣库存的代码加上锁lock.tryLock就万事大吉了

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
复制代码    /**
* @author xiaofu
* @description 扣减库存
* @date 2020/4/21 12:10
*/
public String stockLock() {
RLock lock = redissonClient.getLock("stockLock");
try {
/**
* 获取锁
*/
if (lock.tryLock(10, TimeUnit.SECONDS)) {

/**
* 扣减库存
*/
。。。。。。
} else {
LOGGER.info("未获取到锁业务结束..");
}
} catch (Exception e) {
LOGGER.info("处理异常", e);
}
return "ok";
}

结果业务代码执行完以后我忘了释放锁lock.unlock(),导致redis线程池被打满,redis服务大面积故障,造成库存数据扣减混乱,被领导一顿臭骂,这个月绩效~ 哎·~。

随着 使用redis 锁的时间越长,我发现 redis 锁的坑远比想象中要多。就算在面试题当中redis分布式锁的出镜率也比较高,比如:“用锁遇到过哪些问题?” ,“又是如何解决的?” 基本都是一套连招问出来的。

今天就分享一下我用redis 分布式锁的踩坑日记,以及一些解决方案,和大家一起共勉。

一、锁未被释放

这种情况是一种低级错误,就是我上边犯的错,由于当前线程 获取到redis 锁,处理完业务后未及时释放锁,导致其它线程会一直尝试获取锁阻塞,例如:用Jedis客户端会报如下的错误信息

1
复制代码redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool

redis线程池已经没有空闲线程来处理客户端命令。

解决的方法也很简单,只要我们细心一点,拿到锁的线程处理完业务及时释放锁,如果是重入锁未拿到锁后,线程可以释放当前连接并且sleep一段时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码  public void lock() {
while (true) {
boolean flag = this.getLock(key);
if (flag) {
TODO .........
} else {
// 释放当前redis连接
redis.close();
// 休眠1000毫秒
sleep(1000);
}
}
}

二、B的锁被A给释放了

我们知道Redis实现锁的原理在于 SETNX命令。当 key不存在时将 key的值设为 value ,返回值为 1;若给定的 key 已经存在,则 SETNX不做任何动作,返回值为 0 。

1
复制代码SETNX key value

我们来设想一下这个场景:A、B两个线程来尝试给key myLock加锁,A线程先拿到锁(假如锁3秒后过期),B线程就在等待尝试获取锁,到这一点毛病没有。

那如果此时业务逻辑比较耗时,执行时间已经超过redis锁过期时间,这时A线程的锁自动释放(删除key),B线程检测到myLock这个key不存在,执行 SETNX命令也拿到了锁。

但是,此时A线程执行完业务逻辑之后,还是会去释放锁(删除key),这就导致B线程的锁被A线程给释放了。

为避免上边的情况,一般我们在每个线程加锁时要带上自己独有的value值来标识,只释放指定value的key,否则就会出现释放锁混乱的场景。

三、数据库事务超时

emm~ 聊redis锁咋还扯到数据库事务上来了?别着急往下看,看下边这段代码:

1
2
3
4
5
6
7
8
9
10
复制代码   @Transaction
public void lock() {

while (true) {
boolean flag = this.getLock(key);
if (flag) {
insert();
}
}
}

给这个方法添加一个@Transaction注解开启事务,如代码中抛出异常进行回滚,要知道数据库事务可是有超时时间限制的,并不会无条件的一直等一个耗时的数据库操作。

比如:我们解析一个大文件,再将数据存入到数据库,如果执行时间太长,就会导致事务超时自动回滚。

一旦你的key长时间获取不到锁,获取锁等待的时间远超过数据库事务超时时间,程序就会报异常。

一般为解决这种问题,我们就需要将数据库事务改为手动提交、回滚事务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码    @Autowired
DataSourceTransactionManager dataSourceTransactionManager;

@Transaction
public void lock() {
//手动开启事务
TransactionStatus transactionStatus = dataSourceTransactionManager.getTransaction(transactionDefinition);
try {
while (true) {
boolean flag = this.getLock(key);
if (flag) {
insert();
//手动提交事务
dataSourceTransactionManager.commit(transactionStatus);
}
}
} catch (Exception e) {
//手动回滚事务
dataSourceTransactionManager.rollback(transactionStatus);
}
}

四、锁过期了,业务还没执行完

这种情况和我们上边提到的第二种比较类似,但解决思路上略有不同。

同样是redis分布式锁过期,而业务逻辑没执行完的场景,不过,这里换一种思路想问题,把redis锁的过期时间再弄长点不就解决了吗?

那还是有问题,我们可以在加锁的时候,手动调长redis锁的过期时间,可这个时间多长合适?业务逻辑的执行时间是不可控的,调的过长又会影响操作性能。

要是redis锁的过期时间能够自动续期就好了。

为了解决这个问题我们使用redis客户端redisson,redisson很好的解决了redis在分布式环境下的一些棘手问题,它的宗旨就是让使用者减少对Redis的关注,将更多精力用在处理业务逻辑上。

redisson对分布式锁做了很好封装,只需调用API即可。

1
复制代码  RLock lock = redissonClient.getLock("stockLock");

redisson在加锁成功后,会注册一个定时任务监听这个锁,每隔10秒就去查看这个锁,如果还持有锁,就对过期时间进行续期。默认过期时间30秒。这个机制也被叫做:“看门狗”,这名字。。。

举例子:假如加锁的时间是30秒,过10秒检查一次,一旦加锁的业务没有执行完,就会进行一次续期,把锁的过期时间再次重置成30秒。

通过分析下边redisson的源码实现可以发现,不管是加锁、解锁、续约都是客户端把一些复杂的业务逻辑,通过封装在Lua脚本中发送给redis,保证这段复杂业务逻辑执行的原子性。

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
复制代码 
@Slf4j
@Service
public class RedisDistributionLockPlus {

/**
* 加锁超时时间,单位毫秒, 即:加锁时间内执行完操作,如果未完成会有并发现象
*/
private static final long DEFAULT_LOCK_TIMEOUT = 30;

private static final long TIME_SECONDS_FIVE = 5 ;

/**
* 每个key的过期时间 {@link LockContent}
*/
private Map<String, LockContent> lockContentMap = new ConcurrentHashMap<>(512);

/**
* redis执行成功的返回
*/
private static final Long EXEC_SUCCESS = 1L;

/**
* 获取锁lua脚本, k1:获锁key, k2:续约耗时key, arg1:requestId,arg2:超时时间
*/
private static final String LOCK_SCRIPT = "if redis.call('exists', KEYS[2]) == 1 then ARGV[2] = math.floor(redis.call('get', KEYS[2]) + 10) end " +
"if redis.call('exists', KEYS[1]) == 0 then " +
"local t = redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2]) " +
"for k, v in pairs(t) do " +
"if v == 'OK' then return tonumber(ARGV[2]) end " +
"end " +
"return 0 end";

/**
* 释放锁lua脚本, k1:获锁key, k2:续约耗时key, arg1:requestId,arg2:业务耗时 arg3: 业务开始设置的timeout
*/
private static final String UNLOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"local ctime = tonumber(ARGV[2]) " +
"local biz_timeout = tonumber(ARGV[3]) " +
"if ctime > 0 then " +
"if redis.call('exists', KEYS[2]) == 1 then " +
"local avg_time = redis.call('get', KEYS[2]) " +
"avg_time = (tonumber(avg_time) * 8 + ctime * 2)/10 " +
"if avg_time >= biz_timeout - 5 then redis.call('set', KEYS[2], avg_time, 'EX', 24*60*60) " +
"else redis.call('del', KEYS[2]) end " +
"elseif ctime > biz_timeout -5 then redis.call('set', KEYS[2], ARGV[2], 'EX', 24*60*60) end " +
"end " +
"return redis.call('del', KEYS[1]) " +
"else return 0 end";
/**
* 续约lua脚本
*/
private static final String RENEW_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('expire', KEYS[1], ARGV[2]) else return 0 end";


private final StringRedisTemplate redisTemplate;

public RedisDistributionLockPlus(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
ScheduleTask task = new ScheduleTask(this, lockContentMap);
// 启动定时任务
ScheduleExecutor.schedule(task, 1, 1, TimeUnit.SECONDS);
}

/**
* 加锁
* 取到锁加锁,取不到锁一直等待知道获得锁
*
* @param lockKey
* @param requestId 全局唯一
* @param expire 锁过期时间, 单位秒
* @return
*/
public boolean lock(String lockKey, String requestId, long expire) {
log.info("开始执行加锁, lockKey ={}, requestId={}", lockKey, requestId);
for (; ; ) {
// 判断是否已经有线程持有锁,减少redis的压力
LockContent lockContentOld = lockContentMap.get(lockKey);
boolean unLocked = null == lockContentOld;
// 如果没有被锁,就获取锁
if (unLocked) {
long startTime = System.currentTimeMillis();
// 计算超时时间
long bizExpire = expire == 0L ? DEFAULT_LOCK_TIMEOUT : expire;
String lockKeyRenew = lockKey + "_renew";

RedisScript<Long> script = RedisScript.of(LOCK_SCRIPT, Long.class);
List<String> keys = new ArrayList<>();
keys.add(lockKey);
keys.add(lockKeyRenew);
Long lockExpire = redisTemplate.execute(script, keys, requestId, Long.toString(bizExpire));
if (null != lockExpire && lockExpire > 0) {
// 将锁放入map
LockContent lockContent = new LockContent();
lockContent.setStartTime(startTime);
lockContent.setLockExpire(lockExpire);
lockContent.setExpireTime(startTime + lockExpire * 1000);
lockContent.setRequestId(requestId);
lockContent.setThread(Thread.currentThread());
lockContent.setBizExpire(bizExpire);
lockContent.setLockCount(1);
lockContentMap.put(lockKey, lockContent);
log.info("加锁成功, lockKey ={}, requestId={}", lockKey, requestId);
return true;
}
}
// 重复获取锁,在线程池中由于线程复用,线程相等并不能确定是该线程的锁
if (Thread.currentThread() == lockContentOld.getThread()
&& requestId.equals(lockContentOld.getRequestId())){
// 计数 +1
lockContentOld.setLockCount(lockContentOld.getLockCount()+1);
return true;
}

// 如果被锁或获取锁失败,则等待100毫秒
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
// 这里用lombok 有问题
log.error("获取redis 锁失败, lockKey ={}, requestId={}", lockKey, requestId, e);
return false;
}
}
}


/**
* 解锁
*
* @param lockKey
* @param lockValue
*/
public boolean unlock(String lockKey, String lockValue) {
String lockKeyRenew = lockKey + "_renew";
LockContent lockContent = lockContentMap.get(lockKey);

long consumeTime;
if (null == lockContent) {
consumeTime = 0L;
} else if (lockValue.equals(lockContent.getRequestId())) {
int lockCount = lockContent.getLockCount();
// 每次释放锁, 计数 -1,减到0时删除redis上的key
if (--lockCount > 0) {
lockContent.setLockCount(lockCount);
return false;
}
consumeTime = (System.currentTimeMillis() - lockContent.getStartTime()) / 1000;
} else {
log.info("释放锁失败,不是自己的锁。");
return false;
}

// 删除已完成key,先删除本地缓存,减少redis压力, 分布式锁,只有一个,所以这里不加锁
lockContentMap.remove(lockKey);

RedisScript<Long> script = RedisScript.of(UNLOCK_SCRIPT, Long.class);
List<String> keys = new ArrayList<>();
keys.add(lockKey);
keys.add(lockKeyRenew);

Long result = redisTemplate.execute(script, keys, lockValue, Long.toString(consumeTime),
Long.toString(lockContent.getBizExpire()));
return EXEC_SUCCESS.equals(result);

}

/**
* 续约
*
* @param lockKey
* @param lockContent
* @return true:续约成功,false:续约失败(1、续约期间执行完成,锁被释放 2、不是自己的锁,3、续约期间锁过期了(未解决))
*/
public boolean renew(String lockKey, LockContent lockContent) {

// 检测执行业务线程的状态
Thread.State state = lockContent.getThread().getState();
if (Thread.State.TERMINATED == state) {
log.info("执行业务的线程已终止,不再续约 lockKey ={}, lockContent={}", lockKey, lockContent);
return false;
}

String requestId = lockContent.getRequestId();
long timeOut = (lockContent.getExpireTime() - lockContent.getStartTime()) / 1000;

RedisScript<Long> script = RedisScript.of(RENEW_SCRIPT, Long.class);
List<String> keys = new ArrayList<>();
keys.add(lockKey);

Long result = redisTemplate.execute(script, keys, requestId, Long.toString(timeOut));
log.info("续约结果,True成功,False失败 lockKey ={}, result={}", lockKey, EXEC_SUCCESS.equals(result));
return EXEC_SUCCESS.equals(result);
}


static class ScheduleExecutor {

public static void schedule(ScheduleTask task, long initialDelay, long period, TimeUnit unit) {
long delay = unit.toMillis(initialDelay);
long period_ = unit.toMillis(period);
// 定时执行
new Timer("Lock-Renew-Task").schedule(task, delay, period_);
}
}

static class ScheduleTask extends TimerTask {

private final RedisDistributionLockPlus redisDistributionLock;
private final Map<String, LockContent> lockContentMap;

public ScheduleTask(RedisDistributionLockPlus redisDistributionLock, Map<String, LockContent> lockContentMap) {
this.redisDistributionLock = redisDistributionLock;
this.lockContentMap = lockContentMap;
}

@Override
public void run() {
if (lockContentMap.isEmpty()) {
return;
}
Set<Map.Entry<String, LockContent>> entries = lockContentMap.entrySet();
for (Map.Entry<String, LockContent> entry : entries) {
String lockKey = entry.getKey();
LockContent lockContent = entry.getValue();
long expireTime = lockContent.getExpireTime();
// 减少线程池中任务数量
if ((expireTime - System.currentTimeMillis())/ 1000 < TIME_SECONDS_FIVE) {
//线程池异步续约
ThreadPool.submit(() -> {
boolean renew = redisDistributionLock.renew(lockKey, lockContent);
if (renew) {
long expireTimeNew = lockContent.getStartTime() + (expireTime - lockContent.getStartTime()) * 2 - TIME_SECONDS_FIVE * 1000;
lockContent.setExpireTime(expireTimeNew);
} else {
// 续约失败,说明已经执行完 OR redis 出现问题
lockContentMap.remove(lockKey);
}
});
}
}
}
}
}

五、redis主从复制的坑

redis高可用最常见的方案就是主从复制(master-slave),这种模式也给redis分布式锁挖了一坑。

redis cluster集群环境下,假如现在A客户端想要加锁,它会根据路由规则选择一台master节点写入key mylock,在加锁成功后,master节点会把key异步复制给对应的slave节点。

如果此时redis master节点宕机,为保证集群可用性,会进行主备切换,slave变为了redis master。B客户端在新的master节点上加锁成功,而A客户端也以为自己还是成功加了锁的。

此时就会导致同一时间内多个客户端对一个分布式锁完成了加锁,导致各种脏数据的产生。

至于解决办法嘛,目前看还没有什么根治的方法,只能尽量保证机器的稳定性,减少发生此事件的概率。

总结

上面就是我在使用Redis 分布式锁时遇到的一些坑,有点小感慨,经常用一个方法填上这个坑,没多久就发现另一个坑又出来了,其实根本没有什么十全十美的解决方案,哪有什么银弹,只不过是在权衡利弊后,选一个在接受范围内的折中方案而已。


小福利:

整理了几百本各类技术电子书和视频资料 ,嘘~,免费 送,公号内回复【666】自行领取。和小伙伴们建了一个技术交流群,一起探讨技术、分享技术资料,旨在共同学习进步,感兴趣就入我们吧!

本文转载自: 掘金

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

9个小技巧让你的 if else看起来更优雅

发表于 2020-04-22

if else 是我们写代码时,使用频率最高的关键词之一,然而有时过多的 if else 会让我们感到脑壳疼,例如下面这个伪代码:

伪代码-2.png

是不是很奔溃?虽然他是伪代码,并且看起来也很夸张,但在现实中,当我们无数次 review 别人代码时,都会发现类似的场景,那么我们本文就来详细聊聊,有没有什么方法可以让我们避免来写这么多的 if else 呢?
我们本文提供了 9 种方法来解决掉那些“烦人”的 if else,一起来看吧。

1.使用 return

我们使用 return 去掉多余的 else,实现代码如下。

优化前代码:

1
2
3
4
5
复制代码if ("java".equals(str)) {
// 业务代码......
} else {
return;
}

优化后代码:

1
2
3
4
复制代码if (!"java".equals(str)) {
return;
}
// 业务代码......

这样看起来就会舒服很多,虽然相差只有一行代码,但真正的高手和普通人之间的差距就是从这一行行代码中体现出来的。

「勿以善小而不为,勿以恶小而为之」「千里之堤,溃于蚁穴」,说的都是同样的道理。

2.使用 Map

使用 Map 数组,把相关的判断信息,定义为元素信息可以直接避免 if else 判断,实现代码如下。

优化前代码:

1
2
3
4
5
6
7
复制代码if (t == 1) {
type = "name";
} else if (t == 2) {
type = "id";
} else if (t == 3) {
type = "mobile";
}

我们先定义一个 Map 数组,把相关判断信息存储起来:

1
2
3
4
复制代码Map<Integer, String> typeMap = new HashMap<>();
typeMap.put(1, "name");
typeMap.put(2, "id");
typeMap.put(3, "mobile");

之前的判断语句可以使用以下一行代码代替了:

1
复制代码type = typeMap.get(t);

3.使用三元运算符

三元运算符也叫三元表达式或者三目运算符/表达式,不过代表的都是一个意思,优化代码如下。

优化前代码:

1
2
3
4
5
6
复制代码Integer score = 81;
if (score > 80) {
score = 100;
} else {
score = 60;
}

优化后代码:

1
复制代码score = score > 80 ? 100 : 60;

4.合并条件表达式

在项目中有些逻辑判断是可以通过梳理和归纳,变更为更简单易懂的逻辑判断代码,如下所示。

优化前代码:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码String city = "西安";
String area = "029";
String province = "陕西";
if ("西安".equals(city)) {
return "xi'an";
}
if ("029".equals(area)) {
return "xi'an";
}
if ("陕西".equals(province)){
return "xi'an";
}

优化后代码:

1
2
3
复制代码if ("西安".equals(city) || "029".equals(area) || "陕西".equals(province)){
return "xi'an";
}

5.使用枚举

JDK 1.5 中引入了新的类型——枚举(enum),我们使用它可以完成很多功能,例如下面这个。

优化前代码:

1
2
3
4
5
6
7
8
9
复制代码Integer typeId = 0;
String type = "Name";
if ("Name".equals(type)) {
typeId = 1;
} else if ("Age".equals(type)) {
typeId = 2;
} else if ("Address".equals(type)) {
typeId = 3;
}

优化时,我们先来定义一个枚举:

1
2
3
4
5
6
7
复制代码public enum TypeEnum {
Name(1), Age(2), Address(3);
public Integer typeId;
TypeEnum(Integer typeId) {
this.typeId = typeId;
}
}

之前的 if else 判断就可以被如下一行代码所替代了:

1
复制代码typeId = TypeEnum.valueOf("Name").typeId;

6.使用 Optional

从 JDK 1.8 开始引入 Optional 类,在 JDK 9 时对 Optional 类进行了改进,增加了 ifPresentOrElse() 方法,我们可以借助它,来消除 if else 的判断,使用如下。

优化前代码:

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

优化后代码:

1
2
3
复制代码Optional<String> opt = Optional.of("java");
opt.ifPresentOrElse(v ->
System.out.println(v), () -> System.out.println("Null"));

小贴士:注意运行版本,必须是 JDK 9+ 才行。

7.梳理优化判断逻辑

和第 4 点比较类似,我们可以通过分析 if else 的逻辑判断语义,写出更加易懂的代码,例如以下这个嵌套判断的优化。

优化前代码:

1
2
3
4
5
6
7
8
9
10
11
复制代码// 年龄大于 18
if (age > 18) {
// 工资大于 5000
if (salary > 5000) {
// 是否漂亮
if (pretty == true) {
return true;
}
}
}
return false;

优化后代码:

1
2
3
4
5
6
7
复制代码if (age < 18) {
return false;
}
if (salary < 5000) {
return false;
}
return pretty;

我们需要尽量把表达式中的包含关系改为平行关系,这样代码可读性更高,逻辑更清晰。

8.使用多态

继承、封装和多态是 OOP(面向对象编程)的重要思想,本文我们使用多态的思想,提供一种去除 if else 方法。

优化前代码:

1
2
3
4
5
6
7
8
9
复制代码Integer typeId = 0;
String type = "Name";
if ("Name".equals(type)) {
typeId = 1;
} else if ("Age".equals(type)) {
typeId = 2;
} else if ("Address".equals(type)) {
typeId = 3;
}

使用多态,我们先定义一个接口,在接口中声明一个公共返回 typeId 的方法,在添加三个子类分别实现这三个子类,实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
复制代码public interface IType {
public Integer getType();
}

public class Name implements IType {
@Override
public Integer getType() {
return 1;
}
}

public class Age implements IType {
@Override
public Integer getType() {
return 2;
}
}

public class Address implements IType {
@Override
public Integer getType() {
return 3;
}
}

注意:为了简便我们这里把类和接口放到了一个代码块中,在实际开发中应该分别创建一个接口和三个类分别存储。

此时,我们之前的 if else 判断就可以改为如下代码:

1
2
复制代码IType itype = (IType) Class.forName("com.example." + type).newInstance();
Integer typeId = itype.getType();

有人可能会说,这样反而让代码更加复杂了,此可谓“杀鸡焉用宰牛刀”的典型范例了。这里作者只是提供一种实现思路和提供了一些简易版的代码,以供开发者在实际开发中,多一种思路和选择,具体用不用需要根据实际情况来定了。灵活变通,举一反三,才是开发的上乘心法。

9.选择性的使用 switch

很多人都搞不懂 switch 和 if else 的使用场景,但在两者都能使用的情况下,可以尽量使用 switch,因为 switch 在常量分支选择时,switch 性能会比 if else 高。

if else 判断代码:

1
2
3
4
5
6
7
8
9
10
11
复制代码if ("add".equals(cmd)) {
result = n1 + n2;
} else if ("subtract".equals(cmd)) {
result = n1 - n2;
} else if ("multiply".equals(cmd)) {
result = n1 * n2;
} else if ("divide".equals(cmd)) {
result = n1 / n2;
} else if ("modulo".equals(cmd)) {
result = n1 % n2;
}

switch 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码switch (cmd) {
case "add":
result = n1 + n2;
break;
case "subtract":
result = n1 - n2;
break;
case "multiply":
result = n1 * n2;
break;
case "divide":
result = n1 / n2;
break;
case "modulo":
result = n1 % n2;
break;
}

在 Java 14 可使用 switch 代码块,实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码// java 14
switch (cmd) {
case "add" -> {
result = n1 + n2;
}
case "subtract" -> {
result = n1 - n2;
}
case "multiply" -> {
result = n1 * n2;
}
case "divide" -> {
result = n1 / n2;
}
case "modulo" -> {
result = n1 % n2;
}
}

总结

业精于勤荒于嬉,行成于思毁于随。编程是一门手艺,更是一种乐趣,哈佛最受欢迎的幸福课《幸福的方法》一书中写到「让我们能感到快乐和幸福的方法,无非是全身心的投入到自己稍微努力一下才能完成的工作中去!」是啊,太简单的事情通常无法调动起我们的兴趣,而太难的工作又会让我们丧失信心,只有那些看似很难但稍微努力一点就能完成的事情,才会给我们带来巨大的快乐。

最后的话

原创不易,如果觉得本文对你有用,请随手点击一个「赞」,这是对作者最大的支持与尊重,谢谢你。

参考 & 鸣谢

www.tuicool.com/wx/2euqQvZ

www.blogjava.net/xzclog/arch…

更多精彩内容,请关注微信公众号「Java中文社群」

本文转载自: 掘金

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

SpringCloud 链路跟踪(3)-实现异步数据采集

发表于 2020-04-22

环境

SpringCloud:Finchley.RELEASE

SpringBoot:2.0.0.RELEASE

JDK:1.8

  1. Zipkin异步数据采集

sleuth与zipkin入门章节中,zipkin client的数据通过http的方式发送到服务端。http是阻塞式请求,如果zipkin server处理的效率低,直接导致业务服务延迟高或者返回超时,此时可以通过在client和server中间增加消息队列实现异步数据采集,如RabbitMQ或者Kafka。这里使用RabbitMQ示例。

  1. 通过RabbitMQ实现异步数据采集

2.1 RabbitMQ搭建,通过docker的方式创建

1
2
3
4
5
6
7
8
9
10
复制代码dokcer pull registry.cn-beijing.aliyuncs.com/buyimoutianxia/rabbitmq:V3.7.25

docker run -it -d --name rabbitmq \
-p 5672:5672 -p 15672:15672 \
-e RABBITMQ_DEFAULT_USER=admin \
-e RABBITMQ_DEFAULT_PASS=admin \
registry.cn-beijing.aliyuncs.com/buyimoutianxia/rabbitmq:V3.7.25

#解决http://localhost:15672的网址打不开的问题
docker exec -it rabbitmq sh -co "rabbitmq-plugins enable rabbitmq_management"

2.2 zipkin server配置连接RabbitMQ,通过docker的方式创建

1
2
3
4
5
6
7
8
复制代码#拉取阿里云个人镜像仓库
docker pull registry.cn-beijing.aliyuncs.com/buyimoutianxia/zipkin:2.11

docker run -d --name zipkin -p 9411:9411 \
-e RABBIT_ADDRESSES=172.17.0.2:5672 \
-e RABBIT_USER=admin \
-e RABBIT_PASSWORD=admin \
registry.cn-beijing.aliyuncs.com/buyimoutianxia/zipkin:2.11

2.3 zipkin客户端改造

  1. 导入依赖文件
1
2
3
4
5
6
7
8
9
复制代码        <!--rabbitmq-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-sleuth-zipkin</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.amqp</groupId>
<artifactId>spring-rabbit</artifactId>
</dependency>
  1. 修改配置,接入rabbitmq
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
复制代码spring:
application:
name: microservice-provider #微服务名称
#修改zipkin使用rabbitmq采集数据
zipkin:
# base-url: http://localhost:9411 # zipkin server地址和端口
sender:
type: rabbit # 向rabbit中发送消息
# type: web #zipkin消息传送发送方式,采用Http协议:
sleuth:
sampler:
probability: 1 # sleuth配置采样比,默认是0.1
rabbitmq:
host: localhost
port: 5672
username: admin
password: admin
listener: #重试策略
direct:
retry:
enabled: true
simple:
retry:
enabled: true

代码示例-github

本文转载自: 掘金

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

1…818819820…956

开发者博客

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