提交 28421349 authored 作者: 邓砥奕's avatar 邓砥奕

第一次添加笔记

上级 c44be931
## Lambda和匿名内部类
#### 一、引用外部方法局部变量
Java8之前Lambda和匿名内部类引用外部局部变量必须声明为final类型,Java8之后引用的局部变量必须为final或effectively final.如果该局部变量在定义赋值后没有被修改,则认为该变量为effectively final类型,如果被赋值或修改则无法通过编译。因此在引用外部局部变量时,不能进行修改。
#### 二、为什么必须为final或effectively final
1.由于Lambda和匿名内部类无法直接访问外部局部变量,因此只能通过它们的构造方法将需要引用的局部变量传入进去。通过构造方法传递的只是局部变量的一个拷贝,因此当外部或者内部类修改这个变量时,对方都不知道该变量的修改。为了保证变量的安全,所以在Java中直接不允许修改引用的局部变量,编译时该变量必须为final或者effectively final类型。
2.局部变量和内部类对象的生命周期不同。当调用该方法时,该方法中的局部变量在栈中被创建,可能该方法调用结束并且释放了栈中的变量,但是内部类却还没有执行完,此时就找不到该局部变量了。为了保证变量的安全访问,内部类会自动通过构造函数的参数创建一个final类型的拷贝值。由于是拷贝值,因此一但一方修改了局部变量的值会导致另外一方对该变量的更改不可见,从而出现问题。
3.并发的安全性问题。当多个线程并发执行Lambda或匿名内部类中的代码时,不能保证外部成员变量的原子性,从而可能导致变量值的安全性问题。当引用自由变量时,考虑并发的安全性,也必须为final或effectively final。
#### 三、修改变量的一些方法
1.定义为内部类局部变量
2.定义为外部类的成员变量:成员变量存储在堆上,可以正常访问。
3.定义为Atomic原子类型:保证了变量的可见性和原子性。
4.定义为数组、list:数组和list的地址不会发生改变,可以修改数组和list中的值,但是并发时不能保证安全性。
## 雪花算法SnowFlake学习
#### 一、雪花算法原理
1.传统方法使用uuid生成的订单号是无序的而且没有任何特殊信息,为了实现订单号有序并且能够反解出相关信息,采用雪花算法Snowflake能够解决这些问题。
2.雪花算法采取long字段(64bit)来生成订单号,第一个位是符号位,由于订单号为正,所以首位为0。接下来41位是时间位,单位是毫秒,记录该订单号生成的时间,最多可以记录2<sup>41</sup>/1000/60/60/24/365年,大约是69年。接下来10位是机器位,用来记录生成订单的机器,最多可以允许有2<sup>10</sup> =1024个机器,可以根据需要设计机房位。最后12位为标识位,最多可以记录2<sup>12</sup>=4096个订单标识。
<u>0</u> <u>00000000000000000000000000000000000000000</u> <u>0000</u> <u>000000</u> <u>000000000000</u>
符号位+时间戳+机房位+机器位+标识位
3.由于不同的位数存储着不同的信息,所以能够根据订单号反解出这些信息。同时最高位为时间戳位,时间戳的递增保证了生成订单号的趋势递增。
#### 二、雪花算法实现
1.分别定义long类型的各数据及其位数以及开始时间戳,可以设置从配置文件读取。定义一个last_time来记录上一次生成订单的时间戳,在生成下一个订单号时来和当前的时间戳作比较。
2.编写设置机房ID和机器ID的函数。如果设置的值大于最大ID或者小于0则抛出异常,否则修改当前的机房和机器ID。
3.编写生成下一次订单号的函数。考虑到分布式系统不同服务器会同时访问该函数生成ID,所以加上synchronized锁或Lock锁来保证访问的同步和订单号的递增。首先对time_now和last_time作比较,若当前时间戳小于上一次的时间戳则认为时钟发生回拨抛出异常。若二次时间戳相等,判断当前squence的值是否达到最大,若达到最大则等待到下一毫秒,否则squence++。若当前时间戳大于上一次的,则squence置0。通过对时间戳左移22位,机房ID左移18位,机器ID左移12位,最后将它们做按位或运算得到最后的订单ID。
4.编写测试函数。循环生成1千万个订单号,用List来存储。循环开始前记录开始时间戳,循环结束后计算结束时间戳。生成ID的时间就是它们的差值。之后将List转为Set再判断它们的size来计算重复订单数。再通过一个循环遍历List,比较now_id和last_id来判断生成的ID是否递增。生成ID的QPS=idList.size()/runTime*1_000。
5.为了解决时钟回拨的问题,time前面设置了时钟回拨位,如果时钟回拨小于最大等待时间则进行等待,若超过了则时钟回拨位加1,依然保持趋势递增。
6.尝试使用环形数组Ringbuffer来实现雪花算法,使用原子类AtomicLong自增来模拟时间增加,解决了时钟回拨的问题。一个数组用来储存预生成的ID,另一个相同大小的数组用flag用来记录每个位置是否存在ID可以取。每当Ringbuffer中的ID小于容量的一半时对生成ID对数组进行填充。提前生成ID填充进数组中,需要时再分别从数组中取值。
#### 三、多线程的使用
1.为了测试和模拟不同线程同步生成订单号的性能,通过使用线程池开启多个线程来实现。
N = *CPU*的核数
U = 目标*CPU*的使用率, 0 <= U <= 1
*W/C* = 等待时间与计算时间的比率
为保持处理器达到期望的使用率,最优的池的大小等于:
Threads = N x U x (1 + *W/C*)
2.分别使用单线程和多线程的方式来测试二种实现方法生成1千万ID的QPS。通过增加seq位数来增大QPS。seq增大到14位后生成1千万个ID,多线程创建2个线程分十次生成1千万个ID,测得的QPS如下表格和折线图所示:
| 测试次数 | 单线程SnowFlake的QPS(num/s) | 多线程SnowFlake的QPS(num/s) | 单线程RingBuffer的QPS(num/s) | 多线程RingBuffer的QPS(num/s) |
| :------: | :-------------------------: | :-------------------------: | :--------------------------: | :--------------------------: |
| 1 | 16077000 | 14114000 | 14084000 | 6065000 |
| 2 | 15822000 | 11799000 | 14619000 | 5733000 |
| 3 | 16103000 | 11835000 | 14492000 | 5189000 |
| 4 | 16207000 | 12423000 | 13495000 | 4527000 |
| 5 | 16051000 | 10802000 | 14326000 | 5223000 |
| 6 | 15723000 | 11437000 | 13477000 | 5797000 |
| 7 | 15873000 | 10191000 | 12610000 | 5405000 |
| 8 | 15384000 | 14112000 | 13157000 | 5470000 |
| 9 | 16129000 | 10830000 | 11350000 | 5243000 |
| 10 | 16155000 | 9526000 | 11904000 | 5827000 |
| 平均值 | 15952400 | 11706900 | 13351400 | 5447900 |
| 理论值 | 16384000 | 16384000 | 16384000 | 16384000 |
![折线图](折线图.png)
3. 根据以上数据可以看出,多线程下雪花算法的QPS会得到比较大的降低。使用的线程越多,线程之间切换的越频繁,会占用大量的时间,降低性能。生成ID这种CPU密集型操作速度很快,使用单个线程就足够完成了,各个线程之间的切换占用大量的时间,使用多个线程反而会导致性能变差,并且生成ID必须加上锁来控制ID的递增和不重复。在不分批和使用更多线程的情况下,性能会更差。因此使用单线程就足够满足该算法的要求了。
4. 使用多线程要注意的问题:
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;可见性:各个线程从工作内存读写数据,工作内存需要及时从主内存刷新数据。如果刷新不及时就会导致变量的值读写错误。可以通过使用volatile修饰变量或使用原子类Atomic,也可以通过加synchornized或lock锁来保证可见性。定义为Final的变量也具有可见性。
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;原子性:多线程下为了保证操作的原子性,需要使用原子类Atomic或者加锁来保证原子性。volatile定义的变量并不保证原子性。
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;while循环:重复读取一个不具备可见性的变量的值时,cpu会一直自旋读取这个值,从而可能没有时间从主存中刷新该变量的值。当读取到足够多的次数以后,经过测试大概是20万次,cpu可能就会认为这个值是不变的了,就不会从主存中刷新该变量的值。解决方法有:thread.sleep线程休眠、print打印、volatile修饰变量、volatile变量进行读写、加锁、debug等。线程休眠能让cpu空闲下来去刷新缓存中的数据,print打印的方法内部带有sync锁,和加锁的方式相同,获取锁和释放锁的时候都会刷新缓存。volatile读写我认为是因为每次读写都需要访问主存消耗大量时间,导致cpu空闲下来去刷新缓存。同时voliate具有的内存屏障,保证volatile写之前的操作对之后的操作可见。debug时也会使cpu空闲下来去刷新缓存。
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;voliate内存屏障:当第二个操作为volatile写时,不管第一个操作是什么都不会进行重排序。JMM在每个volatile写之前插入一个StoreStore屏障,在写之后插入了一个StoreLoad屏障来保证不会被重排序。StoreStore屏障可以保证在volatile写之前的所有普通写操作刷新到主内存并对volatile写可见,同时volatile写对之后的操作可见。由可见性的传递性可得volatile写之前的操作对之后的操作可见。因此对volatile写可以保证之后读到的都是最新的值。
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;判断所有线程ID是否生成结束可以对线程池进行shutdown,shutdown会在所有线程结束后关闭线程池。如果是shutdownNow会立即关闭线程池。再使用线程池isTerminated()方法来判断线程池是否结束。
#### 四、总结
1.写代码时要注意代码的规范,比如注释、大小写、判断语句等地方,使用阿里Java代码规约插件来约束代码规范,让自己的代码具有较好的阅读性。
2.学习使用一些idea的快捷键和插件来方便我们的使用,从而加快编写代码的速度。
3.多线程while死循环
```
volatile static int other;
public static void main(String[] args) {
//Thread1
new Thread(() -> {
int loop = 0;
while (run) {
loop=1;
//如果注释掉这一行,会是死循环;如果加上,在循环就会正常退出
other=1; //①
}
System.out.println("loop=" + loop);
}).start();
//Thread2
new Thread(()->{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
run = false;
System.out.println("set run = false");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end");
}).start();
}
```
如果注释掉other=1,while会发生死循环。while循环太快会导致cpu一直在读取run的值。JVM为了保证线程安全,具有缓存一致性协议(MESI),为了保证每个缓存的数据都一致,cpu一有空就会不断从主存中获取数据的变化。这里如果把第二个线程刚开始的休眠去掉或者休眠很短的时间,线程一都可以跳出循环。由此我觉得cpu可能自旋读取一定多的次数以后,就认为这个值不变了,不会再从主存中刷新数据了。通过定义一个num,每次循环里面加1,不断改变线程二休眠的时间,我的电脑测试出大概循环读取20万次左右之后就不能跳出循环了。以上解决的方法是添加一个volatile变量的写,触发了写的内存屏障保证了不会发生重排序和可见性。解决死循环的办法有很多,像thread.sleep线程休眠、print打印、volatile修饰变量、volatile变量进行读写、加锁、debug等都可以跳出循环。具体每个方法的解决原因在之前都已经解释过了。在多线程下为了保证变量的可见性,最后用volatile进行修饰。同时在使用while循环时,每次循环后都可以sleep一下,来防止cpu一直被占用。
4.具体的原因可能有很多方面,以上所分析的各种原因可能不一定是准确的,具体在底层JVM是如何实现的还需要进一步的研究和分析。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论