0%

摆脱过去论#

封闭自己不愿接触外界,因为自己遭遇了不公的对待

哀叹家暴,导致自己无法自立

因为脸红恐惧症,无法接触对方……

本质上都是因为害怕被别人讨厌,所以故意用过去的经历作为借口,来拒绝被讨厌的情况发生。

对于那些人来说,维持现状是最符合内心舒适的感觉,因此会制造很多的借口。

但现状所带来的后果又一直在折磨,反复循环。

烦恼的根源#

人的不幸都是自己选择的,不是社会或者世界,而是自己亲手选择的。

烦恼的根源,都来自于人际关系。

总是希望获得别人的认可,因此为了避免不认可的情况,刻意做出了逃避

如何看待自卑感#

认为我是世界的中心。

认为所有人都在关注我的一举一动

认为我的事情,就是别人的事情, 以及别人的看法,就是我的价值。

为了避免被讨厌,搬出自己不行的自卑,来逃避

怎么获得被讨厌的勇气?#

课题分离

我无法掌控他人,他人也无法掌控我。

我的选择是我的课题,他的想法干涉不了我。

他的选择是他的课题,我不需要去干涉他。

我只要尽力做我想做的, 他如何回应,他是接受还是讨厌,是他的课题。我不应该为之付出痛苦或者焦虑的情绪。

我不能可以追求对方的回报,我也不能无底线的回报别人的付出,分离!

怎么获得幸福?#

“他人贡献”#

只要知道自己的存在是有价值的,是对他人、社会、整体有贡献的

只有在感觉自己有价值的时候,才能有勇气。

这个价值不是别人赋予的,而是自己赋予的,自己认可的价值。

他人贡献不代表为了他人的期待而活,共同体,范围要看广,不只是某个人不满意就不代表你没有了他人贡献!

直面人生的课题(工作、交友、爱)#

自己主动动起来,而不是等着别人来赠予。

无论遇到什么不好的反馈,都是别人的课题,我只要做了自己希望做的事情就好!

如何识别他人贡献中的“他人”?#

  1. 优先信任而不是怀疑

    永远保持对人的信任,而不是怀疑……

  2. 共同体理论。

当你的行为被某人讨厌, 这个人并不代表的全部,不代表整个“共同体”

你的行为或者存在可以放大, 学校、公司、社会、家庭,都是共同体。

因此不要因为部分人的讨厌而自责,你对其他的人肯定仍然存在着贡献,不一定是行为,仅仅是存在就可以是贡献。

如何改变#

不再关注过去,不再幻想未来。

关注此时此刻

此时此刻就像漫无目的的跳舞。

但只要跳了,做了,就有一定的微小变化,无论最终是否达到,只要享受此时此刻就好。


[toc]

ArrayList原理#

Q: 讲一下啊arrayList的扩容原理?
A:
比较简单,算出新容量后,直接Arrays.copy拷贝到新数组,搞定
c0f46fd3dbc58ac475051f1102dd4c43f80fc8e0

扩容容量= 原容量 + (原容量右移1位,即0.5倍)= 1.5倍
395ae953519c9a0a9cba440644a6d8dd4999db66


Q: arrayList的初始最大容量是多少?可以更改吗?
A:
初始容量为10。不可修改
11f8400ec134b045dae9118f28877a284924b1c3

CopyOnWriteArrayList 原理#

就是一个写时复制列表

当发生“删除/修改/新增”时,会先新建一个数组,更新后的数据拷贝到数组中

最后再更新到CopyOnWriteArrayList的数组成员引用上。

为什么需要写时复制?#

针对多线程环境下,读多写少的情况,如果每个读都加线程同步,太浪费了

因此可以读的时候不加锁

只有写的时候加锁,写完更新

读的时候可能会有点延迟的数据,但不影响。

如何避免多线程修改的冲突?#

加了一个sync锁,修改动作是线程安全的。

如何保证更新后,数组引用的变动能及时对外呈现?#

数组成员设置成volatile

HashMap核心原理#

hashMap完整的put过程#

  1. 首先,要获取key的哈希值。
    如果为空,就统一是0
    否则,调用对象的.hashCode()方法,接着再与自己的右移16位进行异或,以便充分利用高位信息。
  2. 接着判断内部node数组是否为空,如果是,先进行初始化扩容。默认为16。
  3. 根据(n-1)&hash值,获取哈希表索引位置。 (&的性能比取余要高,具体讨论见CPU取余原理
  4. 哈希表的node数组中,存放的是每组链表的头节点。
    先检查头节点是否和自己要存放的key完全匹配 (hash值相同,key值相同,先hash再key,是因为hash的判断简单,key的equals判断可能会复杂)
    如果匹配,得到需要替换的节点。
  5. 头节点和自己要放的key不匹配,则判断一下这个头节点是否是红黑树节点,如果是,说明已经升级成红黑树了,调用putTree插入到红黑树中。
  6. 如果不是红黑树, 那就是遍历链表,完全匹配就得到需要替换的节点。如果到尾部了,也没匹配的,则插入新节点。
  7. 如果前面找到了要替换的节点,则判断一下是否可以替换(是否没要求putIfAbsent,或者value为null),是就替换,不是就结束
  8. 如果前面是插入了新节点,非替换, 则要modCount++(方便迭代器确认map是否更新), 同时++size, 然后和扩容阈值做判断, 如果太大,就resize进行扩容
    hashMap-put详解

hashMap的扩容过程,java7和8扩容的区别#

  1. java7:
  • 当resize时,新建一个数组newTable
  • 遍历原table中的每个链表和节点,重新hash,找到新的位置放入
  • 放入的方式是头插法,即始终插在链表的头节点。
  1. java8:
  • 不再每个点rehash放置,而是最高位是0则坐标不变,最高位是1则坐标变为“10000+原坐标”,即“原长度+原坐标. 避免了频繁的哈希计算和搬移过程。
  • 使用尾插法在链表上插入节点
  • 桶内元素超过8个,链表转成红黑树

为什么java8要改成尾插法?#

A:
多线程时,java7的map-put可能造成死循环。
A线程扩容到那一半, 还处在遍历链表做头插法搬移的过程时,存了2个局部变量,当前链点now指向a, next指向b,正准备搬移(a->b->c这样的链表,a是头节点)

B线程则同时完成线程扩容,但是map里都是引用,浅拷贝,** 因为是头插法, 会导致顺序变化**, 原本a->b->c 变成了c->b->a。
因此A恢复时, 链点还是a,next还是b, 于是往下走到了b, 取bbs的next时,已经变成了a, 于是发生了a->b->a的循环
导致后续操作的next都是错误操作,引发环形指针。

java8里改成尾插法,这样做resize时,a->b->c 如果仍然哈希到同一个节点, 顺序是不会发生变化的。


虽然解决了死循环问题, 但java8的hashMap仍然是线程不安全的,为什么?#

A:
因为缺乏同步,导致同节点发生哈希碰撞时,if条件的判断都可能是有问题的,导致本该插在链表头节点后面的,结果直接作为链表头覆盖到数组上了。


具体到底满足什么情况,才会resize扩容呢?#

A:
 HashMap负载因子 LoadFactor,默认值为0.75f。
 衡量HashMap是否进行Resize的条件如下:
 HashMap.Size >= Capacity * LoadFactor

另一种情况。JDK1.8源码中,执行树形化之前,会先检查数组长度,如果长度小于64,则对数组进行扩容,而不是进行树形化


扩容后,capacity扩容多少倍呢?#

A:
哈希表每次扩容是两倍

ConcurrentHashMap 核心原理#

CHM结构

每个concurrentHashMap里有一个segment数组

每个segment里有HashEntry数组

每个hashEntry可以演变为链表或者红黑树

1659369632360

https://blog.csdn.net/qq_18300037/article/details/123795776

https://blog.csdn.net/every__day/article/details/114293107

JDK6-7中的实现原理#

初始化map#

初始化segment数组(通过concurrencyLevel和ssize)#

sszie即segment size,是seg数组的大小

ssize一定是2的N次方,且一定是 大于等于concurrencyLevel的最小值

sszie的最大值为65535

ssize的默认值是16

初始化segmentShift段偏移量和segmentMask段掩码#

这2个变量用于后面定位segment的散列算法使用

sshift 是 ssize的2的次方数

segmentShift = 32 - sshift 默认等于32-4=28

segmentMask = ssize -1 默认等于15

初始化每个segment#

每个segment里有一个entry数组

这个数组的大小cap也是2的N次方, 且大于等于 initialCapacity(就是你传的初始容量值) / ssize 即平均每段数量

1
this.segments[i] = new Segment<K,V>(cap, loadFactor);

loadFactor默认0.75

initialCapatity默认值为16

定位Segment#

对于一个key ,先是获取hashcode, 然后对hashcode进行变种算法再散列

1
2
3
4
5
6
7
8
private static int hash(int h) {
h += (h << 15) ^ 0xffffcd7d;
h ^= (h >>> 10);
h += (h << 3);
h ^= (h >>> 6);
h += (h << 2) + (h << 14);
return h ^ (h >>> 16);
}

这个算法能保证各位都能利用上,尽可能均匀, 相比hashmap里单纯和高16位做异或不同。

1
return segments[(hash >>> segmentShift) & segmentMask];

可以看出

segmentShift的作用是让hash通过右移动,只保留符合ssize的2进制位数长度

顺便通过和segmentMask进行&操作,剔除其他的位

总目的就是为了让32位的hash偏移后保持在ssize的范围内

按默认值而言,即 (hash>>>28) & 15

get操作#

先按照上文进行哈希, 获取segment后再进行get操作

整个过程都不需要加锁!

除非读到的值时空的,才会加锁重读确认(可能是被修改过了)

获取segment这个操作中,涉及的数组count、存储HashEntry的value,都涉及成了volatile,保持可见性。

put操作#

put操作处理共享变量(例如count等)是一定会加锁的。

区别在于扩容时,只对某一个segment扩容,不会影响其他的segment

扩容时会先创建容量两倍的数组,重新放入后在修改引用。

CHM是插入前先判断,再选择是否扩容

而hashMap是插入后判断,再选择是否扩容

size操作#

size自然是要把所有segment里的size进行相加

但是for循环相加可能会有前面的segment被修改,导致size变化,但如果加锁又太耗时间

因此引入了modCount,如果统计完成过程中发现modCount变化,则会加锁的方式重新统计。

JDK8的改造升级(重要)#

在`JDK8中,它进行了以下重要改进:

  1. 取消分段锁机制(Segment),进一步降低冲突概率; 使用桶的概念。

    其内部虽然仍然有 Segment 定义,但仅仅是为了保证序列化时的兼容性而已,不再有任何结构 上的用处。

    因为不再使用 Segment,初始化操作大大简化,修改为 lazy-load 形式,这样可以有效避免 初始开销

  2. size()方法做了优化,表达范围变大。原来map原有的size方法最大只能表达2的31次方减一,现在额外提供mappingCount方法,最大表示为2的63次方减一

  3. 使用 UnsafeLongAdder 之类底层手段,进行极端情况的优化。使用 CAS 等操作,在特定场景进行无锁并发操作。 即get使用volatile读, 而put则使用cas进行并发put。

  4. 类似hashMap引入红黑树解构,同一个哈希槽元素个数超过一定的阙值后,单链表转化成红黑树;

  5. 支持多线程并发扩容,大大提升扩容速度。

(4条消息) java8中concurrentHashmap的改进_庄国帅哥的博客-CSDN博客

(1条消息) JDK 8 ConcurrentHashMap_黄泥川水猴子的博客-CSDN博客_concurrenthashmap jdk8

ConcurrentLinkedQueue核心原理#

核心是使用CAS而不是锁来处理链式队列。

比较容易想到的是每次用cas来处理next指针的跟新以及tail指针的指向

1
2
3
4
5
6
7
8
9
10
11
public boolean offer(E e) {
if (e == null)
throw new NullPointerException();
Node<E> n = new Node<E>(e);
for (;;) {
Node<E> t = tail;
if (t.casNext(null, n) && casTail(t, n)) {
return true;
}
}
}

但是缺点在于及时没有并发,也要频繁cas更新tail节点。

实际上只需要用到tail节点的时候再去定位即可。

所以设置了一个HOPS值

当tail节点和实际尾节点的距离大于等于HOPS值,才去定位并cas更新tail节点,否则只更新next指针。

1659795490061

完整代码:

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
public boolean offer(E e) {
if (e == null) throw new NullPointerException();
// 入队前,创建一个入队节点
Node<E> n = new Node<E>(e);
retry:
// 死循环,入队不成功反复入队。
for (;;) {
// 创建一个指向tail节点的引用
Node<E> t = tail;
// p用来表示队列的尾节点,默认情况下等于tail节点。
Node<E> p = t;
for (int hops = 0; ; hops++) {
// 获得p节点的下一个节点。
Node<E> next = succ(p);
// next节点不为空,说明p不是尾节点,需要更新p后在将它指向next节点
if (next != null) {
// 循环了两次及其以上,并且当前节点还是不等于尾节点
// 需要退出2层for循环,重新定位tail节点了
if (hops > HOPS && t != tail)
continue retry;
p = next;
}
// 如果p是尾节点,则设置p节点的next节点为入队节点。
else if (p.casNext(null, n)) {
/*如果tail节点有大于等于1个next节点,则将入队节点设置成tail节点,
更新失败了也没关系,因为失败了表示有其他线程成功更新了tail节点*/
if (hops >= HOPS)
casTail(t, n); // 更新tail节点,允许失败
return true;
}
// p有next节点,表示p的next节点是尾节点,则重新设置p节点
else {
p = succ(p);
}
}
}
}

代价是距离越长,入队时定位队尾的时间越长。

但是效率总体上提升了, 因为通过增加对volatile变量的读操作来减少对volatile变量的写操作。

[toc]

相关整体搭建链接#

从零开始搭建个人博客(超详细) - 知乎 (zhihu.com)

http://theme-next.iissnan.com/

常用新文章头部内容:#

title: java类文件原理
date: 2022-05-04 22:25:40
tags: java
categories:

- [java,java高级原理]

typora-root-url: …

title: 第296场周赛-209名
categories:

    • 编程
    • 力扣周赛记录
      tags: ‘算法’
      typora-root-url: …/…/…/
      date: 2022-06-05 22:12:42

一些常见命令#

开启本地服务#

hexo server
hexo s

新建文章#

hexo new a

新建草稿#

hexo new draft b

发布草稿成为文章#

hexo publish b

发布关于#

hexo new page c

生成静态文章#

hexo generate
hexo g

部署文章#

hexo deploy
hexo d

背景音乐:#

https://blog.csdn.net/qq_39800978/article/details/109407258

菜单图片问题:#

需要看css里支持了哪些
https://blog.csdn.net/qq_43340547/article/details/120581558

修改page页默认前缀:#

F:\Code\Blog\scaffolds\post

typora+hexo插入图片#

https://www.cnblogs.com/yinyoupoet/p/13287385.html
hexo的图片只能是根目录下source/images目录
因此typora必须放到那个地方
且markdown里图片路径必须基于source/images
F:/Code/Blog/source/images/${filename}

支持toc目录#

https://www.pianshen.com/article/9489125286/

1
<!-- toc --> 

[toc]用于编辑时目录跳转

图片支持放大#

https://blog.csdn.net/wugenqiang/article/details/89057323
fancybox本地无法使用,会导致加载失败
主要是因为默认配置的cdn我们无法访问
https://blog.csdn.net/Aelous_dp/article/details/107643344
需要改为如下:
vendor:
jquery: //code.jquery.com/jquery-2.0.3.min.js
fancybox: //cdn.bootcss.com/fancybox/3.3.5/jquery.fancybox.min.js
fancybox_css: //cdn.bootcss.com/fancybox/3.3.5/jquery.fancybox.css
主要是本地无法连接他默认配置的那个CDN

添加访问量#

https://blog.csdn.net/baidu_34310405/article/details/102665373

valine评论(国内版)#

https://blog.csdn.net/weixin_48927364/article/details/123321038
https://blog.csdn.net/blue_zy/article/details/79071414
Valine交流群: 480972291
配置再next的config里的valine:

waline国外版

社交链接#

https://blog.csdn.net/weixin_44634406/article/details/122777058

themes/next下的_config.yml,搜索social

图标库

http://www.fontawesome.com.cn/faicons/#web-application

想要其他的国内软件图标:

https://blog.csdn.net/weixin_44634406/article/details/122777058

https://www.iconfont.cn/?spm=a313x.7781069.1998910419.d4d0a486a

版权#

hexo next config 中找到creative_commons, 各种设置为true

主config中设置url: ‘http://breakdawncoder.com/

复制黏贴时带版权信息:
(1条消息) 新手如何给Hexo博客在复制时添加版权声明_只是学习学习的博客-CSDN博客

升级markdown渲染器,支持插件#

https://www.jianshu.com/p/0bfc3029c980

超链接颜色改变#

打开 Blog\themes\next\source\css\_common\components\post 路径下的post.styl , 并在底部添加如下代码

1
2
3
4
5
6
7
8
9
a:not(.btn){
color: #6495ED; //超链接显示颜色
border-bottom: none;
&:hover {
color: #0000FF; //鼠标移动上去后超链接颜色
font-weight: bold;
text-decoration: underline;
}
}

修改左侧菜单栏#

themes/next下的_config.yml,查找menu

图标库:http://www.fontawesome.com.cn/faicons/#web-application

新添加的菜单需要翻译对应的中文,打开theme/next/languages/zh-CN.yml,在menu下设置

增加社交链接#

打开主题配置文件即themes/next下的_config.yml,搜索social:

显示站点文章总字数(另一种统计站点总字数的方法)#

从根目录Blog打开Git Bash,执行下面的命令,安装插件:

1
npm install hexo-wordcount --save

然后在/themes/next/layout/_partials/footer.swig文件尾部加上:

1
2
3
4
<div class="theme-info">
<div class="powered-by"></div>
<span class="post-count">博客全站共{{ totalcount(site) }}字</span>
</div>

再加上这里面的文章字数统计和阅读时常统计,关闭站点统计,站点统计用上面的这个

https://blog.csdn.net/qq_44082148/article/details/105701427

如果出现NAN,尝试hexo clean即可

1660668978986

1660669009040

添加博客背景图片#

打开主题配置文件即themes/next下的_config.yml,将 style: source/_data/styles.styl 取消注释:

1
2
custom_file_path:
style: source/_data/styles.styl

打开根目录Blog/source创建文件_data/styles.styl,添加以下内容:

1
2
3
4
5
6
7
8
// 添加背景图片
body {
background: url(/images/background.png);//自己喜欢的图片地址
background-size: cover;
background-repeat: no-repeat;
background-attachment: fixed;
background-position: 50% 50%;
}

images目录处于source\images下

注意图片名字不能带中文

Hexo-NexT 设置博客背景图片 - 锦瑟,无端 - 博客园 (cnblogs.com)

里面backgroud的属性是css的常见属性

设置next自带的动态背景#

在themes/next目录下打开Git Bash,输入:

1
git clone https://github.com/theme-next/theme-next-three source/lib/three

打开主题配置文件即themes/next下的_config.yml,找到three,这里有三种风格,可以试一下看看喜欢哪种风格,直接将false改为true就行了

1
2
3
4
5
6
7
# JavaScript 3D library.
# Dependencies: https://github.com/theme-next/theme-next-three
three:
enable: true
three_waves: false
canvas_lines: false
canvas_sphere: false

添加搜索框#

(5条消息) hexo+next 增加搜索功能_moguPeople的博客-CSDN博客_next 搜索

1661444579928

注意里面关于跳过的部分,不设置根目录的配置反而可以用

1、安装本地搜索插件 hexo-generator-search

在博客根目录安装搜索插件

1
2
3
  # 安装插件,用于生成博客索引数据(在博客根目录下执行下列命令):
npm install hexo-generator-search --save
12

安装之后,会在站点目录的 public 文件夹下创建一个 search.xml 文件。(如果没有 search.xml 文件,请继续往下看)

2、修改站点配置文件(如果上一步没有找到 search.xml文件 则可以跳过 )

在主题配置文件中的 _config.yml 中添加如下内容:

1
2
3
4
5
6
7
  # Search
search:
path: ./public/search.xml
field: post
format: html
limit: 10000
123456
  • path:索引文件的路径,相对于站点根目录
  • field:搜索范围,默认是 post,还可以选择 page、all,设置成 all 表示搜索所有页面
  • limit:限制搜索的条目数

3、主题配置文件

在主题配置文件 _config.yml 中找到如下内容:

1
2
3
4
5
  local_search:
enable: true
trigger: auto
top_n_per_article: 1
1234

确保 enable 设成 true

top_n_per_article 字段表示在每篇文章中显示的搜索结果数量,设成 -1 会显示每篇文章的所有搜索结果数量。

然后,重新部署网站即可愉快的使用本地搜索功能了。

文章首页置顶#

Hexo nexT主题之文章置顶 - 简书 (jianshu.com)

但是好像只能首页指定,不能分类置顶。。

替换目录下的文章厚葬的所有分类名字#

例如要把“后台开发知识”改成“开源组件原理”

sed -i “s/后台开发知识/开源组件原理/g” grep 后台开发知识 -rl .

https://blog.csdn.net/weixin_35864328/article/details/116653159

博文加密#

npm install hexo-blog-encrypt

1673073248716

encrypt:

enable: true

然后文章头部加入相应的字段

password:abcde
message: 这是私密博文哦,写看看其他的文章吧谢谢~。
wrong_pass_message: 抱歉, 秘密错误,不要尝试啦看看别的谢谢

url格式设置#

博客根目录的_config.yml中进行配置
permalink: :year/:month/:day/:title/ #年/月/日/文章路径

1673086806046

toc+标题+超链接#

解决Hexo建站使用toc目录跳转 undefined的问题 - 知乎 (zhihu.com)

站内文章链接#

Post not found: hexo blog

B站视频#

Hexo博客如何加入B站视频/How to add bilibili shared video in Hexo - 简书 (jianshu.com)

1
2
3
4
{% raw %}
<div style="position: relative; width: 100%; height: 0; padding-bottom: 75%;">
<iframe src="/ayer.bilibili.com/player.html?aid=622751345&bvid=BV1Qb4y137Ab&cid=1382547145&p=1" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true" style="position: absolute; width: 100%; height: 100%; Left: 0; top: 0;" ></iframe></div>
{% endraw %}

上面的src里的内容替换成 b站视频的外链

另外记得url里的最后加一个 &autoplay=false , 不然自动播放这种事情挺不礼貌的

[toc]

早期编译优化#

编译过程大致分为3类:

  1. 解析与填充符号表
  2. 注解处理
  3. 分析与字节码生成

源码JavaCompiler里的关键过程:
第一步:

da80a1a1a8f52928a88e81bac0766d8a0eeab3ed

第二步语法分析、词法分析

e87a2122c8d20662ac01c4a7f8af159ee4c8a865
第三步:

2fe67bfe0e1f3e543deba464130880231b32df7d
第四步:
执行注解处理
08cce0ef45b40e3db03ec3c0362444eb3dee4a24

接着就是语义分析及字节码生成
19956ec4da3e080289d22e64e04b58e9253e0de0

以上的关键点:

  • 词法语法解析是第一步,生成符号
  • 注解处理是第二步
  • 然后语法糖、字节码都是第三步的事情。

上述步骤的详细解释:

第一步:#

-------词法分析:#

就是代码转成token标记。
例如int a=b+2 转成 Int \a=\b+\2 这6个token。

-------语法分析(注意实际上只是生成一个语法树,还没做语法的校验):#

根据生成的token,构造一个抽象语法树。

-------填充符号表:#

生成一个符号地址和符号信息构成的表格。
(后面第三步的阶段会用于语义分析中的标注检查, 比如名字的使用是否和说明一致,也会用于产生中间代码)
符号表是目标代码生成时的地址分配的依据

第二步:#

-------注解处理器:#

注解处理器会扫描抽象语法树中带注解的元素, 并进行语法树的更新。
更新之后我们会重新走回解析与填充的过程,重新处理。
这个处理器是一种插件,我们可以自己不断往其中去添加。

注意,上面这2步只是简单去对源文件做转换, 还不涉及任何语法相关的规则。

第三步:#

-------语义分析:#

判断语法树是否正确。分为2种检查:

  1. 标注检查: 检查变量是否已被声明、 赋值、等式的数据类型是否匹配
    标注检查中会进行常量折叠, 把a=1+2折叠成3
    标注检查的范围比较小,不会有太多上下文依赖。

  2. 数据及控制流分析
    对程序上下文逻辑更进一步验证
    这里会涉及很多交互的上下文交互依赖
    比如 带返回值的方法是否全路径都包含了返回、 受检异常是否被外部处理、局部变量使用前是否被赋值。

final 局部变量(或者final参数)和非final局部变量,生成的class文件没有区别。
因为局部变量不会在常量池中持有符号引用, 所以不会有acesses_flasg信息。
所以class文件不知道局部变量是不是final, 因此final局部对运行期没有任何影响, 只会在编译期去校验。

-------解语法糖:#

虚拟机本身不支持这种语法, 但是会在编译阶段 把这些语法糖转为 普通的语法结构(换句话说做了把语法糖代码变成了普通代码, 例如自动装拆箱,可能就是转成了包装方法的特定调用)

-------字节码生成:#

对象的初始化顺序, 实际上会在字节码生成阶段, 收敛到一个方法中。 即init中控制了那些成员、以及构造方法的调用顺序
类初始化同理,也是收敛到一个
PS: 注意,默认构造器是在填充符号表阶段完成的。
字符串的替换(+操作转成sb) 是在字节码阶段生成的。
完成了对语法树的遍历之后,会把最终的符号表交给ClassWRITE类,设计概念从一个字节码和文件


java语法糖#

Q: java语法糖属于早期优化还是晚期优化?
A: 属于早期优化。

泛型擦除:#

java中的泛型只在 程序源码中存在, 在编译后的字节码文件中已经替换为原生类型, 并会插入一些强制转换的代码。

1
2
3
4
5
6
7
8
9
10
T f(T  t) {
T a = T.getA();
return a
}
实际上是

T f(Object t) {
Object a = (T)t.getA();
return (T)a;
}

即只在会方法的入口 和方法的出口处,做强制转换, 而实际上传入的都是原生类型,可以理解为object

神奇的例子:

1
2
3
4
5
6
7
public static void method(List<Integer> list) {
System.out.println("inoke method(List<Integer> list)");
}

public static void method(List<String> list) {
System.out.println("inoke method(List<String> list)");
}

这个编译会报错,认为有2种相同的方法, 因为编译后被擦除了。

然后下面这个例子却是tm能ok的:

1
2
3
4
5
6
7
8
9
10
11
12
public static Integer method(List<Integer> list) {
System.out.println("inoke method(List<Integer> list)");
}

public static String method(List<String> list) {
System.out.println("inoke method(List<String> list)");
}

public static void main(){
method(new ArrayList<String>());
method(new ArrayList<Integer>());
}

解释:
返回值不参与重载的选择,但是因为返回值作为描述符不一致,以至于可以在一个class文件中共存。
必须用Sun JDK1.6才能编译通过

Signature:#

用于解决泛型歹来的参数类型的识别问题。
可存储字节码层面的特征签名。
字节码层面: 方法名称、参数顺序、参数类型、 返回值、受检查异常, 这5个决定了1个字节码层面的方法是否唯一。
如果是java语法层面, 则签名只受方法名称、参数顺序、参数类型这3个的影响。

他会保存参数化类型的信息, 即虽然code里都转成了object, 但是其参数化类型还是通过signature保存到了元数据区, 可以通过反射获取参数化类型。

自动装箱拆箱、循环#

自动装箱拆箱: 就是在编译后, 自动把装拆箱的地方加上 Integer.valueOf() 或者 包装类型.intValue()

可变参数:被优化成一个数组
Arrays.asList(“1”,“2”,“3”,“4”) => Arrays.asList(new String[]{ “1”, “2”, “3”, “4”})

带iterator接口的 for循环:
for(String str : listStr) {
}
被优化成

1
2
3
4
5
6
7
8
9
10
11
for(Interator it = list.iterator(); it.hasNext()) {
xxx it.next xxx...
}

Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 321;
Integer f = 321;
Long g = 3L;

// true, 小于128,用常量池里的常量比较
System.out.println(c == d);
// false,非常量池,用各自不同的地址比较
System.out.println(e == f );
// true, 同1
System.out.println(c == (a+b));
// true, Integer
System.out.println(c.equals(a+b));
// true, 这个为什么==是可以的,是因为Long小于128时,也会用常量池的值吗
System.out.println(g == (a+b));
// false , equals不处理数据转型的关系。
System.out.println(g.equals(a+b));

条件编译:#

1
2
3
4
5
if(true) {
xxx
} else {
yyy
}

被优化成了

1
xxx

因为编译器很明确只能走xxx这个分支。
只能用于if+常量

使用常量与其他带有条件判断能力的语句搭配,则会报错

1
2
3
while(false) {
xxx
}

这个会报错unreachable statement code
为什么呢?
因为系统检测到这句代码是永远无法到达的分支,直接给你禁掉了

晚期编译优化#

HotSpot中, 解释器与编译器共存
当程序刚启动时,会先马上使用解释器发挥作用。
在程序运行后, 编译器逐步发挥作用,把还没用到的代码逐步编译。
内存资源比较少的情况下,可以用解释器来跑程序,减少编译生成的文件。
如果编译器的优化出现bug,可以通过“逆优化”回退到最初的解释器模式来运行

解释器Interperter#

编译器#

有两种编译器

  • Client Compiler ——C1编译器, 更高的编译速度

  • Server Compiler——C2编译器, 更好的编译质量
    即选择了-client或者-server时会用到。

  • 默认混合模式: 解释器和编译器共存, 即MixedMode。

关于这2种编译器的参数:

  • -Xint参数: 强制使用解释模式
  • -Xcomp参数: 强制使用编译模式( 但是如果编译无法进行时, 解释会介入)

混合模式中, 解释器需要收集性能信息,提供给编译阶段判断和优化, 这个性能信息有点浪费
因此JDK7引入了分层编译策略:

  • 第0层: 解释执行。 不开启性能监控。
  • 第1层: C1编译, 把字节码编译为本地代码, 进行一些简单优化, 加入性能监控
  • 第2层: C2编译, 启动耗时较长的优化, 根据性能监控信息进行激进优化

CC和SC编译过程的区别:

  • client Compiler 编译过程:
    前端字节码-》 方法内联/常量传播(基础优化)-》 HIR(高级中间代码)-》 空值检查消除/范围检查消除
    -》 后端把HIR转成LLR(低级中间代码)-》 线性扫描算法分配寄存器-》窥孔优化-》机器码生成-》本地代码生成
    都是一些不需要运行期信息就能做优化的操作

  • serverCompiler编译过程:
    会执行所有的经典优化动作
    会根据cc或者解释器提供的监控信息,进行激进的优化
    寄存器分配器是一个全局图着色分配器


晚期优化的一些常见措施(即运行中才会做优化的步骤)#

----热点代码#

  1. 被多次调用的方法。 会触发JIT编译
  2. 被多次执行的循环体, 会触发OSR编译(栈上替换), 发生在方法执行过程中, 所以是在栈上编译并切换方法。

HotSpot 使用 计数器的热点探测法确定热点代码。
* 给每个方法建立方法计数器, 在一个周期中如果超过阈值, 就触发JIT编译,编译后替换方法入口。
* 如果一个周期内没超过,则计数器/2(半衰)
* 如果没有触发时, 都是用解释方式 按照字节码内容死板地运行。

该计数器的相关参数
-XX:-UserCounterDecay 关闭热度衰减
-XX: CounterHalfLifeTime 设置半衰期-XX:CompileThreshold 设置方法编译阈值

回边计数器就是计算循环次数的计数器
* 没有半衰
* 但是当触发OSR编译时,会把计数器降低,避免还在运行时重复触发。
* 会溢出, 并且会把方法计数器也调整到溢出。
* clint模式和server模式中, OSR的阈值计算公式不同, clint= CompileThredshold * osr比率, server= CompileThredshold * (osr比率 - 解释器监控比率)

—冗余访问消除:#

如果已经拿到了 a.value, 该方法内a.value一定不会变的话, 那么后续用到时就不再从a中取value了
复写传播:

1
2
3
y=b.value
z=y
c = z + y

变成

1
2
3
y = b.value
y = y
c = y + y

无用代码消除:
去掉上面的Y=y

----公共子表达式消除#

就是对一些比较长的计算公式做化简
a+(a+b)2
会优化成
a
3+b*2
尽可能减少计算次数

—数组边界检查:#

如果能确定某个for循环里的数组取值操作一定不会超出数组范围,那么在做[]取值操作时,不会做数组边界检查。

—隐式异常处理:#

if(a == null) {
xxx
}
else{
throw Exception
}
优化成
try {
xxx
} catch(Exception e) {
throw e
}

为什么隐式异常处理不能放在早期优化?
f25fce1b1f632496074589dad0c260c0f1cc936a

----方法内联:#

不能被继承重写的方法,比如私有、构造器、静态之类的方法,可以直接在早期优化中做内联优化。
而其他会被抽象继承实现的方法在编译器无法做内联,因为他不知道实际是用哪一段代码。

  • final方法并不是非虚方法(为什么呢)
  • 类型继承关系分析CHA: 如果发现虚方法,CHA会查一下当前虚拟机内该方法是否有多个实现, 如果发现只有这一种实现,那么就可以直接内联。
  • 如果后续有其他的class动态加载进来后,该方法有多个实现了,并且被使用到了,那么就会抛弃已编译的内联代码,回退到解释状态执行。
  • 内联缓存: 即使程序中发现该方法有多个实现, 依然对第一个使用的那个方法做内联,除非有其他重写方法被调用(即虽然你定义了,但是你很可能不用,所以我一直使用你的第一个方法,除非你真的用了多种重写方法去跑。

----逃逸分析:#

分析new 出来的对象是否不会逃逸到方法外, 如果确认只在方法内使用,外部不会有人引用他, 那么就会做优化,比如:
* 不把new出来的对象放到堆,而是放到方法栈上,方法结束了对象直接消失。
* 不需要对这种对象做加锁、同步操作了
* 标量替换: 把这个对象里的最小基本类型成员拆出来作为局部变量使用。

----java和C++, 即时编译和静态编译的区别:#

  1. 即时编译可能会影响用户体验,如果在运行中出现较大影响的延迟的话
    2.
    java中虚方法比C要多, 因为做各种内联分析消耗的检查和优化的就越多越大
    3.
    java中总是要做安全检查, C
    中不做,出错了我就直接崩溃了越界了
    4.
    C中内存释放让用户控制, 无需后台弄一个垃圾回收器总是去检查和操作
    5.
    java好处: 即时编译能够以运行期的性能监控进行优化,这个是C
    无法做到的。

https://blog.csdn.net/qq_40925525/article/details/98363179


编译优化问答题#

@[toc]
首先提出一个问题,为什么C++的编译速度会比java慢很多?二者运行程序的速度差异子啊那? 了解了java的早期和晚期过程,就能理解这个问题了。

这里会提15个问题确认是否真的理解,如果完全没这方面的概念,则好好看一下前面的早期和晚期编译优化读书笔记

早期编译过程#


Q: java早期编译过程分为哪3步?
A:

  1. 词法语法解析、填充符号表
  2. 注解处理
  3. 语义分析与字节码生成。

Q: 上面的步骤中, 符号表是干吗的?
A:
符号表是符号地址和符号信息构成的表格。

  • 用于后面阶段做语法检查时,从表里取出信息进行对比。
  • 符号表是目标代码生成时的地址分配的依据

Q: 注解处理器做的什么事情?
A: 注解处理器会扫描抽象语法树中带注解的元素, 并进行语法树的更新。
重点就是他是基于语法树做更新。
更新之后我们会重新走回解析与填充的过程,重新处理。

APT可以用来在编译时扫描和处理注解。
通过APT可以获取到注解和被注解对象的相关信息,在拿到这些信息后我们可以根据需求来自动的生成一些代码,省去了手动编写。 例如@Setter
注意,获取注解及生成代码都是在代码编译时候完成的,相比反射在运行时处理注解大大提高了程序性能。



Q: 上面的3个步骤中, 解语法糖是哪一步?
A:
是第三步,在生成字节码的时候才做的语法糖处理。


Q: 什么是解语法糖?大概有哪些?
A:

  • 虚拟机本身不支持这种语法, 但是会在编译阶段 把这些语法糖转为 普通的语法结构。
  • 包含自动装拆箱、 泛型强转应用。

Q: 生成字节码class文件的时候, final和非final的局部变量, 会有区别不?
A:
没有区别。
局部变量不会在常量池中持有符号引用, 所以不会有acesses_flasg信息。
** 因此final局部变量在运行期没有任何作用, 只会在编译期去校验。**


Q: a= 1 + 2会在什么阶段进行优化?
A: 会在早期编译过程的语义分析过程中,进行常量折叠, 变成a=3
同理, 字符串+号优化成stringBuilder.append()这个动作也是该阶段优化的。


Q: 类对象加载的过程有一堆顺序(具体见类初始化顺序, 这个顺序在字节码中体现的吗?还是运行的时候再判断顺序?
A:
字节码中体现的。

  • 在字节码生成时, 编译器针对对象new的过程,会生成了一个方法,里面写明了成员、构造方法的调用顺序。
  • 类静态成员的调用顺序同理封装在中。

晚期编译优化#

Q:
早期编译优化和晚期编译优化的区别?
A:

  • 早期编译优化, 是把 java文件转成字节码,转字节码的过程中做一些简单优化和语法糖处理。
  • 晚期编译优化,是将字节码转机器码执行的过程中,结合一些信息进行动态优化,或者应用上很多的机器码优化措施。

Q: java程序运行的时候,是直接全部转成优化后的机器码再运行吗?
A:
错误。

  • 当程序刚启动时,会先马上使用解释器发挥作用,这时候没做太多优化,直接解释执行。
  • 在程序运行后, 编译器逐步发挥作用,把还没用到的代码逐步编译成机器码。

注意这里的编译器和之前提到的编译器的区别,一个是编译成字节码,另一个是编译成机器码。



Q: JIT是什么?
A:
为了提高执行速度,便引入了 JIT 技术。
当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。然后JIT会把部分“热点代码”编译成本地机器相关的机器码,并进行优化,然后再把编译后的机器码缓存起来,以备下次使用。


Q: 有两种晚期优化编译器

  • Client Compiler ——C1编译器
  • Server Compiler——C2编译器
    他们二者的区别是什么?

A:

  • 速度和质量的区别:
    C1编译器, 更高的编译速度,编译质量一般。
    C2编译器, 更好的编译质量,但是速度慢。
  • 优化特性的区别
    C1编译器都是一些不需要运行期信息就能做优化的操作。
    C2编译器则会根据解释器提供的监控信息,进行激进且动态的优化

Q: java中怎么区分用C1还是C2?
A:
关于这2种编译器的参数:

  • -Xint参数: 强制使用解释模式
  • -Xcomp参数: 强制使用编译模式( 但是如果编译无法进行时, 解释会介入)
  • 选择编译模式时,有-client、-server还有MixedMode(混合模式)可以选择

混合模式中, JDK7引入了分层编译策略:
第0层: 解释执行。 不开启性能监控。
第1层: C1编译, 把字节码编译为本地代码, 进行一些简单优化, 加入性能监控
第2层: C2编译, 启动耗时较长的优化, 根据性能监控信息进行激进优化


Q: 分层优化中,如果正在运行,jvm是怎么知道需要对哪些代码做JIT或者OSR优化?
A:

  1. 被多次调用的方法。 会触发JIT编译(热点代码计数器)
  2. 被多次执行的循环体, 会触发OSR编译(栈上替换), 发生在方法执行过程中, 所以是在栈上编译并切换方法。(使用回边计数器)

Q: 哪些方法会在早期优化中做内联,哪些方法会在晚期优化中做内联?
A:

  • 不能被继承重写的方法,比如私有、构造器、静态之类的方法,可以直接在早期优化中做内联优化。
  • 其他会被抽象继承实现的方法在早期无法做内联,因为他不知道实际是用哪一段代码.
  • 晚期优化中可以根据一些运行信息,判断是否总是只用某个子类方法跑,是的话做一下尝试内联,如果后面来了其他的子类就切回去。

Q: java数组一般都会自动做边界检查,不满足就抛异常。 什么情况下会优化掉这个自动检查?
A:
运行期,发现传入的参数放到数组中用的时候, 肯定不会超出边界,则会优化掉这个检查动作。


看完上面的,就可以给出C++和java编译和运行速度差距的原因了:

  1. java即时编译可能会影响用户体验,如果在运行中出现较大影响的延迟的话。
  2. java中虚方法比C++要多, 因为做各种内联分析消耗的检查和优化的就越多越大
  3. java中总是要做安全检查, C++中不做,出错了我就直接崩溃了越界了
  4. C++中内存释放让用户控制, 无需后台弄一个垃圾回收器总是去检查和操作
  5. java好处: 即时编译能够以运行期的性能监控进行优化,这个是C++无法做到的

[toc]

为什么说线程切换会很慢?#

所谓的用户态和内核态之间的切换仅仅是一方面, 并非全部, 更关键的在于, 多CPU的机器中,当你切换了线程,意味着线程在原先CPU上的缓存也许不再有用, 因为当线程重新触发时,可能在另一个CPU上执行了。

正因如此,不建议没事就挂起线程, 让线程自旋会比挂起后切换CPU好很多。

正与基于这一点,才有了后面的sync锁升级机制,理解了为什么要锁升级,才能逐步理解锁升级过程,

对象头中的mark-word#

java每个对象的对象头中, 都有32或者64位的mark-word。

mark-word是理解锁升级过程的重要部分,且后面的锁升级过程都会涉及,因此这里会进行一个非常详细的解释。这部分只对一个对象必有的属性做解释(即一般不会随着锁状态变化而消失的属性)。对于各锁状态独有的属性,会在锁升级过程中做详细的解释。

1655422124426

锁状态标志位 +偏向锁标记位(2bit + 1bit)#

除了markword中的2位锁状态标志位, 其他62位都会随着锁状态标志位的变化而变化。

这里先列出各锁状态标志位代表的当前对象所用锁的情况。后面会详细解释各种锁的含义和运行原理。

  • 锁状态标志位为01: 属于无锁或者偏向锁状态。因此还需要额外的偏向锁标记位1bit来确认是无锁还是偏向锁
  • 锁状态标志位为00: 轻量级锁
  • 锁状态标志位为10: 重量级锁
  • 锁状态标志位为11: 已经被gc标记,即将释放

为什么无锁/偏向锁的标志位是01,而轻量级锁的标志位是00?#

即按理说,无锁是锁状态的初始情况,为什么标志位不是从00开始?

个人查询到的一个解释,是因为 轻量级锁除了锁标志位外,另外62位都是一个指针地址。

如果将轻量级锁标志位设置为00, 那么在判断标志位为00后, jvm无需再额外做一次markWord>>2的操作,而是直接将markWord拿来当作地址使用即可!

可以从这里看到jvm的设计者还是非常细节的,并没有随意地定义各状态的标志位

hashcode(31bit)#

哈希code很容易理解,将对象存储到一些map或者set里时,都需要hashcode来确认插入位置。

但markword里的hashcode,和我们平时经常覆写的hashCode()还是有区别的。

markword中的hashcode是哪个方法生成的?#

很多人误以为,markword中的hashcode是由我们经常覆写的hashcode()方法生成的。

实际上, markword中的hashcode只由底层 JDK C++ 源码计算得到(java侧调用方法为 System.identityHashCode() ), 生成后固化到markword中,

如果你覆写了hashcode()方法, 那么每次都会重新调用hashCode()方法重新计算哈希值。

根本原因是因为你覆写hashcode()之后,该方法中很可能会利用被修改的成员来计算哈希值,所以jvm不敢将其存储到markword中。

**因此,如果覆写了hashcode()方法,对象头中就不会生成hashcode,而是每次通过hashcode()方法调用 **

markword中的hashcode是什么时候生成?#

很容易误以为会是对象一创建就生成了。

实际上,是采用了延迟加载技术,只有在用到的时候才生成。

毕竟有可能对象创建出来使用时,并不需要做哈希的操作。

hashcode在其他锁状态中去哪了?#

这个问题会在后面锁升级的3个阶段中,解释hashcode的去向。其他的例如分代年龄同理。

gc分代年龄(4bit)#

在jvm垃圾收集机制中, 决定年轻代什么时候进入老年代的根据之一, 就是确认他的分代年龄是否达到阈值,如下图所示。

1655422054870

分代年龄只有4bit可以看出,最大值只能是15。因此我们设置的进入老年代年龄阈值 -XX:MaxTenuringThreshold 最大只能设置15。

cms_free#

在无锁和偏向锁中,还可以看到有1bit的cms_free。

实际上就是只有CMS收集器用到的。但最新java11中更多用的是G1收集器了,这一位相当于不怎么常用,因此提到的也非常少。

从上述可以看出, 只有锁状态标记位、 hashcode、 分代年龄、cms_free是必有的, 但是从markword最初的示意图来看, hashcode、 分代年龄、cms_free似乎并非一直存在,那么他们去哪了呢?会在后面的锁升级过程进行详细解释。

锁升级四个阶段超级详解#

无锁#

无锁状态的markword如下所示,可以看到上文提到的信息都存在

1655337139035

处于无锁状态的条件或者时机是什么?#

无锁状态用于对象刚创建,且还未进入过同步代码块的时候

这一点很重要, 意味着如果你没有同步代码块或者同步方法, 那么将是无锁状态。

对象从没进入同步块,为什么偏向锁标志位却是1?#

上面这个问题说过,没进入同步块, 不会上偏向锁。

但是我们如果用java的jol工具测试打印新对象,会看到低3位是101

1655537149976

这其实是jvm后面加入的一种优化, 对每个新对象,预置了一个**“可偏向状态”,也叫做匿名偏向状态**,是对象初始化中,JVM 帮我们做的。

注意此时 markword中高位是不存在ThreadID的, 都是0, 说明此时并没有线程偏向发生,因此也可以理解成是无锁。

好处在于后续做偏向锁加锁时,无需再去改动偏向锁标记位,只需要对线程id做cas即可。

偏向锁#

一旦代码第一次进入sync同步方法块,就可能从无锁状态进入偏向锁状态。

另外很多人应该都知道, 偏向锁只存储了当前偏向的线程id, 只有线程id不同的才会触发升级。

但这是非常简化的说法, 实际上中间的细节和优化非常之多!这里将为你详细讲述。

为什么要有偏向锁?#

理解这个才能理解偏向锁中的各种设计。 假设我们new出来的对象带有同步代码块方法,但在整个生命周期中只被一个线程访问,那么是否有必要做消耗消耗的竞争动作,甚至引入额外的内存开销?没有必要。

因此针对的是 对象有同步方法调用,但是实际不存在竞争的场景

偏向锁的markword详解#

1655534605569

这个markword和无锁对比, 偏向标志位变成了1, hashcode没了,多了个epoch和线程id。

markword中的当前线程id#

这个id就是在进入了对象同步代码块的线程id。

java的线程id是一个long类型, 按理说是64位,但为什么之类的线程id只有54位?#

具体没有找到解释,可能是jvm团队认为54位线程id足够用了,不至于会创建2^54那么多的线程,真的有需要创建这么频繁的程序,也会优先采用线程池池才对

线程id如何写入?#

线程id是直接写入markword吗? 不对, 一定要注意到这时候是存在同时写的可能的。

因此会采用CAS的方式进行线程id的写入。 简而言之, 就是先取原线程id后,再更新线程id,更新后检查一下是否和预期一致,不一致则说明被人改动过,则线程id写入失败,说明存在竞争,升级为轻量级锁。

哈希code去哪了#

我们注意到无锁时的hashcode不见了。

对于偏向锁而言, 一旦在对象头中设置过hashcode, 那么进入同步块时就不会进入偏向锁状态,会直接跳到轻量级锁,毕竟偏向锁里没有存放hashcode的地方(下文的轻量级锁和重量级锁则有存储的地方)

因此凡是做过类似hashmap.put(k,v)操作且没覆写hashcode的k对象, 以后加锁时,都会直接略过偏向锁。

epoch是什么?#

这个属性很多人叫它“偏向时间戳”, 却鲜有人进行详细解释。

主要是因为它涉及到了偏向锁中非常重要的2个优化(批量重偏向和批量撤销)

对于这个epoch,放到下文的偏向锁解锁过程进行解释。

你可以先简单理解为,通过epoch,jvm可以知道这个对象的偏向锁是否过期了,过期的情况下允许直接试图抢占,而不进行撤销偏向锁的操作。

偏向锁运作详解#

偏向锁上锁时,如何避免冲突和竞争?#

我们知道偏向锁其实就是将线程id设置了进去,但是如果存在冲突怎么办?

因此,jmv会通过CAS来设置偏向线程id,一旦设置成功那么这个偏向锁就算挂上了。

后面每次访问时,检查线程id一致,就直接进入同步代码块执行了。

CAS概念补充:

CAS是一个原子性操作, 调用者需要给定修改变量的期望值 和 最终值

当内存中该变量的值和期望值相等时,才更新为最终值, 这个相等的比较和更新的操作是原子操作

对于到偏向锁加锁过程, 其实就是先取出线程id部分, 如果为空, 则进行(期望值:空 , 最终值:当前线程id)的CAS操作, 如果发现期望值不匹配,就说明被抢先了 。

离开同步代码块时, markword中的线程id会重新变为0吗?#

并不会,这个偏向锁线程id会一直挂着, 后面只要识别到id一致,就不用做特殊处理。

偏向锁发生竞争时的切锁或者升级操作。#

但当有其他线程来访问时,之前设置的偏向锁就有问题了,说明存在多线程访问同一个对象的情况。

注意!!!这里并非像很多资料里说的那样, 一旦发生多线程调用, 偏向锁就升级成轻量级锁,而是做了很多的细节处理,来尽可能避免轻量级锁这种耗费CPU的操作。

首先,jvm考虑到了这种场景:

最开始1h内,都是线程A在调用大量的对象obj, 于是偏向锁一直都是线程A。

后来线程A不跑了, 对象obj的调用交给了线程B,即未来都是线程B来调用。

那么这时候,有必要马上升级轻量级锁吗?

没必要!因为未来仍然是单线程调用,仅仅是线程不同而已,也许可以尝试仍旧用偏向锁?

于是就有了如下的撤销偏向锁的动作:

  1. 当线程B发现是偏向锁,且线程id不为自己时,开始撤销操作
  2. 首先,线程B会一直等待 对象obj 到达jvm安全点
  3. 到达安全点后, 线程B检查线程A是否正处在obj的同步代码块内。
  4. 如果线程A正在同步代码块中, 则没得商量了,直接升级为轻量级锁。
  5. 如果线程A不在同步代码块中, 那么线程B还有机会, 它先把偏向锁改成无锁状态,然后再用CAS的方式尝试重新竞争,如果能竞争到,那么就会偏向自己。

完整过程如下图所示:

1655599370757

为什么要等待安全点,才能做撤销操作?#

这是为了保证撤销操作的安全性。否则可能出现jvm正在撤销的时候, 另一个线程又开始对该对象做操作,引发错误。

为什么要先退化成无锁状态,再试图竞争成偏向锁?不能直接偏向吗?#

因为你无法预测A是否会卷土重来,置成无锁后, A和B可以公平竞争。

为什么原偏向线程在同步代码块中时,就必须升级为轻量级锁?能否同样撤销无锁来竞争?#

不可以,因为同步代码块还在执行的话,那B线程此时是注定无法立刻得到锁的,注定了它必须升级为轻量级锁,通过轻量级锁中的循环能力来做获取锁的操作。

批量重偏向,以及epoch的应用#

上文提到, 线程B重新抢偏向锁时,会试图等待安全点,撤销成无锁,再做公平抢占。 这个动作还是比较费时的。

假设有一个场景, 我们new 了30个obj对象, 最初都是由A线程使用,后面通过for循环都由B线程使用,那么会发现在很短的时间内,连续发生了偏向锁撤销为无锁,且未因同步块竞争而发生轻量升级的情况。

那么,jvm猜测此时后面都是类似的情况,于是B线程调用obj对象时,不再撤销了,直接CAS竞争threadId,因为jvm预测A不会来抢了,具体步骤如下所示:

  1. jvm会在obj对象的类class对象中, 定义了一个偏向撤销计数器以及epoch偏向版本。

  2. 每当有一个对象被撤销偏向锁, 都会让偏向撤销计数器+1。

  3. 一旦加到20, 则认为出现大规模的锁撤销动作。 于是class类对象中的epoch值+1(但是epoch一般只有2位即0~3)。

  4. 接着, jvm会找到所有正处在同步代码块中的obj对象, 让他的epoch等于class类对象的epoch。

  5. 其他不在同步代码块中的obj对象,则不修改epoch。

  6. 当B线程来访问时,发现obj对象的epoch和class对象的epoch不相等,则不再做撤销动作,直接CAS抢占。 因为当epoch不等时,这说明该obj对象之前一直没被原主人使用, 但它的兄弟们之前纷纷投降倒戈了, 那我应该直接尝试占用就好,没必要那么谨慎了!

详细过程如下图所示:

1655542634901

批量撤销#

但如果短时间内该类的撤销动作超过40个, jvm会认为这个数量太多了, 不保险,数量一多,预测就不准了。

jvm此时会将 obj对象的类class对象中的偏向标记**(注意是类中的偏向锁开启标记,而不是对象头中的偏向锁标记)**设置为禁用偏向锁。 后续该对象的new操作将直接走轻量级锁的逻辑。

1655600519145

偏向锁在进程一开始就启用了吗#

即使你开启了偏向锁,但是这个偏向锁的启用是有延迟,大概 4s左右。

即java进程启动的4s内,都会直接跳过偏向锁,有同步代码块时直接使用轻量级锁。

原因是 JVM 初始化的代码有很多地方用到了synchronized,如果直接开启偏向,产生竞争就要有锁升级,会带来额外的性能损耗,jvm团队经过测试和评估, 选择了启动速度最快的方案, 即强制4s内禁用偏向锁,所以就有了这个延迟策略 (当然这个延迟时间也可以通过参数自己调整)

偏向锁的重要演变历史和思考#

偏向锁在JDK6引入, 且默认开启偏向锁优化, 可通过JVM参数-XX:-UseBiasedLocking来禁用偏向锁。

jdk的演变过程中, 为偏向锁做了如上所述的批量升级、撤销等诸多动作。

但随着时代发展,发现偏向锁带来的维护、撤销成本, 远大于轻量级锁的少许CAS动作。

官方说明中有这么一段话: since the introduction of biased locking into HotSpot also change the amount of uncontended operations needed for that relation to remain true。

随着硬件发展,原子指令成本变化,导致轻量级自旋锁需要的原子指令次数变少(或者cas操作变少 个人理解),所以自旋锁成本下降,故偏向锁的带来的优势就更小了

于是jdk团队在Jdk15之后, 再次默认关闭了偏向锁

也许你会问,那前面学习了那么一堆还有啥意义,都不推荐使用了。

但大部分java应用还是基于jdk8开发的, 并且偏向锁里的思想还是值得借鉴的。

还有就是奥卡姆剃刀原理, 如果增加的内容带来很大的成本,不如大胆的废除掉,接受一点落差,将精力放在提升度更大的地方。

轻量级锁#

轻量级锁的markword如下所示,可以看到除了锁状态标记位,其他的都变成了一个栈帧中lockRecord记的地址。

1655603492133

原先markword中的信息都去哪里了?#

之前提到markword中有分代年龄、cms_free、hashcode等固有属性。

这些信息会被存储到对应线程栈帧中的lockRecord中。

lockRecord格式以及存储/交换过程如下:

1656209393501

**另外注意, 当轻量级锁未上锁时, 对象头中的markword存储的还是markword内容,并没有变成指针,只有当上锁过程中,才会变成指针。 **

因此轻量级锁是存在反复的加锁解锁操作的(偏向锁只有在更换偏向线程时才会有类似动作)

解锁过程同理,通过CAS,将对象头替换回去。

轻量级锁如何处理线程重入问题?#

对于同一个线程,如果反复进入同步块,在sync语义上来说是支持重入的(即持有锁的线程可以多次进入锁区域), 对轻量级锁而言,必须实现这个功能。

因此线程的lockRecord并非单一成员,他其实是一个lockRecord集合,可以存储多个lockRecord

每当线程离开同步块,lockRecord减少1个, 直到这个lockReocrd中包含指针,才会做解锁动作。

1656209539382

轻量级锁加锁过程#

根据上述CAS和重入相关,可以得到进入同步代码块时的加锁过程:

  1. 进入同步块前,检查是否已经储存了lockRecord地址,且地址和自己当前线程一致 。如果已经存了且一致,说明正处于重入操作,走重入逻辑,新增lockRecord

  2. 如果未重入,检查lockRecord是否被其他线程占用,如果被其他线程占用,则自旋等待,自旋超限后升级重量级锁

  3. 如果未重入,且也没被其他线程占用,则取出lockRecord中存的指针地址,然后再用自己的markword做CAS替换

  4. 替换失败,则尝试自旋重新CAS,失败次数达到上限,也一样升级

    img

轻量级锁的解锁流程#

根据上面重入的问题,可以得到轻量级锁的退出流程如下:

1656211852407

自旋次数的上限一定是10次吗?#

在JDK 6中对自旋锁的优化,引入了自适应的自旋。

自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。

另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越精准,虚拟机就会变得越来越“聪明”了

重量级锁#

重量级锁如下:

每个对象会有一个objectMonitor的C对象生成, 通过地址指向对方,后面的逻辑都是通过C来实现。

1656831726425

升级为重量级锁的条件#

  1. 从轻量级锁升级为重量级锁的条件: 自旋超过10次 或者达到自适应自旋上限次数

  2. 从无锁/偏向锁直接升级为重量级锁的条件:调用了object.wait()方法,则会直接升级为重量级锁!

    第二个条件容易被忽略的

markword去哪了#

对象头中的markwod,和轻量级锁中的处理类似, 被存入了objectMonitor对象的header字段中了。

重量级锁同步的原理图解#

每个对象的重量级锁指向一个独有的objectMonitor

这个对象是C++实现的

里面的东西比较多,内容非常复杂,里面关于cxq、entryList、qmod之间的关系非常复杂,这里只会简单解释部分过程,不一定全部正确或者包含所有细节。

因此特地拿出一句我认为说的很好的话:

“与其费劲心机研究C++实现的objectMonitor,不如去研究使用java实现的AQS原理,二者的核心思想大部分一致,AQS源码在语言上对java人而言更为友好 ,能让你更好理解线程排队、等待、唤醒的各种过程”

但这篇文章毕竟说的是sync关键字,所以还是简要说一下monitor的过程:

  1. 当线程第一次调用monitorEntry执行且是重量级锁的情况下,会先进入cxq队列

    1656836560641

  2. 当涉及锁的频繁竞争且需要阻塞时,需要进入entryList队列中。

    1656836632599

  3. 如果线程能CAS竞争到onwer指针,就说明占有同步代码块成功, 如果CAS竞争不到,则block阻塞。

    1656836734369

  4. monitorExit退出时,会让entryList中block阻塞的线程唤醒重新竞争

    1656836830029

  5. 如果调用了object.wait()方法, onwer线程会进入等待队列(注意,因为竞争失败的线程,不会进入waitSet,waitSet只服务于那些wait()方法引发的线程)

    1656836839076

  6. 当调用的object.notify()或者notifyAll, 等待队列中的线程会根据qmod模式的不同,进入cxq或者进入entryList。

1656836900003

简要版流程如下:

1656836348826

关于synchronized关键字的思考#

终于写完了,说点其他的。

众所周知,随着jdk的不断升级, 官方提供的JUC以及衍生同步组件越来越强大, sync与其相比,功能相当少,背后逻辑却异常复杂,甚至因为过于复杂,还在中间对偏向锁的功能进行了默认关闭的操作。

那么这个关键字是否还有存在的必要呢?

首先,很多历史代码以及内部某些jdk代码实现,都还是会依赖这个关键字进行同步处理,没法全部替换成AQS。

另外,不考虑背后升级的复杂逻辑, sync使用起来绝对是比JUC简单很多的, 当你的场景很简单,但确实有同步的问题, 用sync会提升不少开发的效率。

《深入理解Java虚拟机》第二版中,关于volatile的原理,特地先讲述了如下图所示的JMM内存模型:
image.png
它用了8种内存操作,以及多种规则,来告诉你特定情况下线程间的数据会如何同步。
然而这个模型实际上已经在JDK5之后的虚拟机规范中被废弃了。
最新官方文档中是采用“happens-before”模型来帮助java程序员分析多线程环境下的运行情况。
关于happends-before模型的详细解释,建议阅读《java并发编程的艺术》一书的第三章节。
本文并不讨论happends-before模型,只讨论底层原理,希望借着理解volatile,去理解一下它和CPU之间的关系。关于这部分内容,网上其实有很多错误的解读,根本原因在于没有从真正底层运行的原理考虑,导致了很多误区的产生。

本文将为你解答一下三大误区问题:

  1. MESI缓存一致性,为什么要设置4种状态这么复杂?是否都是同步、阻塞地保证缓存一致?
  2. 更新变量后,另一个线程真的永远不可见吗?多线程问题的本质是什么?
  3. volatile保证一致性的真正底层运行逻辑是什么?

以上问题,我将从CPU的层面,以超长图解和文字的形式,为你完整呈现。


[toc]

JMM内存模型和CPU#

为什么需要这个JMM模型?用来做什么的?#

这是为了给java开发者提供屏蔽平台差异性的、统一的多线程执行语义
不同的操作系统或者不同的CPU,其对多线程并发问题的支持情况是不同的,但jvm尽量在背后将其实现成一套统一的逻辑。
即你按照我的关键字操作,就可以像JMM模型里那样运作,不需要关心背后的CPU怎么跑的

上述模型和背后操作系统、CPU实现有什么关系?工作内存对应什么,主内存对应什么?#

image.png

  • 主内存对应于java堆中的对象实例部分(物理硬件的内存)
  • 工作内存对应于虚拟机栈中的部分区域( 寄存器,高速缓存, 线程主要访问读写的都是工作内存)

什么是CPU缓存?CPU缓存长什么样?#

CPU缓存可以理解为一个容量有限的哈希表。
将某地址数据根据地址做哈希,映射到缓存的某一行,并且有替换的情况。
而划分两列,则是避免一出现hash冲突,就马上淘汰原内容的情况。
因此增加了备用列。通过这样一个多行两列的结构,根据内存地址实现了缓存的功能。
image.png

2个CPU同时更新数据时,有什么办法避免同时修改主存?#

  • 一种方式是使用总线锁:确保CPU在持有总线锁期间,处理器可以独占任何共享内存。

总线锁的代价是开销很大,其他CPU会一直在等待总线释放,读和写操作都无法处理。

  • 另一种方式是缓存锁,锁住各自的缓存,并通过缓存一致性协议MESI来进行和主存的同步。

MESI协议超级详解#

画了很多张图,顺便带上很多文字,来解释MESI协议的原理:
首先,CPU1和CPU2中的缓存都是空的, 因此缓存状态位是“I”(Invalid)
后面会相继提到各个状态的变化。
image.png


此时主存会将a的数据返回给CPU1, 并把CPU1的缓存状态设置为独占E(Exclude)
image.png

为什么要设计独占状态?#

目的是为了减少不必要的全局同步消息。
当这个a变量只有CPU1使用时,无论CPU1怎么修改,也只有CPU1在查看,没必要把信息同步给其他CPU,从而提升了效率, 对于一些不参与竞争的变量来说,非常有用。


好,独占的问题搞定,那么当CPU2也读取时,会发生什么?
此时就会不再是独占状态了,2个CPU同时被修改为共享状态S(Share)
image.png

为什么CPU2对a值的获取,能够修改CPU1的状态?#

这是因为CPU可以通过总线广播+监听消息来变更状态, 也称嗅探机制。
即CPU核心都会经常监听总线上的广播事件,根据事件(消息)类型,来做不同的应对。
因此当CPU2更改后,总线会广播read消息,当CPU1收到read消息,并确认这个数据的地址和自己缓存中的地址是一致的时候,就会修改状态为S了。


上述问题搞定,再看最关键的修改缓存的部分了!

  1. 当CPU1触发对a变量的修改时,会先发送一个Invalid消息。
  2. CPU1此时会等待不动,停止任何操作,类似于阻塞了,它在等待其他CPU给他回应。
  3. 当其他的CPU收到Invalid消息时,会将缓存中的a变量修改为I(Invalid)无效状态
  4. 当所有CPU都处理完成时,总线为CPU1返回Invalid ack消息,CPU1才放心的将S状态修改为了M(modify)状态。
    结合下面的图进行阅读更佳,注意图中的序号:
    image.png
    很容易想到, 要将其他CPU设置为无效的原因,是为了保证其他CPU后面再次试图取a值时,取到的是最新的,而不是缓存的错误数据。

修改完M状态后,发生了什么?是直接同步回内存吗?#

不是的!M状态此时就像“独占状态”一样,贪婪地占有这个缓存,后续的修改、读取,都直接读这个缓存,不再走任何总线

其目的和独占状态E一样,都是为了减少非竞争情况下不必要的总线消耗
image.png


那么什么时候Modify状态会变化呢?
当其他的CPU试图获取a值,就会发生变化。其过程与 Exclude独占状态到Share状态 是类似的
image.png
image.png

上面的内容给我一种感觉,MESI协议中,一直在给我们传达一个信息:MESI设置那么多状态,主要是为了避免每次都竞争。竞争只是偶然发生的,我们要尽可能少地乱锁总线!

不可见的误区例子#

从上面可以看到,当我们修改缓存时,会通过触发对其他缓存的无效化,达到变量对其他线程“可见”的效果。
因此,MESI缓存一致性协议已经实现了缓存的可见性
下面这种例子中,当flag=true时, 其他CPU通过MESI协议,是能够感知到flag的变化的,因为缓存一定会在那个时刻被设置为无效,从而获取最新的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class A {
static boolean flag = false;
static int num = 0;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
while (!flag){
num++;
}
}).start();
flag = true;
}
//输出结果
1255362997
}

重头戏:有MESI保证可见性的下,为什么还有会多线程问题?#

首先可以看这个知乎问题的回答,如果看不懂,可以看我为你整理的详细解释:
https://www.zhihu.com/question/277395220


每个java线程有自己的寄存器。
线程寄存器和CPU缓存的关系?


上面的MESI协议图中,其实缺少了2个关键的优化,这2个优化点,也成了可见性问题的根源。
为了好好讲清楚这2个优化点带来的影响,我特地放到这里才讲述,也将会帮助你大大理解“可见性”问题的本质!

首先我们回到CPU1修改a值时的那张图:
image.png

里面提到,CPU1会等待所有CPU都将状态位置为I(无效)后,才开始修改状态并更新。那么有个问题:

CPU1等待其他CPU清空缓存的阻塞等待行为会不会太慢了?#

如果CPU1后面有好几条和a无关的指令(例如b=3,d=e等),都在为了等待a的更新而不执行,未免太浪费时间了!

因此MESI设计了一个叫做 “StoreBuffer” 的东西,它会接收CPU1的修改动作,并由StoreBuffer来触发“阻塞等待->全部收到->修改状态M”的动作。
而CPU1则继续管自己去执行后续与a无关的指令。
因此StoreBuffer就像是一个异步的“生产者消息队列”。,如下所示:
image.png

但是还有个问题,因为是等待所有CPU将a状态改为I,这个修改动作是需要时间的。
如果有一个CPU修改的比较慢,可能会导致 StoreBuffer这个生产者队列出现队满的情况,于是继续引发了阻塞。

有什么办法能加快响应速度Invalid消息的响应速度呢?#

那就是再引入一个“异步的消费者队列”,名叫Invalid Queue
这样其他CPU收到消息时,先别急着处理,而是存到这个Queue中,然后直接返回Invalid消息,这样响应就变快了! 也就是更新动作,和失效消息的接收,都加了一个队列!
如下图所示:
image.png

多线程问题与StoreBuffer、Invalid-Queue之间的关系#

终于来到了关键的部分了。
从刚才的描述中,可以看到CPU引入了2个异步的队列,来处理数据的更新动作。
那么就可能存在赋值的动作被放入异步队列,导致延迟触发的情况。
而正是这个延迟放入的动作,可能导致数据延迟修改,即使没有发生指令重排序。
这样干讲比较难懂,还是需要结合代码和图解。
首先是这个经典的多线程代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; // 1
flag = true; // 2
}
Public void reader() {
while (flag != true) { // 3
;
}
int c = a * 2; // 4
…………
}
}

按照设想,程序员本是希望有如下的表现:
image.png
但是事与愿违,当reader方法中离开了flag循环时,a的值仍然是初始化值0,导致c的值为0。

那么在了解了刚才的CPU原理后,我们终于可以开始分析这段代码为什么会发生这种问题了:

  1. 当线程writer执行a=1时,CPU要做更新,会通过上面提到的异步机制进行更新。如果这个CPU此时堆积了很多的写操作,会导致a=1这个动作在异步队列中处于等待。
    image.png

  2. 时间片切换,线程writer切到了另一个CPU上
    image.png
    注意一个很重要的点:
    线程执行指令,并非只在1个CPU上运行,是可以通过时间片轮转切换的。因此CPU和线程并非完全绑定的关系

  3. flag=true动作在CPU2上迅速响应,很快完成了缓存一致性
    image.png

4.reader线程读到了最新的flag,却没有读到新的a,导致了a还在用旧的值。
image.png

因此可以看到,正是CPU之间缓存更新的延迟,导致了多线程不同步问题的发生

volatile又是如何避免CPU更新延迟问题的?#

这里不谈论那让人费解的内存屏障, 只要记住一点:
对于volatile变量,一旦更新,不会走CPU异步更新,而是在这个CPU阻塞住,直到写动作完整完成,才会继续下一个指令的运行
本质上是利用的#LOCK指令。
它的作用就是必须等待该变量的storeBuffer的清空,读取时也必须等待InvalidQueue的清空,才能去做写和读。从而保证不会出现因异步导致的多线程不同步问题


图解总结#

写文章不容易,学习也不容易,给我点个关注点个赞,未来会持续更新具有思考深度的学习文章。欢迎在华为云社区共同交流和学习。

MESI缓存一致性原理图解如下:
image.png

多线程同步问题如下:
image.png

1654438529701

[toc]

题目简单, 速度不够快。

总结:#

  1. 再提升1分钟的速度即可进前200

  2. 这种光标左右移动的题目,只划分左右两边的情况,使用双栈会比链表更合适。

第一题:简单遍历题#

1654438568104

重点在于能否快速理解并迅速编写,用一个递归可以快速搞定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public int minMaxGame(int[] nums) {
if (nums.length == 1) {
return nums[0];
}
int[] newNums = new int[nums.length/2];
for (int i = 0;i<newNums.length;i++) {
if (i%2==0) {
newNums[i] = Math.min(nums[2*i], nums[2*i+1]);
} else {
newNums[i] = Math.max(nums[2*i], nums[2*i+1]);
}
}
return minMaxGame(newNums);
}

第二题:排序预处理#

1654438642853

题目说是子序列(即可以不连在一起),然后要求每个序列内差值尽可能满足小于k。

那很容易想到先排序,再选取即可,就是预处理的思想

可惜这里还是思考的慢了点,用了5分钟才做完

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public int partitionArray(int[] nums, int k) {
Arrays.sort(nums);
int lastNum = 0;
int result = 0;
for (int i = 0;i < nums.length;i++) {
if (i == 0) {
lastNum = nums[i];
continue;
}

if (nums[i] - lastNum > k) {
result++;
lastNum = nums[i];
}
}

result++;
return result;
}

第三题#

1654438926785

这里如果能快速理解不需要考虑相同的整数,无论如何替换都是保证数组中互不相同的话,那就非常简单了,直接使用1个map进行处理即可。

1654438971349

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public int[] arrayChange(int[] nums, int[][] operations) {
Map<Integer, Integer> numToIndexMap = new HashMap<>();
for (int i =0;i<nums.length;i++) {
numToIndexMap.put(nums[i], i);
}
for (int[] op : operations) {
int a = op[0];
int b = op[1];
if (numToIndexMap.containsKey(a)) {
int index = numToIndexMap.get(a);
numToIndexMap.remove(a);
nums[index] = b;
numToIndexMap.put(b, index);
}
}
return nums;
}

第四题:链表模拟,最优则是栈模拟#

1654439021956

想了5分钟,决定模拟一个链表, 很久没写了,写得很慢,每次还得画图。

链表模拟:#

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
class TextEditor {

static class Node {
char c;
Node last;
Node next;

public Node(char c) {
this.c = c;
}
}

Node cursorNode;

public TextEditor() {
cursorNode = new Node('|');
}

public void addText(String text) {
Node lastNode = cursorNode.last;
for (char c:text.toCharArray()) {
Node node = new Node(c);
node.last = lastNode;
node.next = cursorNode;

if (lastNode != null) {
lastNode.next = node;
}
cursorNode.last = node;
lastNode = node;
}
}

public int deleteText(int k) {
int deleCount = 0;
while (cursorNode.last != null && k-- > 0) {
Node deleNode = cursorNode.last;
Node lastNode = deleNode.last;

if (lastNode != null) {
lastNode.next = cursorNode;
}
cursorNode.last = lastNode;
deleCount++;
}
return deleCount;
}

public String cursorLeft(int k) {
while(cursorNode.last != null && k-- > 0) {
Node lastNode = cursorNode.last;
Node nextNode = cursorNode.next;

if (lastNode != null) {
lastNode.next = nextNode;
if (lastNode.last != null) {
lastNode.last.next = cursorNode;
}
cursorNode.last = lastNode.last;
cursorNode.next = lastNode;
lastNode.last = cursorNode;
}

if (nextNode != null) {
nextNode.last = lastNode;
}
}
return getLeftStr();
}

public String getLeftStr() {
Node lastNode = cursorNode.last;
int k = 10;
StringBuilder sb = new StringBuilder();
while(lastNode != null && k-- > 0) {
sb.append(lastNode.c);
lastNode = lastNode.last;
}
return sb.reverse().toString();
}

public String cursorRight(int k) {
while(cursorNode.next != null && k-- > 0) {
Node lastNode = cursorNode.last;
Node nextNode = cursorNode.next;

if (nextNode != null) {
nextNode.last = lastNode;
if (nextNode.next != null) {
nextNode.next.last = cursorNode;
}
cursorNode.next = nextNode.next;
cursorNode.last = nextNode;
nextNode.next = cursorNode;
}

if (lastNode != null) {
lastNode.next = nextNode;
}
}
return getLeftStr();
}
}

/**
* Your TextEditor object will be instantiated and called as such:
* TextEditor obj = new TextEditor();
* obj.addText(text);
* int param_2 = obj.deleteText(k);
* String param_3 = obj.cursorLeft(k);
* String param_4 = obj.cursorRight(k);
*/

关于这一题,更好的做法是用2个栈,因为始终只划分左右两边,且都是相邻移动,不会直接跳转,用2个栈是更合适的一种实现。

使用栈实现,5分钟写完:#

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
class TextEditor {

Deque<Character> leftStack = new LinkedList<>();
Deque<Character> rightStack = new LinkedList<>();

public TextEditor() {

}

public void addText(String text) {
for (char c : text.toCharArray()) {
leftStack.push(c);
}
}

public int deleteText(int k) {
int deleCount = Math.min(k, leftStack.size());
while (!leftStack.isEmpty() && k-- > 0) {
leftStack.pop();
}
return deleCount;
}

public String cursorLeft(int k) {
while (!leftStack.isEmpty() && k-->0) {
rightStack.push(leftStack.pop());
}
return getLeftStr();
}

public String getLeftStr() {
int k = 10;
StringBuilder sb = new StringBuilder();
while (!leftStack.isEmpty() && k-->0) {
sb.append(leftStack.pop());
}
String result = sb.reverse().toString();
addText(result);
return result;
}

public String cursorRight(int k) {
while (!rightStack.isEmpty() && k-->0) {
leftStack.push(rightStack.pop());
}
return getLeftStr();
}
}

/**
* Your TextEditor object will be instantiated and called as such:
* TextEditor obj = new TextEditor();
* obj.addText(text);
* int param_2 = obj.deleteText(k);
* String param_3 = obj.cursorLeft(k);
* String param_4 = obj.cursorRight(k);
*/

第 301 场周赛 - 力扣(LeetCode)

1657468677496

第四题要点:

  1. 我直接站在n(10^4)的角度去推演和动规, 却忘记了必须是连续被整除的条件。

    即使n=100, maxValue=32, 实际上大部分都是重复的数字, 连续被整除的关键点最长只有1->2->4->8->16->32, 这是最长的6个关键点, 剩下的就是从n=100中选6个放入关键点,其他数字保持一致。

  2. 因此dp关键点方案数的数组的定义不是dp[n][maxValue], 而是dp[log2(maxValue)][maxValue]

  3. 基于dp和排列组合方式, 联合计算

  4. 排列组合方式求解。

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
public static int MOD = 1000000007;

// c[n][m],数组下标就是m,n-1是固定的了
long[][] c;

// 关键点1:预处理的话,如果C(n,m)的m比较小,就用杨辉三角来预处理,记住杨辉三角特点,纵轴是n,横轴是m
public void getC(int n, int m) {
c = new long[n+1][m+1];
for (int i = 1;i<=n;i++) {
c[i][0] = 1;
// 注意这里判断
if (i <=m) {
c[i][i] = 1;
}
for (int j = 1;j<m && j < i;j++) {
c[i][j] = c[i-1][j-1] + c[i-1][j];
c[i][j] %= MOD;
}
}
}


public int idealArrays(int n, int maxValue) {
Set<Integer>[] divNums = new Set[maxValue+1];
for (int i = 0;i<=maxValue;i++) {
divNums[i]= new HashSet<>();
}

for (int value = 2;value<=maxValue;value++) {
divNums[value].add(1);
// 关键点:求除数,只需要根号(value)即可求解出来,不需要遍历整个value
for (int div = 2; div * div <= value;div++) {
if (value % div == 0) {
// 注意加上value/div
divNums[value].add(div);
divNums[value].add(value/div);
}
}
}

// 如何快速求log2?
// 关键点:x->y->z->maxValue, 最大变化长度为log2(mavValue),其他数字全是相同的
// 因此不需要以n做动态规划
int maxDivCount = 0;
int value = maxValue;
while (value > 0) {
value /= 2;
maxDivCount++;
}

long[][] dp = new long[maxDivCount+1][maxValue+1];

// 以变换点个数做数组长度进行动态规划
for (value = 0; value <=maxValue;value++) {
for (int i = 1; i <=maxDivCount;i++) {
if (i==1) {
dp[i][value] = 1;
continue;
}

for (int div : divNums[value]) {
dp[i][value] += dp[i-1][div];
dp[i][value] %= MOD;
}
}
}

// 组合数
getC(n-1, maxDivCount);

long sum = 0;
for (value = 1;value <= maxValue;value++) {
for (int divCount = 1; divCount <=maxDivCount;divCount++) {
// value作为末尾点,长度为divCount,不相同时的方案数, 乘上(n-1)里面选(divCount-1)的组合数
sum += c[n-1][divCount-1] * dp[divCount][value];
sum %= MOD;
}
}
return (int)sum;
}

第296场周赛-209名-36分钟4题
第301场周赛-808名-3题
第303场周赛-463名-4题(pair类、var特性、双指针而不是二分)
第304场周赛-897-4题(心态重要)
第305场周赛-264名-22分钟4题
第306场周赛-763名-80分钟4题
第307场周赛-1232名-3题
第308场周赛-357名-4题
第309场周赛-1345名-3题
第311场周赛-104名-4题
第85场双周赛-1322-3题
Post not found: 编程/力扣周赛记录/第86 场双周赛-930名-3题
第86场双周赛-376名-4题