突破封锁限制,ChatGPT完整注册流程

随着AI浪潮的推进,相信大家都被ChatGPT不断刷屏了,对ChatGPT有或多或少的认知。

ChatGPT颠覆了以往对人工智能的刻板认知,以前的人工智能交互场景主要是语音识别然后分词再匹配搜索对应的答案输出,而ChatGPT的基于上下文的交互分析模型以及其强大精准的输出能力,让人感受到极强的人工智能能力。若能把ChatGPT用得好,就像每个人都能拥有一个像钢铁侠的贾维斯智能助手一样。

然而由于技术的封锁,现在ChatGPT关闭了对中国的访问,导致我们一般用户难以使用ChatGPT,本文介绍如何通过特殊手段完成ChatGPT的注册及使用。

一、突破访问限制

如果直接访问ChatGPT的官网地址https://chat.openai.com,通常得到的结果是Access denied

若要能成功访问到ChatGPT官网其实很简单,方法就是翻墙。翻墙的教程可以参考我前一篇文章《》

要注意的是,翻墙后不能使用HongKong这种节点,因为这些地区同样是被列为封锁对象,要选一些如美国、英国等没有封锁的节点才可以。(有些过于主流的节点也可能用不了,因为太多人使用该节点作为跳板也容易被列为封锁对象)

另外要注意,ChatGPT还会检测浏览器环境,最简单的是使用浏览器的无痕模式,否则要清除浏览器的浏览记录、Cookie等信息。

成功访问的界面如下图:

二、注册账号

由于技术封锁,ChatGPT除了限制中国地区的用户访问以外,甚至大规模封禁中国用户申请的账号,而通常检测的手段就是判定邮箱及手机号码。

邮箱填写

以前是对邮箱限制不大的,但我最近尝试填写一个163的邮箱,发现一直注册不成功,无论怎么输密码都提示密码不符合,换了一个gmail邮箱后就成功,所以还是建议使用国外平台的邮箱,推荐用gmail的。

邮箱注册后会收到一封验证邮件,点击邮件中的验证链接就完成验证,简单填写昵称后就跳到下一步手机号码填写了。

手机号码填写

手机号码是难倒大部分用户的一环,比较直接的方式是通过万能的淘宝,购买一张国外的sim卡,但这通常需要等待几天才能到货,而且还需要每月支付月租,所以这不是我们的首选方案。

由于手机号码除了在注册过程中用到以外,后续不会再有地方用到,(ChatGPT作为一个拿来即用的工具,没有什么值得保存的信息,假如账号丢了重新再注册一个新的没有任何影响)所以我们的首选方案是通过接码平台完成手机号码注册。

接码平台

这里推荐一个主流的接码平台https://sms-activate.org/

接码平台注册

接码平台的注册过程就不多讲了,要注意的是注册用到的邮箱建议使用常用邮箱,因为接下来的操作需要充值,而我们一次消费不完这个金额,以后有需要还能继续用的。

接码平台充值

这个接码平台支持支付宝充值,而充值的最低金额有要求,以前是1美元,现在已经涨价到2美元了。

接码

我们在左侧的搜索框输入“openai”,以找到支持openai短信模板的账号,然后在展开的列表中可以选择一个我们认为价格合适的地区账号点击购物车按钮添加。

点击购物车按钮添加后可以看到号码已经添加到激活列表中,点击复制手机号码

然后在ChatGPT注册界面中输入手机号码,点击发送短信按钮。(注意复制到的手机号码是带区号的,要去掉前边的区号,然后手动选择对应的国家)

回到接码平台等待片刻后便能看到接收到的短信验证码

复制验证码填入即可完成全部的注册流程了

接下来大家便可自由的探索ChatGPT了

最后

ChatGPT(应该说AIGC)绝对是近年来各种热炒的概念里最实用的一个,之前的VR、AR、区块链、元宇宙这些都只是一股浪潮,而AI绝对是释放生产力、提高工作效率的极强利器。后续我会鼓捣一下ChatGPT的代理以免老是被禁,同时会研究AI的应用场景,后续再与大家分享,大家可以关注我的公众号,持续获知新的干货。公众号里也留了我的WX联系方式,有需要可随时交流~


爆炸级好用的一款VPN推荐

概述

之前用的某P开头的VPN,包年优惠后平均每月费用十几块,稳定性有点糟糕,今年年初不知是不是跑路了,VPN连不上,找客服也一直不响应,最后没办法只好另觅新的VPN,苦寻一番后找到现在的这个方案,12块用了四个多月了,稳定性还挺好的。难得找到便宜好用的好东西,赶紧分享给大家~

使用主要分两个步骤:

1、安装客户端

2、购买服务

一、安装客户端

推荐使用clash_for_windows(虽然名字叫这个,但也有对应mac版本的,mac用户可以放心使用),这是一个非常主流的免费开源客户端,github的stars数高达50K。下载链接 :

https://github.com/Fndroid/clash_for_windows_pkg/releases

github偶尔会被墙掉访问不了,我放了一份到百度网盘,大家有需要可以直接下载。下载链接:

https://pan.baidu.com/s/1KGv8z6kknGJX25TlXE7wFw?pwd=3w5c

提取码: 3w5c

运行后是这样的:

clash_for_windows是一个通用的客户端,本身不提供外网节点,外网节点需要从其他平台购买。

二、购买服务

能提供外网节点的平台有很多,这里推荐的是我在用的AntLink,注册地址:

https://antlink.icu/#/register?code=a3d8otVQ

(后边那串code是我的邀请码,大家可以顺手帮我攒一丢丢羊毛哈)

注册后可以根据个人喜好购买对应的服务,为防止平台跑路,大家可以先购买12元的一次性流量包试用。我个人属于轻度用户,就上上网页偶尔youtube看看视频,50G流量竟然4个月还没用完,真是万万没想到。

AntLink上本身就有详尽的教程,大家可以自行阅读。最快捷的方式是购买服务后,在“使用文档”菜单中点击“最新使用教程汇总”,然后点击“一键导入Clash”。

点击导入后会自动跳转到clash_for_windows软件,成功后会看到Proxies标签页中列出了可用的节点,可以选择“自动选择”的节点,或者选中其中一个指定的节点。

返回到General标签页中,开启“System Proxy(全局代理模式)”模式,即可开始畅游外网了~

clash_for_windows还支持很多其他模式,大家可以自行探索~

三、移动端

移动端的使用在AntLink平台就有很详细教程,在此就简单提一提。

Andorid用户

对于Android用户是比较简单的,直接使用clash_for_android即可,下载地址:

https://github.com/Kr328/ClashForAndroid/releases

当然也可以自行选择其他更习惯的客户端,如Shadowrocket等,Android用户的可选择还是很多的。

iOS用户

对于iOS用户,由于政策的限制,大家使用国内的AppStore是搜不到对应安装包的,而突破的方式就是使用国外的AppStore账号。AntLink平台直接提供了可用的AppStore账号,登录后搜索安装Shadowrocket即可。(注意切换账号时是从AppStore中点击头像切换,不要在系统“设置”中切换账号,不然一不小心就把手机的内容同步到新账号的icloud了)


最后

大家可以关注我的公众号【leapmie】,假如平台跑路了我会第一时间寻找新的优质平台推送给大家~ 另外大家在使用过程中有什么疑问,也可以通过公众号获取我的联系方式进行咨询哈~


Java八股文纯享版——篇④:数据库

注:由于互联网行业普遍使用MySQL,以下内容无特殊注明均面向MySQL

MyIsam和Innodb差异对比

MyIsam Innodb
存储文件 .frm(表定义文件)、.myd(数据文件)、.myi(索引文件) .frm(表定义文件)、.ibd(数据文件)
表锁 表锁、行锁
事务 不支持 ACID
count 专门存储的地方 扫表

性能调优

1、explain 分析执行计划

  1. Id - 第几层执行计划

  2. Select_type

    • Simple
    • Subquery
  3. Type - 依次从好到差:system,const,eq_ref,ref,fulltext,ref_or_null,
    unique_subquery,index_subquery,range,index_merge,index,ALL

    • system const的特例,仅返回一条数据的时候。
    • const 查找主键索引,返回的数据至多一条(0或者1条),属于精确查找 。
    • eq_ref 查找唯一性索引,返回的数据至多一条。属于精确查找。
    • ref 查找非唯一性索引,返回匹配某一条件的多条数据。属于精确查找、数据返回可能是多条 。
    • range 查找某个索引的部分索引,一般在where子句中使用 < 、>、in、between等关键词。只检索给定范围的行,属于范围查找 。
    • index 查找所有的索引树,比ALL要快的多,因为索引文件要比数据文件小的多。
    • ALL 不使用任何索引,进行全表扫描,性能最差。
  4. Key - 使用的索引

  5. key_len - 索引键长度

  6. Rows - 记录条数

  7. Extra - 额外细节信息

2、只取需要的列

数据列占空间,排序占空间

3、索引

3.1 索引的类型

按聚集分类

索引可以分为聚集索引、非聚集索引。

  • 一个表只有一个聚集索引(通常是主键),聚集索引直接在叶子节点上保存数据;
  • 非聚集索引的叶子节点不保存数据,只保存主键,需要通过主键再在聚集索引上查找数据,所以需要二次查询。
按实现算法分类

非聚集索引索引类型分为唯一索引、普通索引、全文索引、空间索引,实现方式分为HASH和B+TREE。其中Hash索引无法实现范围查询,只在部分场景下适用。

3.2 索引的数据结构

B+Tree

为什么不使用B-树或红黑树?

对比B-树:

  1. B+树只有叶子节点有data域,相对B-树更节省空间
  2. B+数叶子节点之间有链指针相连,遍历叶子节点即可以实现区间访问

对比红黑树:
红黑树(二叉查找树)、AVL树(平衡二叉树)都是二叉树,基本只会在内存中使用,数据库的索引涉及大量的IO操作,树的深度对IO影响很大,B树可以有多个子节点,有效降低树高度。

3.3 索引的优缺点:

优点:

查询效率高

缺点:

  • 更新索引的IO耗时:插入、重排
  • 占用存储空间

不要盲目创建索引

3.4 组合索引

尽量使用组合索引,组合索引满足最左原则,如有组合索引(A,B,C),可理解为数据库创建了对应的三个索引(A)、(A,B)、(A,B,C),所以索引A不用独立创建索引,否则浪费存储空间和影响插入、更新的效率。

从前往后依次使用生效,如果中间某个索引没有使用,那么断点前面的索引部分起作用,断点后面的索引没有起作用。比如:

  • where a=3 and b=45 and c=5 …. 这种三个索引顺序使用中间没有断点,全部发挥作用;
  • where a=3 and c=5… 这种情况下b就是断点,a发挥了效果,c没有效果
  • where b=3 and c=4… 这种情况下a就是断点,在a后面的索引都没有发挥作用,这种写法联合索引没有发挥任何效果;
  • where b=45 and a=3 and c=5 …. 这个跟第一个一样,全部发挥作用,abc只要用上了就行,跟写的顺序无关

3.5 合理索引减少排序性能耗损

多列索引是先按照第一列进行排序,然后在第一列排好序的基础上再对第二列排序,如果没有第一列的话,直接访问第二列,那第二列肯定是无序的,直接访问后面的列就用不到索引了。

简单理解即组合索引中第二个索引数据可实现自动排序(BTree数据结构会按顺序排列,所以查询时可免去排序运算)

3.6 避免索引失效

索引失效情况:

  1. like 以%开头,索引无效;当like前缀没有%,后缀有%时,索引有效。
  2. 当or左右查询字段只有一个是索引,该索引失效,只有当or左右查询字段均为索引时,才会生效
  3. 数据类型出现隐式转化。如varchar不加单引号的话可能会自动转换为int型,使索引无效,产生全表扫描
  4. 在索引字段上使用not,<>,!=。不等于操作符是永远不会用到索引的,因此对它的处理只会产生全表扫描。 优化方法: key<>0 改为 key>0 or key<0。
  5. 对索引字段进行计算操作、字段上使用函数。
  6. 当全表扫描速度比索引速度快时,mysql会使用全表扫描,此时索引失效。

4 小表驱动大表

当两个关联字段中只有一个字段有索引的时候:

Select * from A join B on A.id = B.userId where A.name like ‘陈%’
B.userId建立了索引,以A表驱动B表时,先查出A表符合的数据,然后再以表A数据匹配表B,而遍历表B时因为触发索引查询,所以匹配的效率为LogN

假设A表100条数据,B表10000条数据,查询的过程可以理解为

for 100条数据 in A {
Log(10000)
}

查询效率为 100log(10000),如果以B表驱动A表,则效率为10000log(100),显然前者性能更优。

假如使用块的嵌套循环连接的话

内存中放小表的I/O代价小于内存中放大表的 I/O代价,数据库系统实现P108,简而言之,经过化简是 B(S)+B(S)B( R)/M-1,由于加号的后面是定值,所以前面的值的话整体就是小的

in和exists的对比:
下面关于大表和小表的讨论均为A是小表

  1. 小表放到in之后的效率优于放到in之前

    例:select name from B where id in(select id in A);

    分析:上面的查询语句可以拆成 首先 select id in A; 其次 select name from B where B.id=A.id;这样的话

  2. 小表放到exists之前效率优于放到exists之后

    例:select name from A where exitsts (Select * from B where B.id=A.id);

    分析:先遍历小表A,取得A中每个值和B中的值进行比对,如果比对成功了返回true

事务

事务的并发问题

1、脏读

事务A读取了事务B更新的数据,然后B回滚操作,那么A读取到的数据是脏数据

2、不可重复读

事务 A 多次读取同一数据,事务 B 在事务A多次读取的过程中,对数据作了更新并提交,导致事务A多次读取同一数据时,结果 不一致。

3、幻读

事务A 按照一定条件进行数据读取, 期间事务B 插入了相同搜索条件的新数据,事务A再次按照原先条件进行读取时,发现了事务B 新插入的数据 称为幻读。
(注: 期间事务B 删除了符合条件的某一条数据,导致事务A 再次读取时数据少了一条,这种情况归为 不可重复读)

MySQL事务隔离级别

事务隔离级别 脏读 不可重复读 幻读
读未提交(read-uncommitted)
不可重复读(读已提交)(read-committed)
可重复读(repeatable-read)(默认)
串行化(serializable)

MVCC机制

锁机制开销较大,Mysql的可重复读模式是采用MVCC(多版本控制)机制实现,MVCC可以在大多数情况下代替行级锁,能降低其系统开销。

MVCC是通过保存数据在某个时间点的快照来实现的。不同存储引擎的MVCC实现是不同的,典型的有乐观并发控制和悲观并发控制。当我们创建表完成后,mysql会自动为每个表添加 数据版本号(最后更新数据的事务id)db_trx_id 删除版本号 db_roll_pt (数据删除的事务id) 事务id由mysql数据库自动生成,且递增。


Java八股文纯享版——篇③:JVM

JVM结构

  • 程序计数器:指向线程当前正在执行的字节码指令的地址(行号)
  • 虚拟机栈:存储线程当前运行方法的数据、指令、返回地址
  • 本地方法栈:存储线程调用本地方法(如C++代码)时的相关信息
  • 方法区:存储类信息、常量、静态变量等
  • 堆:存储对象实例等

线程结构如下图,方法区、堆是共享的

虚拟机栈结构

虚拟机栈通过栈帧存储每个方法调用的信息,包括局部变量表、操作数栈、方法出口等,一个栈帧对应一个方法。

局部变量表中对于基本类型数据直接存在栈中,如果是对象则存储引用(指针),指向堆中的实例。

GC

GC的发展

GC的作用是垃圾回收,经过多年的发展,GC有多个版本的更新,从Serial到CMS再到G1,目前最前的成果是Shenandoah和ZGC。(G1收集器是JDK7开始有实现,JDK9开始作为默认收集器)

由于GC过程会造成用户线程停顿(stop the word现象),所以GC每个版本的提升方向主要都是提高吞吐量,缩短停顿时间。

为了降低GC的性能损耗,针对实际使用过程中不同对象存活的时间不同,GC把内存划分为新生代、老年代、永久代。针对不同代分别对应不同的GC,不同代的GC频率不一样,结合内存对象数量及GC频率的需求,不同代又有不同的算法,如复制算法、标记-清理算法、标记-整理算法等。

附:

新生代、老年代、永久代

注:不同代的GC内存划分结构及算法会有差异,以下内容是针对经典结构进行描述。

为了提升GC的性能,减少GC的耗时,GC把内存划分为新生代、老年代、永久代。
其中新生代、老年代对应是堆的划分,永久代对应的是方法区。

新生代

主要是用来存放新生的对象。一般占据堆空间的1/3,由于频繁创建对象,所以新生代会频繁触发MinorGC进行垃圾回收。(基于标记-复制算法,通过空间换时间,因为大部分对象是不存活的,需要复制的存活对象不多,可以确保性能)

新生代分为Eden区、ServivorFrom、ServivorTo三个区:

  • Eden区:
    Java新对象的出生地(如果新创建的对象占用内存很大则直接分配给老年代)。当Eden区内存不够的时候就会触发一次MinorGc,对新生代区进行一次垃圾回收。

  • ServiorFrom区、ServiorTo区:

    1. 首先,把Eden和ServiorFrom区域中存活的对象复制到ServivorTo区域(如果有对象的年龄已经达到了老年的标准,则复制到老年代),同时把这些对象年龄+1。
    2. 清空Eden区和ServivorFrom中的对象。
    3. 最后,ServivorTo和ServivorFrom互换,原ServivorTo成为下一次GC时的ServivorFrom区。

老年代

老年代的对象比较稳定,所以MajorGC不会频繁执行。当老年代也满了装不下的时候,就会抛出OOM。

永久代

指内存的永久保存区域,主要存放Class和Meta(元数据)的信息。Class在被加载的时候元数据信息会放入永久区域,但是GC不会在主程序运行的时候清除永久代的信息。所以这也导致永久代的信息会随着类加载的增多而膨胀,最终导致OOM。
(注意: 在Java8中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。)

GC触发时机

简单描述:

  • MinorGC:Eden区满的时候。
  • youngGC: 发生在Eden、S0、S1区
  • MajorGC:老年代空间不足时触发。
  • FullGC:老年代空间不足、永久代不足都可能触发。

CMS与G1的区别

新生代由于每次垃圾回收时只有少量对象存活,所以采用“标记-复制”算法,通过空间换取时间;而老年代由于对象多,复制的效率低且空间代价大,只能采用“标记-清除”或“标记-整理”算法。

CMS

步骤

基于“标记-清除”算法实现:

  1. 初始标记(CMS initial mark):独占CPU,stop-the-world, 仅标记GCroots能直接关联的对象,速度比较快;
  2. 并发标记(CMS concurrent mark):可以和用户线程并发执行,通过GCRoots Tracing 标记所有可达对象;
  3. 重新标记(CMS remark):独占CPU,stop-the-world, 对并发标记阶段用户线程运行产生的垃圾对象进行标记修正,以及更新逃逸对象;
  4. 并发清理(CMS concurrent sweep):可以和用户线程并发执行,清理在重复标记中被标记为可回收的对象。

优点

  • 支持并发收集
  • 低停顿,因为CMS可以控制将耗时的两个stop-the-world操作保持与用户线程恰当的时机并发执行,并且能保证在短时间执行完成,这样就达到了近似并发的目的

缺点

  • CMS收集器对CPU资源非常敏感,因为占用了一部分CPU资源,如果在CPU资源不足的情况下应用会有明显的卡顿
  • 无法处理浮动垃圾
  • CMS清理后会产生大量的内存碎片,当有不足以提供整块连续的空间给新对象/晋升为老年代对象时又会触发FullGC

G1

G1收集器的内存结构完全区别于CMS,弱化了CMS原有的分代模型(分代可以是不连续的空间),将堆内存划分成一个个Region(1MB~32MB, 默认2048个分区),这么做的目的是在进行收集时不必在全堆范围内进行。

步骤

基于“标记-整理”算法实现:

  1. 初始标记(Initial Marking):标记一下GC Roots能直接关联到的对象,此阶段是stop-the-world操作。
  2. 根区间扫描,标记所有幸存者区间的对象引用,扫描 Survivor到老年代的引用,该阶段必须在下一次Young GC 发生前结束。
  3. 并发标记(Concurrent Marking):是从GC Roots开始堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行,该阶段可以被Young GC中断。
  4. 最终标记(Final Marking):是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,此阶段是stop-the-world操作,使用snapshot-at-the-beginning (SATB) 算法。
  5. 筛选回收(Live Data Counting and Evacuation):首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,回收没有存活对象的Region并加入可用Region队列。这个阶段也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。

优点

  • 并行与并发:G1充分发挥多核性能,使用多CPU来缩短Stop-The-world的时间
  • 分代收集:G1能够自己管理不同分代内已创建对象和新对象的收集。
  • 空间整合:G1从整体上来看是基于‘标记-整理’算法实现,从局部(相关的两块Region)上来看是基于‘复制’算法实现,这两种算法都不会产生内存空间碎片。

小结

JVM在各个分区内存占满时将触发GC,如果内存不占满在没有手动调用system.gc()的情况下即使变量设置为null也不会立刻被回收。

GC判断对象是否可回收是通过“引用标记计数法”或“可达性判断法”判断,当一个对象没有被GC Root直接或间接引用时即可以被回收。

Spring 中创建的Bean将存储于一个map结构中,所以所有Bean都是一直存活的,这也是经常JVM运行久了会有内存增长的原因,通常业务正确的时候增长到最后是处于一个稳定的状态,而方法中定义的变量都是临时变量,使用完后即会自动释放,所以较少情况需要手动指定变量为null以标记GC可回收对象。当然,在非常确定对象不再需要使用时可手动指定对象为null。

内存泄漏例子

尽量避免定义公共变量,如必要,需加倍注意内存占用大小

容器使用中会造成瞬时内存泄漏,如果到达内存临界点容易触发OOM

单例模式导致的内存泄露

单例模式,很多时候我们可以把它的生命周期与整个程序的生命周期看做差不多的,所以是一个长生命周期的对象。如果这个对象持有其他对象的引用,也很容易发生内存泄露。

HashMap使用的内存泄漏例子


只有把map变量也设置为null才能完全断开持有。

JVM类的加载机制

类的加载时机

  • 隐式加载 new 创建类的实例,
  • 显式加载:loaderClass,forName等
  • 访问类的静态变量,或者为静态变量赋值
  • 调用类的静态方法
  • 使用反射方式创建某个类或者接口对象的Class对象。
  • 初始化某个类的子类
  • 直接使用java.exe命令来运行某个主类

类的加载过程

JVM类加载机制分为五个步骤:加载、[验证、准备、解析]、初始化。

  • 加载:把class字节码文件从各个来源通过类加载器装载入内存中,来源包括.class文件、jar包中的.class文件、动态代理编译等;
  • 验证:验证加载的class文件的正确性,如错误的语法等(因为.class文件可能被人为修改,或者不同的jdk版本不能适配对应语法等);
  • 准备:给类中的静态变量分配内存空间;
  • 解析:虚拟机将常量池中的符号引用替换成直接引用(即内存地址指向)的过程。
  • 初始化:对静态变量和静态代码块执行初始化;

类加载过程只是一个类生命周期的一部分,在其前,有编译的过程,只有对源代码编译之后,才能获得能够被虚拟机加载的字节码文件;在其后还有具体的类使用过程,当使用完成之后,还会在方法区垃圾回收的过程中进行卸载。

双亲委托模型

双亲委派模式是Java1.2之后引入的,其工作原理是,如果其中一个类加载器收到了类加载的请求,它并不会自己去加载而是会将该请求委托给父类的加载器去执行,如果父类加载器还存在父类加载器,则进一步向上委托,如此递归,请求最终到达顶层的启动类加载器。如果父类能加载,则直接返回,如果父类加载不了则交由子类加载,这就是双亲委派模式。

类的加载主要ClassLoader(类加载器)负责, JVM中提供了三层ClassLoader:

  • Bootstrap classLoader(启动类加载器):主要负责加载核心的类库,由c++来写的,加载的是Javahome/jre/lib/rt.jar

  • ExtClassLoader(扩展类加载器):主要负责加载jre/lib/ext/*.jar。

  • AppClassLoader(应用类加载器):主要负责加载classPath下面的类。

执行路径如下图,当加载类时由AppClassLoader -> ExtClassLoader -> BootstrapClassLoader的方向逐级查询是否已加载过,如果加载过则不再加载;到达顶端后再由BootstrapClassLoader -> ExtClassLoader -> AppClassLoader 的方向逐级判断自己是否可以加载,如果可以则加载,如果不能则交给子类进行加载,直到没有ClassLoader可以加载时抛出ClassNotFoundException。

双亲委派模型的优势

双亲委派模型的优势

JVM的调优

内存配置参数

  • Java整个堆大小设置,Xmx 和 Xms设置为老年代存活对象的3-4倍,即FullGC之后的老年代内存占用的3-4倍
  • 永久代 PermSize和MaxPermSize设置为老年代存活对象的1.2-1.5倍。
  • 年轻代Xmn的设置为老年代存活对象的1-1.5倍。
  • 老年代的内存大小设置为老年代存活对象的2-3倍。

另:
1、Sun官方建议年轻代的大小为整个堆的3/8左右, 所以按照上述设置的方式,基本符合Sun的建议。
2、堆大小=年轻代大小+年老代大小, 即xmx=xmn+老年代大小 。 Permsize不影响堆大小。

假设FullGC后老年代相对稳定占用的内存为2G,则整体分配大概如下:

内存分析(Jmap)

Jmap命令

1
jmap -heap pid

Jmap dump命令

1
jmap -dump:format=b,file=heapdump.phrof pid

配置内存溢出时dump

1
2
-XX:+HeapDumpOnOutOfMemoryError 
-XX:HeapDumpPath=/export/home/tomcat/domains/server2/oom.hprof

内存溢出排查工具MAT

通过Mat工具可以对dump文件分析,跟踪内存溢出等情况。

线程分析(JStack)

操作系统Top

通过top命令查看CPU是否异常

在top中找到CPU占用高的pid后,可通过top -Hp 命令显示线程情况,排查是否某个线程占用CPU资源。

Jstack

用法

可结合top -Hp 找到线程号后转16位于nid匹配,jstak dump出多次的线程栈对比改线程运行情况

另外一种情况是可以分析jstak 的信息看是否有线程BLOCKING阻塞状态,判断是否有锁冲突。

jstat -gc pid

查看GC情况


[目录]《Java八股文纯享版——目录》

[上一篇]《Java八股文纯享版——篇②:并发编程》

[下一篇]《Java八股文纯享版——篇④:数据库》



Java八股文纯享版——篇②:并发编程

注:
1.笔记为个人归纳整理,尽力保证准确性,如有错误,恳请指正
2.写文不易,转载请注明出处
3.本文首发地址 https://blog.leapmie.com/archives/c02a6ed1/
4.本系列文章目录详见《Java八股文纯享版——目录》
5.文末可关注公众号,内容更精彩

Java创建线程的方法

方式一:继承Thread类的方式

继承于Thread类,重写Thread类中的run()方法,创建子类对象,调用start()方法。

1
2
3
4
5
6
7
8
9
10
11
12
public class Demo {
public static void main(String[] args) {
Thread t = new MyThread();
t.start();
}
}
class MyThread extends Thread {
@Override
public void run() {
System.out.println(“1”);
}
}

方式二:实现Runnable接口的方式

创建实现Runnable接口的类,实现run()方法,创建对象,以此对象作为参数传入Thread类的构造器中,调用Thread类的start()方法。

对比Thread优点:Thread是继承的形式,一个类只能继承一个类,所以Runnable接口实现的方式更灵活。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class RunnableDemo {
public static void main(String[] args) {
Thread t = new Thread(new MyRunnableThread());
t.start();
}
}
class MyRunnableThread implements Runnable {

@Override
public void run() {
System.out.println("abc");
}
}

方式三:实现Callable接口

实现Callable接口,传入FutureTask构造器创建对象,把FutureTask构造器对象传入Thread构造器,调用Thread类的start()方法。

FutureTask是Futrue接口的唯一的实现类,Future接口可以对具体Runnable、Callable任务的执行结果进行取消、查询是否完成、获取结果等。

对比Runnable优点:
Callable功能更强大些,实现的call()方法相比run()方法,可以返回值方法,可以抛出异常,支持泛型的返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class CallableDemo {
public static void main(String[] args) {
MyCallableThread myCallableThread = new MyCallableThread();
FutureTask<String> futureTask = new FutureTask<>(myCallableThread);
new Thread(futureTask).start();
try {
String result = futureTask.get();
System.out.println("result:" + result);
} catch (Exception e) {
e.printStackTrace();
}
}
}
class MyCallableThread implements Callable {
@Override
public String call() throws Exception {
System.out.println("callable > call");
return "hello";
}
}

锁的类型

取锁方式分类:悲观锁、乐观锁

悲观锁

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,通常用于写多的场景。
典型代表为Java中的synchronized、ReentrantLock等独占锁。

乐观锁

总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,大部分情况下不需要上锁,通常用于读多的场景。
典型代表为CAS机制,java.util.concurrent.atomic包下面的原子变量类使用CAS实现。

锁的性质分类:不可重入锁、可重入锁、共享锁、排它锁

不可重入锁

【例】无。(Java提供的都是可重入锁,不可重入锁非常容易导致死锁。)
只判断这个锁有没有被锁上,只要被锁上申请锁的线程都会被要求等待,实现简单。

可重入锁

【例】ReentrantLock、ReentrantReadWriteLock
可重入锁:不仅判断锁有没有被锁上,还会判断锁是谁锁上的,当就是自己锁上的时候,那么他依旧可以再次访问临界资源,并把加锁次数加一(计数用于正确解锁)。
Java提供的锁都是可重入锁。不可重入锁非常容易导致死锁。

共享锁

【例】ReentrantReadWriteLock
线程可以同时获取锁。ReentrantReadWriteLock对于读锁是共享的。在读多写少的情况下使用共享锁会非常高效

排它锁

【例】ReentrantLock
多线程不可同时获取的锁,与共享锁对立。与重入锁不矛盾可以是并存属性。

取锁时是否先参与排队分类:公平锁、非公平锁

公平锁

【例】ReentrantLock(boolean fair)可以配置为公平锁
线程试图获取锁时,先按尝试获取锁的时间顺序排队

非公平锁

【例】ReentrantLock默认是非公平锁
线程试图获取锁时,如果当前锁没有线程占有,则跟排队获取锁的线程一起竞争锁而无序按顺序排队,则为非公平锁。如果竞选失败,依然要排队。

根据锁的状态划分:偏向锁、轻量级锁、重量级锁

偏向锁

一段同步代码一直被一个线程所访问,那么该线程会自动获取锁

轻量级锁

当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能

重量级锁

当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低

根据锁粒度划分:分段锁等

分段锁

【例】jdk8的ConcurrentHashMap实现方式
分段锁是一种锁思想,对数据分段加锁已提高并发效率,比如jdk8之前的ConcurrentHashMap,jdk8后采用CAS+synchronized。当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在哪一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。

常用的并发方案

Lock

1
2
3
4
Lock lock = new XxxLock();
lock.lock();
// work
lock.unlock();

Synchronized

使用方便,常用的解决方案,支持方法、静态方法、代码块的锁

1. 修饰实例方法(修饰静态方法一样用法)

1
2
3
public class Demo {
public synchronized void methodOne() { }
}

2.修饰代码块
有时如果直接对整个方法进行同步操作,可能会得不偿失,此时我们可以使用同步代码块的方法对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了。
我们可以使用如下几种对象来作为锁的对象:

1)成员锁(锁的对象是变量)

1
2
3
4
5
public Object synMethod(Object a1) {
synchronized(a1) {
// 操作
}
}

2)成员锁(锁的对象是变量)

1
2
3
4
5
synchronized(this) { 
for (int j = 0; j < 100; j++) {
i++;
}
}

3)当前类的 class 对象锁

1
2
3
4
5
synchronized(AccountingSync.class) {
for (int j = 0; j < 100; j++) {
i++;
}
}

实现原理:

在 Java 中,每个对象都会有一个 monitor 对象,使用synchronized关键字在编译后是在方法前增加monitor.enter指令,在方法退出和异常处插入monitor.exit指令,通过对一个对象监视器(monitor)进行获取从而达到只能一个线程访问。

在 Java 中,针对每个类也有一个锁,可以称为“类锁”,所以每个类只有一个类锁,所以synchronized关键字可以传入class,对于静态方法,由于此时对象还未生成,所以只能采用类锁。

底层进阶:
操作系统本身并不支持 monitor 机制,monitor机制是由编程语言实现,java的monitor由JVM实现。操作系统中semaphore 信号量 和 mutex 互斥量是最重要的同步原语,monitor是对semaphore 和mutex 的进一步封装,提供简洁易用的接口。

Volatile

volatile 只能保证可见性而不能保证原子性(准确的说是不能保证复合操作的原子性),要非常小心的使用才能确保线程安全,通常在某些特定场合下使用,如双重检查锁模式(常用于单例模式或延迟赋值的场景)

(volatile可使线程每次读取变量时都从主内存中读取,保证变量对于各线程的可见性,但是对于多CPU的计算机,CPU中有一层高速缓存——寄存器,volatile 不能保证其它 cpu 的缓存同步刷新,因此无法保证原子性。)

双重检查锁模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Singleton {
private volatile static Singleton uniqueSingleton; // 1. 为变量添加volatile修饰符

private Singleton() {
}

public static Singleton getInstance() {
if (null == uniqueSingleton) { //2. 第一重检查
synchronized (Singleton.class) { // 3. synchronized加锁
if (null == uniqueSingleton) { // 4. 第二重检查
uniqueSingleton = new Singleton();
}
}
}
return uniqueSingleton;
}
}

CAS (Compare And Set)

java.util.concurrent.automic包中实现

CAS是最常见的乐观锁之一,应用于小概率需要锁资源的场景,常用于高并发的“查询并修改”的场景,通过“先查询判断再更新”的方式保证数据一致性。
场景例:
现有变量余额money=100,线程1需要扣款20元,线程2需要扣款30元,执行顺序如图:

业务上最终应该money=50,但由于并发问题最终money=70,在CAS方案中,在修改前需要先进行判断,对应思路的伪代码如下

1
2
3
4
Get money = 100
If(money == 100) {
Set money = money -100
}

显然实际使用中并不能如上简单实现,因为以上操作并非原子操作,实际的Java代码需要调用Unsafe类的compareAndSwap系列方法,如compareAndSwapInt。该方法实际通过JNI调用底层C语言实现,最终CPU指令 cmpxchg,通过CPU实现原子性及“查询并修改”。

compareAndSet只会返回成功或失败,CAS的常规使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
public final int incrementAndGet() {
while(true) {
//获取当前值
int current = get();
//设置期望值
int next = current + 1;
//调用Native方法compareAndSet,执行CAS操作
if (compareAndSet(current, next))
//成功后才会返回期望值,否则无线循环
return next;
}
}

从底层来说CAS也是有排他锁,但是相对synchronized的排他时间短非常短,在多线程情况下性能会比较好。

CAS使用注意事项:
CAS当需要等待获取锁时会自旋等待(即while true),非常消耗CPU资源,所以避免在高频取锁的场景中使用CAS。

ReentrantLock底层原理AQS

ReentrantLock通过AQS(AbstractQueuedSynchronizer)实现。AQS底层是通过CLH双向队列实现。

AQS定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如常用的ReentrantLock/Semaphore/CountDownLatch。

它维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。

AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。

不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:

  • isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
  • tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
  • tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
  • tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
  • tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。

以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。

ThreadLocal

通过为每个线程提供一个独立的变量副本解决了变量并发访问的冲突问题。最常见的ThreadLocal使用场景为用来解决数据库连接、Session管理等。

内部结构

ThreadLocal提供四个方法

  1. public T get() { }
  2. public void set(T value) { }
  3. public void remove() { }
  4. protected T initialValue(){ }

内部是通过ThreadLocalMap实现

使用注意事项

1)脏数据

线程复用会造成脏数据。由于线程池会复用Thread对象,因此Thread类的成员变量threadLocals也会被复用。如果在线程的run()方法中不显式调用remove()清理与线程相关的ThreadLocal信息,并且下一个线程不调用set()设置初始值,就可能get()到上个线程设置的值

2)内存泄露

ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用来引用它,那么系统 GC 的时候,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永远无法回收,造成内存泄漏

大白话一点,ThreadLocalMap的key是弱引用,GC时会被回收掉,那么就有可能存在ThreadLocalMap的情况,这个Object就是泄露的对象。

其实,ThreadLocalMap的设计中已经考虑到这种情况,也加上了一些防护措施:在ThreadLocal的get(),set(),remove()的时候都会清除线程ThreadLocalMap里所有key为null的value。

解决办法

解决以上两个问题的办法很简单,就是在每次用完ThreadLocal后,及时调用remove()方法清理即可。

Java内存模型(JMM)

JMM(Java Memory Model)是一个抽象的概念,JMM是和多线程相关的,定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

Java内存模型中分为主内存和各线程的本地内存,共享变量存储于主内存中,各线程操作共享变量前先把变量复制一份副本到本地内存,线程对变量副本运算完后再刷新到主内存。

JMM三大特性:

  1. 可见性
  2. 原子性
  3. 有序性

JMM关于同步的规定:

  1. 线程解锁前,必须把共享变量的值刷新回主内存;
  2. 线程加锁前,必须读取主内存的最新值到自己的工作内存;
  3. 加锁解锁是同一把锁;

(区别概念“JVM内存结构”,JVM内存结构描述的是Java程序执行过程中,由JVM管理的不同数据区域,如虚拟机栈、Java堆,本地方法栈等, 各个区域有其特定的功能。)

如何保证变量的可见性

主要实现可见性的方式有三种:

  • volatile,注意一点 volatile不能保证操作的原子性
  • Synchronized,synchronized互斥锁对应的内存间交互操作为lock和unlock。在对一个变量进行unlock操作之前,必须把变量值同步回主内存。
  • final,被final关键字修饰的变量在构造器中一旦初始化完成,并且没有发生 this 逃逸(其他线程通过this引用访问到初始化了一半的对象),那么其他线程就能看见final字段的值。

内存屏障

大多数现代计算机为了提高性能而采取乱序执行,内存屏障是一个指令级别的同步点,有内存屏障的地方,会禁止指令重排序。

语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。

内存屏障用于解决可见性及有序性问题,内存屏障通过防止指令重排保证有序性,通过内存屏障前后的刷新主存保证可见性(可见安全)。

Volatile、Lock、synchronized、final都是通过内存屏障实现。

  • lock:解锁时,jvm会强制刷新cpu缓存,导致当前线程更改,对其他线程可见。
  • volatile:标记volatile的字段,每次读取都是直接读内存。
  • final:即时编译器在final写操作后,会插入内存屏障,来禁止重排序,保证可见性

Happen-Before原则

happen before的含义是指操作对后续的操作都是可见的,比如 A happen before B 的意思并不是说 A 操作发生在 B 操作之前,而是说 A 操作对于 B 操作一定是可见的。

happen before原则是JMM中重要的一个原则,主要用于明确有序性。

Java的happen before原则规定了八种规则,以明确有序性的满足条件,相反在这八种规则以外意味着不能确定其执行顺序。八种规则如下:

  • 程序次序规则:在一个线程内,按照代码执行,书写在前面的操作先行发生于书写在后面的操作。
  • 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
  • 线程启动原则:Thread对象的start()方法先行发生于此线程的每一个动作
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()方法返回值手段检测到线程已经终止执行
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

如对于一段代码添加了synchronized字段,各个线程在执行这段代码时需要获取锁,那么符合”管理锁定规则”,可以确保其执行顺序,所以代码段中的变量可以不添加volatile关键字。

线程池

创建线程池

Java中已经提供了创建线程池的一个类:Executor,我们创建时,一般使用它的子类:ThreadPoolExecutor.

1
2
3
4
5
6
7
public ThreadPoolExecutor(int corePoolSize,  
                              int maximumPoolSize,  
                              long keepAliveTime,  
                              TimeUnit unit,  
                              BlockingQueue<Runnable> workQueue,  
                              ThreadFactory threadFactory,  
                              RejectedExecutionHandler handler)

  • corePoolSize就是线程池中的核心线程数量,这几个核心线程,即使在没有用的时候,也不会被回收。

  • maximumPoolSize就是线程池中可以容纳的最大线程的数量。
    很多人以为它的作用是这样的:”当线程池中的任务数超过 corePoolSize 后,线程池会继续创建线程,直到线程池中的线程数小于maximumPoolSize“,其实这种理解是完全错误的。它真正的作用是:当线程池中的线程数等于 corePoolSize 并且 workQueue 已满,这时就要看当前线程数是否大于 maximumPoolSize,如果小于maximumPoolSize 定义的值,则会继续创建线程去执行任务, 否则将会调用相应的任务拒绝策略来拒绝这个任务。

  • keepAliveTime,就是线程池中除了核心线程之外的其他的最长可以保留的时间。
    除了核心线程即使在无任务的情况下也不能被清除,其余的都是有存活时间的,keepAliveTime意思就是非核心线程可以保留的最长的空闲时间。
    当ThreadPoolExecutor的allowCoreThreadTimeOut属性设置为true时,keepAliveTime同样会作用于非核心线程。

  • util,就是计算这个时间的一个单位,共7种取值:

    • TimeUnit.DAYS; //天
    • TimeUnit.HOURS; //小时
    • TimeUnit.MINUTES; //分钟
    • TimeUnit.SECONDS; //秒
    • TimeUnit.MILLISECONDS; //毫秒
    • TimeUnit.MICROSECONDS; //微妙
    • TimeUnit.NANOSECONDS; //纳秒
  • workQueue,就是等待队列,任务可以储存在任务队列中等待被执行,执行的是FIFIO原则(先进先出)。

    • ArrayBlockingQueue   //基于数组的先进先出队列,此队列创建时必须指定大小;
    • LinkedBlockingQueue //基于链表的先进先出队列,如果创建时没有指定此队列大小,则默认为Integer.MAX_VALUE;
    • synchronousQueue  //这个队列比较特殊,它不会保存提交的任务,而是将直接新建一个线程来执行新来的任务。
  • threadFactory,线程工厂,用来为线程池创建线程,当我们不指定线程工厂时,线程池内部会调用 Executors.defaultThreadFactory()创建默认的线程工厂,其后续创建的线程优先级都是 Thread.NORM_PRIORITY。如果我们指定线程工厂,我们可以对产生的线程进行一定的操作。

  • rejectHandler,拒绝执行策略

    • ThreadPoolExecutor.AbortPolicy: // 丢弃任务并抛出RejectedExecutionException异常。
    • ThreadPoolExecutor.DiscardPolicy: // 也是丢弃任务,但是不抛出异常。
    • ThreadPoolExecutor.DiscardOldestPolicy:// 丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)
    • ThreadPoolExecutor.CallerRunsPolicy:// 由调用线程处理该任务

线程池的创建方式

java.util.concurrent包下的Executors提供四种线程池:

  • NewCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
  • NewFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
  • NewScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。
  • NewSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

《阿里巴巴Java开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 new ThreadPoolExecutor 实例的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。Executors 返回线程池对象的弊端如下:

  • FixedThreadPool 和 SingleThreadExecutor : 允许请求的队列长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致OOM。
  • CachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致OOM。

怎么设置CPU最佳线程数

最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目

线程等待时间(非CPU运行时间,比如IO)所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。

例如proxy代理应用的线程数量可以开到很大,因为本身不占用太多CPU运算。
例如解码等应用的线程数量只能与CPU核数相近,因为解码需要大量CPU运算。

线程状态

  • 新建状态(New): 线程对象被创建后,就进入了新建状态。例如,Thread thread = new Thread()。
  • 就绪状态(Runnable): 也被称为“可执行状态”。线程对象被创建后,其它线程调用了该对象的start()方法,从而来启动该线程。例如,thread.start()。处于就绪状态的线程,随时可能被CPU调度执行。
  • 运行状态(Running): 线程获取CPU权限进行执行。需要注意的是,线程只能从就绪状态进入到运行状态。
  • 阻塞状态(Blocked): 阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。阻塞的情况分三种:
  • 等待阻塞 – 通过调用线程的wait()方法,让线程等待某工作的完成。
  • 同步阻塞 – 线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态。
  • 其他阻塞 – 通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。
  • 死亡状态(Dead): 线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

yield()方法

yield()方法只是提出申请释放CPU资源,至于能否成功释放由JVM决定。由于这个特性,一般编程中用不到此方法,但在很多并发工具包中,yield()方法被使用,如AQS、ConcurrentHashMap、FutureTask等。

join()方法

thread.Join把指定的线程加入到当前线程,可以将两个交替执行的线程合并为顺序执行的线程。
比如在线程B中调用了线程A的Join()方法,直到线程A执行完毕后,才会继续执行线程B。
例如在main中调用线程t的t.join,则会等待t方法执行完后再执行main的方法。

1
2
t.join();      // 调用join方法,等待线程t执行完毕
t.join(1000);  // 等待 t 线程,等待时间是1000毫秒。

notify/notifyAll()与wait()

wait()和notify()都是定义在Object类中,为什么如此设计。因为synchronized中的这把锁可以是任意对象,所以任意对象都可以调用wait()和notify(),并且只有同一把锁才能对线程进行操作,不同锁之间是不可以相互操作的,所以wait和notify属于Object。

wait、notify要放在sychronized同步块中,否则会抛出IllegalMonitorStateException。如果不在同步块中,调用this.wait()时当前线程都没有取得对象的锁,又谈何让对象通知线程释放锁、或者来竞争锁呢?如果确实不放到同步块中,则会产生 Lost-wake的问题,即丢失唤醒。

调用wait方法可以让当前线程进入等待唤醒状态,该线程会处于等待唤醒状态直到另一个线程调用了object对象的notify方法或者notifyAll方法。

notify()唤醒等待的线程,如果监视器种只有一个等待线程,使用notify()可以唤醒。但是如果有多条线程notify()是随机唤醒其中一条线程,与之对应的就是notifyAll()就是唤醒所有等待的线程。

线程间通信的几种方式

方式一:使用 volatile 关键字

大致意思就是多个线程同时监听一个变量,当这个变量发生变化的时候 ,线程能够感知并执行相应的业务。

方式二:使用Object类的wait() 和 notify() 方法
Object类提供了线程间通信的方法:wait()、notify()、notifyaAl(),它们是多线程通信的基础,而这种实现方式的思想自然是线程间通信。(注意必须作用于synchronized中。可以简单理解lock不能锁对象,而wait、notify是对象的方法,所以是要配合synchronized使用)

方式三:使用JUC工具类 CountDownLatch
CountDownLatch***基于AQS框架,相当于也是维护了一个线程间共享变量state

方式四:使用 ReentrantLock 结合 Condition
显然这种方式使用起来并不是很好,代码编写复杂,而且线程B在被A唤醒之后由于没有获取锁还是不能立即执行,也就是说,A在唤醒操作之后,并不释放锁。这种方法跟 Object 的 wait() 和 notify() 一样。

方式五:基本LockSupport实现线程间的阻塞和唤醒
LockSupport 是一种非常灵活的实现线程间阻塞和唤醒的工具,使用它不用关注是等待线程先进行还是唤醒线程先运行,但是得知道线程的名字。

如何控制多线程执行顺序

方式一 join方法

1
2
3
4
5
6
7
public static void main(String[] args) {
thread1.start();
thread1.join();
thred2.start();
thread2.join();
thread3.start();
}

join方法的底层是调用对象的wait方法,wait方法的意思是阻塞当前线程,而此处的当前线程并非指thread1子线程本身,而是调用thread1.join()的主线程。所以当在主线程调用thread1.join()时,主线程阻塞,等待thread1执行完毕后继续执行thread2的任务,实现顺序执行。

拓展:注意join方法是使调用者当前的线程阻塞,所以可以实现线程嵌套,如先创建threadA,然后在threadA中再运行threadB并调用threadB.join()方法时,是阻塞threadA,让threadB执行完毕。

方式二 Excutors.newSingleThreadExecutor()

1
2
3
4
ExecutorService executor = Excutors.newSingleThreadExecutor();
executor.execute(thread1);
executor.execute(thread2);
executor.execute(thread3);

newSingleThreadExecutor是单线程运行无限队列的线程池,所以每个时间段只有一个线程可以运行,而后续加入的线程将进入队列排队,从而实现顺序执行。

并行与并发的区别

理解一
并发是对需求侧的描述,并行才是对实现侧的描述,这两根本不是同一个范畴的东西,更不可能是互斥的关系。

举个栗子:

每天中午12:00一大波人来到食堂门口,这是并发访问(需求场景)。

然后食堂开了12个打菜窗口给来吃饭的人打菜,这是并行处理(实现方式)。

即使开了12个窗口,也不能同时满足几千人,所以大家要排队(实现方式)。

所以正确的描述上述场景的句子应该是:“食堂每天中午会收到大量并发访问的请求,于是食堂通过开12个窗口的方式并行地处理这些请求,即便如此,仍然无法同时满足所有的请求,所以食堂仍然要求大家排队等待”。

你看,不管是并发,还是并行,还是排队,在上述场景里是同时存在的,其实并不互斥。

理解二

并行与并发不是一个维度的概念,并行是指多个节点能同时进行的能力或场景,并发是指在一个节点中同时发生的场景。

例如在互联网架构中,一个服务可以部署多个节点的集群,同一时刻每个服务都在处理任务,这是并行的状态。如果有大量请求落到一个节点中,则该节点会出现并发场景。

协程与线程的区别

线程

线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。

协程

协程是一种用户态的轻量级线程,协程的调度完全由用户控制,一个进程可轻松创建数十万计的协程。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
(在python的爬虫、go语言中使用协程的频率较高)


[目录]《Java八股文纯享版——目录》

[上一篇]《Java八股文纯享版——篇①:Java基础》

[下一篇]《Java八股文纯享版——篇③:JVM》



Java八股文纯享版——篇①:Java基础

注:
1.笔记为个人归纳整理,尽力保证准确性,如有错误,恳请指正
2.写文不易,转载请注明出处
3.本文首发地址 https://blog.leapmie.com/archives/b8fe0da9/
4.本系列文章目录详见《Java八股文纯享版——目录》
5.文末可关注公众号,内容更精彩

JDK8对比JDK7的差别

1.HashMap的实现差别

2.支持Lambda表达式语法(如创建线程,对于接口只有一个方法需要重写的类可以用lambda方式简洁创建对象)

3.支持Stream流操作。Stream提供一种对 Java 集合的流式操作,比如filter, map, reduce, find, match, sorted等。创建Stream有两种方式:stream() 创建串行流、parallelStream() 创建可以并行计算的并行流。

1
2
3
4
5
6
7
List<String> stringList = Arrays.asList("abc", "", "bc", "efg", "abcd","", "jkl"); 
List<String> filtered = stringList .parallelStream()
.filter(string > !string.isEmpty()) //过滤
.map(i -> i*i) // 映射
.sorted() // 排序
.limit(10) // 分页
.collect(Collectors.toList()); // 返回结果集

4.接口支持默认方法(如果实现多个接口同时都定义了相同的默认方法,则实现类必须重写该方法)

1
2
3
4
5
6
7
8
9
10
11
12
public interface Interface1{
default void helloWorld() {
System.out.println("hi i'm from Interface1");
}
}

public class MyImplement implements Interface1{
public static void main(String[] args) {
MyImplement myImplement = new MyImplement();
myImplement.helloWorld();
}
}

HashMap结构

Jdk7的实现

数组+链表组成,数组是HashMap的主体,链表用于解决Hash冲突。

Jdk8的实现

数组+红黑树。JDK8中当HashMap链表长度大于8的时候,改为红黑树结构,解决链表过长的问题,当小于6时会转换回链表。

转换阈值为什么是8

Java源码的贡献者在进行大量实验分析,hashcode碰撞次数符合泊松分布,在负载因子0.75(HashMap默认值)的情况下,单个hash槽内元素个数为8的概率为0.00000006,概率小于百万分之一,所以发生红黑树转换的情况其实并不多,设置为8可以大幅减少转换的代价。

从红黑树转换为链表的阈值为6,是为了避免元素数量在临界点来回变化导致的结构频繁转换。

以下为源码注释中的概率说明:

0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006

为什么是红黑树而不是其他树?

普通二叉树可能会出现单边长度过长的问题,红黑树属于平衡二叉树,保证树的合理高度,而相比AVL平衡二叉树具备更好的插入、删除效率。(红黑树允许局部少量的不完全平衡,这样对于效率影响不大,但省去了很多没有必要的调平衡操作,avl树调平衡有时候代价较大,所以效率不如红黑树)。

HashMap的扩容机制

当HashMap中的元素越来越多的时候,碰撞的几率也就越来越高,为了提高查询的效率,就要对HashMap的数组进行扩容(resize)。
当hashmap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75。
扩容的大小为原数组长度的一倍。

ConcurrentHashmap实现原理

Jdk7的实现

HashTable是一个线程安全的类,它使用synchronized来锁住整张Hash表来实现线程安全,性能低下。
ConcurrentHashMap内部分为很多个Segment,每一个Segment拥有一把锁,每个段相当于一个小的Hashtable。当一个线程占用锁访问其中一个数据段时不影响其他段的访问,提高并发效率。

Jdk8的实现

table数组+单向链表+红黑树的结构

jdk8中取消segments字段,直接采用transient volatile HashEntry<K,V>[] table 保存数据,采用 table 数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率,代替原来的每一段加锁。
因为段的隔离级别不太容易确定,默认是16,但是很多情况下并不合适,如果太大很多空间就浪费了,如果太小每个段中可能元素过于多,所以取消segments,改成了CAS算法

ArrayList与LinkedArrayList的区别

  • Array(动态数组)的数据结构,一个是Link(链表)的数据结构
  • 当随机访问List时(get和set操作),ArrayList比LinkedList的效率更高
  • 当对数据进行增加和删除的操作时(add和remove操作),LinkedList比ArrayList的效率更高

List的安全实现

ArrayList不是线程安全的,有以下几种方案实List的现线程安全:

1. Vector类

Vector实现方式比较笨重,add等每个方法使用Synchronized修饰

1
2
3
4
5
6
7
8
Vector v = new Vector(3, 2);
v.addElement(new Integer(1));
v.addElement(new Integer(2));
Enumeration en=v.elements();
while(en.hasMoreElements()){
Object object=en.nextElement();
System.out.println(object);
}

2. Collections.synchronizedList

Collections.synchronizedList(List() list),内部使用同步代码块的方式实现同步,用SynchronizedCollection这个静态内部类作为锁。

1
List<String> list = Collections.synchronizedList(new ArrayList<>());

3.CopyOnWriteArrayList

1
2
3
4
5
6
7
8
List<String> list =new CopyOnWriteArrayList<String>();
list.add("1");
list.add("2");
Iterator<String> iter = list.iterator();
while(iter.hasNext()){
String o = iter.next();
    System.out.println(o);
}

内部在add等方法通过ReentrantLock加锁实现。

缺点:
1.因为CopyOnWrite的写是复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象。
2.CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。

Java的异常类别

异常分为Error和exception,其中exception分为CheckedException和RuntimeException

Error

error表示系统级的错误,是java运行环境内部错误或硬件问题,由Java虚拟机抛出,除了退出运行别无选择,如OOM(OutOfMemoryError)。

CheckedException(检查异常)

检查异常主要是指IO异常、SQL异常等。对于这种异常,JVM要求我们必须对其进行catch处理,如FileNotFoundException。

RuntimeException(运行时异常)

运行时异常一般不处理,比如NullPointerException,对于运行时异常,程序会将异常一直向上抛,一直抛到处理代码,如果没有catch块进行处理,到了最上层,如果是多线程就有Thread.run()抛出,如果不是多线程就由main.run抛出,抛出异常后线程终止。

Iterator

如有ArrayList a,内容为[“a”,”b”,”c”,”d”]

在for 循环里遍历List,删除元素会怎样?

1
2
3
4
5
6
7
for (int i = 0; i < a.size(); i++) {
if (i == 1) {
a.remove(i);
} else {
System.out.println(i + a.get(i));
}
}

最终输出0a,2d,因为元素b被删除,然后c往前移位对应i=1,所以c也被跳过输出。

在iterator 循环里遍历List,删除元素会怎样?

1
2
3
4
5
6
7
8
9
Iterator<String> iterator = a.iterator();
while (iterator.hasNext()) {
String s = iterator.next();
if ("b".equals(s)) {
a.remove(1);
} else {
System.out.println(s);
}
}

抛出异常ConcurrentModificationException,要避免抛异常应该使用iterator.remove()进行删除。

Iterator实现原理

Iterator的实现中主要有几个变量cursor,lastRest, expectedModCount三个变量,其中cursor将记录下一个位置,lastRet记录当前位置,expectedModCount记录没有修改的List的版本号。

ArrayList作了添加或删除操作都会增加modCount版本号,这样的意思是在迭代期间,会不断检查modCount和迭代器持有的expectedModCount两者是不是相等,如果不想等就抛出异常了

Java的继承有什么缺点

  1. 父类向子类暴露了实现细节
  2. 父类更改之后子类也要同时更改
  3. 子类覆盖了一些方法,可能会导致其他调用了该方法的方法错误

包装类

《阿里巴巴Java手册》规定如下

【强制】所有整型包装类对象之间值的比较,全部使用 equals 方法比较。
说明:对于 Integer var = ? 在-128 至 127 范围内的赋值,Integer 对象是在 IntegerCache.cache 产 生,会复用已有对象,这个区间内的 Integer 值可以直接使用==进行判断,但是这个区间之外的所有数 据,都会在堆上产生,并不会复用已有对象,这是一个大坑,推荐使用 equals 方法进行判断。

对于以下语句:

1
2
3
4
Integer i01 = 59;
int i02 = 59;
Integer i03 =Integer.valueOf(59);
Integer i04 = new Integer(59);

以下输出结果为false的是:
A System.out.println(i01 == i02);
B System.out.println(i01 == i03);
C System.out.println(i03 == i04);
D System.out.println(i02 == i04);

答案为C

JVM中一个字节以下的整型数据会在JVM启动的时候加载进内存,除非用new Integer()显式的创建对象,否则都是同一个对象
所以只有i04是一个新对象,其他都是同一个对象。所有A,B选项为true
C选项i03和i04是两个不同的对象,返回false
D选项i02是基本数据类型,会触发i04自动拆箱,比较的时候比较的是数值,返回true

重写hashCode方法

为什么重写equals方法要重写hashCode方法?

当equals方法被重写时,通常有必要重写hashCode方法,以维护hashCode方法的常规约定:值相同的对象必须有相同的hashCode。

  • hashCode不同时,object1.equals(object2)为false;
  • hashCode相同时,object1.equals(object2)不一定为true

因为hashCode效率更高(仅为一个int值),比较起来更快,对于HashMap等很多结构是先通过对象的hashCode方法判断是否一致,然后再继续操作。

例如类Person中有属性name、idcard等字段,如果重写equals方法希望通过name、idcard字段值一致则代表该对象相等,必须同时重写hashCode方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Person { 
String name;
String idcard;
String sex;

@Override
public int hashCode() {
int result = 17;  //任意素数  
// 31 有个很好的性能,即用移位和减法来代替乘法,通常*31
result = 31*result +name.hashCode(); 
result = 31*result +idcard.hashCode();  
return result;
}

摘自《Effective Java》中关于重写hashCode方法的习惯步骤如下:

“之所以选择31,是因为它是一个奇素数。如果乘数是偶数,并且乘法溢出的话,信息就会丢失,因为与2相乘等价于位移运算。使用素数的好处并不很明显,但是习惯上都使用素数来计算三列结果。31有个很好的特性,即用移位和减法来代替乘法,可以得到更好的性能:31 * i 等于 (i << 5) - i”。

对象引用类型及回收时机

从JDK 1.2版本开始,把对象的引用分为4种级别,从而使程序能更加灵活地控制对象的生命周期。这4种级别由高到低依次为:强引用、软引用、弱引用和虚引用。

(1)强引用(StrongReference)
强引用是我们使用的最广泛,也是最普遍的一种引用类型。即

1
A a = new A();

只要某个对象有强引用与之关联,JVM必定不会回收这个对象,即使在内存不足的情况下,JVM宁愿抛出OutOfMemory错误也不会回收这种对象。

如果想中断强引用和某个对象之间的关联,可以显示地将引用赋值为null,这样一来的话,JVM在合适的时间就会回收该对象。

⑵软引用(SoftReference)

软引用是用来描述一些有用但并不是必需的对象,在Java中用java.lang.ref.SoftReference类来表示。

1
软引用是用来描述一些有用但并不是必需的对象,在Java中用java.lang.ref.SoftReference类来表示。

软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被JVM回收,这个软引用就会被加入到与之关联的引用队列中。

对于软引用关联着的对象,只有在内存不足的时候JVM才会回收该对象。因此,这一点可以很好地用来解决OOM的问题,并且这个特性很适合用来实现缓存:比如网页缓存、图片缓存等。

⑶弱引用(WeakReference)
弱引用也是用来描述非必需对象的,当JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。
在java中,用java.lang.ref.WeakReference类来表示。

1
WeakReference<String> sr = new WeakReference<String>(new String("aaa"));

不过要注意的是,这里所说的被弱引用关联的对象是指只有弱引用与之关联,如果存在强引用同时与之关联,则进行垃圾回收时也不会回收该对象(软引用也是如此)。弱引用也可以和一个引用队列(ReferenceQueue)联合使用。

⑷虚引用(PhantomReference)
如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。在java中用java.lang.ref.PhantomReference类表示。

1
2
ReferenceQueue<String> queue = new ReferenceQueue<String>();
PhantomReference<String> pr = new PhantomReference<String>(new String("aaa"), queue);

虚引用必须和引用队列关联使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会把这个虚引用加入到与之关联的引用队列中。


[目录]《Java八股文纯享版——目录》

[下一篇]《Java八股文纯享版——篇②:并发编程》



Java八股文纯享版——目录

Java并发编程——为什么要用volatile关键字

日常编程中出现 volatile 关键字的频率并不高,大家可能对 volatile 关键字比较陌生,再深入一点也许是听闻 volatile 只能保证可见性而不能保证原子性,无法有效保证线程安全,于是更加避免使用 volatile ,简简单单加上synchronize关键字就完事了。本文稍微深入探讨 volatile 关键字,分析其作用及对应的使用场景。

并发编程的几个概念简述

首先简单介绍几个与并发编程相关的概念:

  • 可见性

    可见性是指变量在线程之间是否可见,JVM 中默认情况下线程之间不具备可见性。

  • 原子性

    对于 a = 0 操作是属于原子操作,但 a = a + 1 则不是原子操作,因为这里涉及到要先读取原来 a 的值,然后再为 a 加 1 ,当涉及多线程同时执行该语句时,会出现值不稳定的情况,所以非原子操作在并发场景下是不安全的。

  • 有序性

    java 内存模型中允许编译器和处理器进行指令重排优化,重排过程中不会影响单个线程的指令执行顺序,但会影响多线程环境中的运行正确性

  • 指令重排

    在多核 CPU 的情况下,为了充分利用时间片,提高指令执行效率,处理器会根据一定规则对指令进行重排序,由于规则的限定,指令重排后理论上最终运行结果不变。

volatile 的主要作用

volatile 的主要作用是实现可见性禁止指令重排

  1. 实现可见性

    在 JVM 内存模型中内存分为主内存和工作内存,各线程有独自的工作内存,对于要操作的数据会从主内存拷贝一份到工作内存中,默认情况下工作内存是相互独立的,也就是线程之间不可见,而 volatile 最重要的作用之一就是使变量实现可见性。

  2. 禁止指令重排

    虽然指令重排理论上不会影响执行结果的正确性,但指令重排只能保证底层的机器语言重排序后结果正确,而对于Java高级语言,所以在没有干预的情况下并不能确保每条语句在编译对应的指令重排后与期望的执行效果一致。

对于以下示例,由于 ready 没有指定 volatile ,当变量 ready 线程间不可见时,可能导致线程中读不到 ready 的新值,无法停止循环;如果指令重排序,可能在线程执行前变量 ready 已赋值为 true ,导致线程内容不打印。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
@Override
public void run() {
while(!ready) {
Thread.yield();
}
System.out.println("1");
}
}
public static void main(String[] args) {
new ReaderThread().start();
ready = true;
}
}

为什么volatile不能保证线程安全?

想要线程安全必须保证原子性,可见性,有序性,而 volatile 只能保证可见性和有序性。

volatile 字段主要是让线程从主内存中获取值从而保证可见性,但是CPU中还有一层高速缓存——寄存器,对于非原子性操作,在底层指令运算中还是会出现数据缓存导致运算结果不正确的情况,从而无法保证线程安全。
简单来说,volatile 在多 cpu 环境下不能保证其它 cpu 的缓存同步刷新,因此无法保证原子性。

为什么不直接用synchronized

synchronized 可保证原子性、可见性、有序性,能有效保证线程安全,但是有个缺点是性能开销较大,而 volatile 是轻量级的线程安全实现方案,在某些特定场合下也能保证线程安全。由于 synchronized 的便捷性,也容易导致 synchronized 的滥用。

双重检查锁

因为 volatile 不能简易的实现线程安全,需要有较深入的了解才能正确使用,所以 volatile也显得更为复杂,使用频率也较低,而 volatile 的一个典型使用例子是双重检查锁模式

双重检查锁通常用于单例模式或延迟赋值的场景,其代码通常如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Singleton {
private volatile static Singleton uniqueSingleton; // 1. 为变量添加volatile修饰符

private Singleton() {
}

public static Singleton getInstance() {
if (null == uniqueSingleton) { //2. 第一重检查
synchronized (Singleton.class) { // 3. synchronized加锁
if (null == uniqueSingleton) { // 4. 第二重检查
uniqueSingleton = new Singleton();
}
}
}
return uniqueSingleton;
}
}

以下是对这段代码的一些疑问及解答:

Q: 为什么不在 getInstance 方法直接加 synchronized ?

A: 只有在第一次初始化时才需要加锁,如果在getInstance方法上加锁则每次获取实例时都会对整段代码块加锁,影响性能

Q: 为什么需要双重检查?

A: 如果多线程同时通过了第一次检查,其中一个线程需要通过了第二次检查才进行实例化对象,其余线程在后续等待获取到锁后则判断到变量非空,跳过赋值操作。

Q: 为什么 uniqueSingleton 需要添加volatile关键字?

A: 对于 uniqueSingleton = new Singleton();语句,实际上可以分解成以下三个步骤:

  1. 分配内存空间
  2. 初始化对象
  3. 将对象指向刚分配的内存空间

但是有些编译器为了性能的原因,可能会将第二步和第三步进行重排序,顺序就成了:

  1. 分配内存空间
  2. 将对象指向刚分配的内存空间
  3. 初始化对象

现在考虑重排序后,两个线程发生了以下调用:

Time Thread A Thread B
T1 检查到uniqueSingleton为空
T2 获取锁
T3 再次检查到uniqueSingleton为空
T4 为uniqueSingleton分配内存空间
T5 将uniqueSingleton指向内存空间
T6 检查到uniqueSingleton不为空
T7 访问uniqueSingleton(此时对象还未完成初始化)
T8 初始化uniqueSingleton

在这里添加volatile关键字主要是避免在对象未完整完成对象创建就已经被其他线程读取,造成空指针异常。

总结

  1. volatile 的主要作用是实现可见性和禁止指令重排。
  2. 线程安全需要满足可见性、有序性、原子性。
  3. volatile 可以保证可见性和有序性,但是无法保证原子性,所以是线程不安全的。(非原子操作可能会导致数据缓存在CPU的cache中,产生数据不一致)
  4. synchronized 关键字虽然可以保证可见性、有序性、原子性,而且用法简单,但是性能开销大。
  5. 双重检查锁模式是 volatile 的典型使用场景,双重检查锁通常用于实现单例模式或延迟赋值。

参考

Java中Volatile关键字详解

java volatile关键字解惑

为什么双重检查锁模式需要 volatile ?

Java中的双重检查锁(double checked locking)


抛开技术细节,分享五年职场的个人感悟及道理总结

个人简介

1. 过程

我从毕业至今五年一直在目前的公司工作着,从部门最开始的十几人团队发展到现在的将近两百号人,几年了没换工作不是因为习惯舒适区,相反这一路过来都是不断的突破,因为团队在快速壮大,面临的问题也在不断升级,根本没有太多的时间可以休息整顿。当然,上百人的增速那也是部门而已,我的团队人数增速并不大,只是从两三人到目前的二十人,不过项目金额却是从最开始的十万级到目前的千万级,项目难度升级倒是挺大的。

2. 自我评价

优点

  1. 能快速准确的理解需求
  2. 对技术方案有较好的判断及把控
  3. 性格不是主动外交型,面对外部沟通内心会有一定抵触,但还是能较好的完成大部分沟通协调工作
  4. 相对比较容易接受变化,不会太死板

缺点

  1. 技术不够扎实。太多协调处理的事消耗精力,难以全身心投入到技术沉淀
  2. 一直待在一家公司里,少接触其他公司的文化及流程

其他

  1. 一直有做产品的心,奈何团队的几个产品都没能成功落地,工作的方向还是项目型
  2. 内心向往互联网方向,希望最后还是能进入互联网行业
  3. 以架构师、CTO的目标前进,一方面永不放弃技术,另一方面在面对管理协调等“杂事”时,也会尽量调整心态去接受。

3. 当前项目

一直都在想,如果以当前能力再去做小项目简直是游刃有余,能做得漂漂亮亮,但现实是一直以来都被推着往上走,根本不能回头,不过也正因如此才有不断的进步。目前在做的是一个省级项目,虽然概括起来项目都是增删查改,但背后要处理的问题复杂度与普通项目对比绝对不是一个量级的。
在这个项目过程中,我要面对的相关方很多:

  1. 客户——需求沟通,进度反馈(虽说有项目经理,但也难避免要到一线直接沟通)
  2. 厂商——方案沟通,进度跟进
  3. 机房资源——确认使用方式,明确责任边界
  4. 内部团队——带领冲刺、安抚情绪、技术方案确认
  5. 外部团队——项目很难保证开局就有充足的资源投入的,这就避免不了去外部协调资源解决燃眉之急

这个项目过程一直保持高强度工作,可以说是每天被榨干榨尽,关键是现在项目还没结束。。。

个人感悟及道理总结

1. 工作的本质就是解决问题

工作的本质就是解决问题,而你的位置决定了你面临的问题复杂度。按照事情发展的规律,一件复杂的事情总会出现大大小小的状况外的问题,没问题才是不合常规,当出现问题的时候尽量保持淡定和积极的心态,不必过分焦虑和惊慌,因为你工作的意义就是来解决问题的。当你能坦然面对时,这个问题的难度已经减半。

2. 最难的是从混沌到有序

事情最难的是从混沌到有序的过程,你看到的成功产品看似简单,但每一个功能、每一个细节都是由一个个大大小小的决策构成,每个决策的背后都会对应大量的分析、讨论、协调与纠结的过程。每一个能带来价值的产品或项目都是伟大的,背后都是一帮人努力的结晶。

3. 要做成一件事不是那么简单的

要做成一件事,就需要去促使这件事的往前推进,这个过程会有很多的阻力,需要协调各方各面。有时候当事情完满结束时,回过头想好像也并不是那么难,但如果在过程中把每个细节都记下来,那肯定是每天都面对着各种奇奇怪怪的问题。

4. 有人的地方就有江湖

有人的地方就有江湖,有江湖的地方就有是非。曾经我们还是个小团队的时候,大家都很善良淳朴,但是当团队壮大后,一切就都不那么简单了。一来是人际关系变得错综复杂,二来是做的事情大了,与外界的接触也越来越多,不能再待在象牙塔里,不得不面对社会的复杂性。

4.1 没有密不透风的墙

在办公室里,感受最深的就是没有密不透风的墙。工作中总会听到一些小道消息,有公司的经营状况,有高层的动向,有同事间的八卦,有某人对某人的吐槽,真真假假,难辨是非。这也是告诫自己,不要过多的吐槽别人,因为你真的不知道这个吐槽是怎么去到你吐槽对象的耳边的。。。同时当你听闻某人对自己的吐槽时,也没必要过于执着,一来不知真假,二来江湖难免冲突,对于吐槽的内容有则改之即可。

4.2 做事留痕

另外,在这个江湖中同样重要的是要学会保护自己。做的事情大了,就肯定要跟外部打交道,这个外部包括外部团队、外部企业等,你可以保持真诚合作,但也要注意做事过程中保留痕迹,因为你不能保证当发生利益冲突时,是否会导致合作关系破裂,当你没有证据时,很容易有理说不清。
我认为在团队内部应尽量保持真诚,不要过多计较。但是当与团队边界外的相关方合作时,要有一定的戒备,关系越远戒备等级也越高,关系由近致远为:跨团队 -> 跨部门 -> 跨企业。

5. 没有绝对的对错,只有不同的立场

是个简单的道理,不用细说了,遇事能保持客观就好。

6. 做过的事总会有意义的

多做点总是有好处的,现在认为没用的事情,但是这份经验总会在某一天派上用场的。但也要注意分清主次,不要在分支上越走越远了,有些事情点到为止拿到经验值即可。

7. 站在用户角度思考问题

一定要站在用户的角度思考问题,如果不考虑用户的实际使用场景,容易简单认为用户都是无理取闹,武断的否定需求,最终只从技术的简易性去设计方案。有时候一个小细节的实现能对用户工作效率带来非常大的提升,一个技术方案的确认并不那么简单,要结合用户的使用场景、技术的简洁合理、投入成本等多因素综合决策。

8. 合理上升问题

从两方面说为什么要上升问题:

1. 信息断层很致命

项目经理最害怕的是任务下发后,成员埋头苦干,遇到问题不敢问,遇到风险不敢说,到了节点交付日期才说问题解决不了。有些问题,只要及早的向上反馈,解决办法是有多种的:可能可以请教高人指点,可能可以跟客户提前交底降低心理预期,可能可以通过方案的变更降低难度。但如果不把问题的风险尽早暴露,到了最后节点再爆发,可能问题就是一发不可收拾了。

2. 会哭的孩子有奶吃

领导每天要处理的事情都很多,你不出声代表你没问题,那领导也肯定不会主动去找麻烦。所以当问题真的超出自己能力范围后,就应该考虑是否要烦扰领导,把问题上升了。而且有些问题在自己的层面是个大问题,但是在领导的眼里根本不是问题。要学会在必要时候合理的主动上升问题,不要什么都自己藏着掖着。当然,也要注意把握这个度,不要事事都麻烦别人。

关于团队管理

1. 交付的信息越多越有利于做出正确判断

通常我们做技术的,很少会出于私心而对一些信息的交付有所保留,但很多时候是因为我们太忙,在分发任务时容易缺乏耐心。但是对于复杂的任务,要保证成员交付的产物与你的期望一致,应该交付尽可能多的信息,如需求的背景、你希望的实现效果及实现方式、包含的一些隐性需求(如性能、可扩展性)、潜在风险等,只有当团队成员掌握足够多的信息,在遇到问题的歧义时,他才能做出正确的判断,避免任务的返工。

2. 梯度的搭建很重要

你可以保持扁平化的氛围,但是工作任务还是要尽可能的搭建梯度,不然什么事情都会上升到你这里,你会被各种琐事打断你的思路,消耗你的精力。只有当你手上的事情能分发出去,你才能去做更重要的事情。

3. 团队情绪很重要

当开始带领团队后,你就不能再只关注自身,和你一起做事的兄弟,他们不是没有感情的劳动机器,当他们的状态不佳时,你应该更主动的去关心他们,可能是因为一些私事,可能是因为薪酬福利,可能是因为工作强度,这些可能性是需要了解的,而不是一味的吐槽。只有保证团队的情绪是健康的,才能保持高昂的士气,确保团队有足够的战斗力。

4. 对团队成员要有要求

不能对团队成员过于仁慈,一定要对成员提出要求。千万不要因为成员的能力水平不足,或是担心任务超出他们的能力时会有抵触情绪,于是一直只安排简单的任务给到他们。你的仁慈会阻碍成员的成长,只有安排超出他们一定能力范围的工作时,在一定的压力情况下才会有快速的成长和突破,这样你的成员能够更多的为你分担工作,达到双赢的局面。

5. 不患寡而患不均

公平是很难做到的,但也要尽量保证。在工作量分配上,要避免能力强的人承担过多,而能力差的人反而轻轻松松过日子。在奖金福利的分配上更是要小心,相当容易造成不满情绪,别把好事变成坏事。

6. 做好决策

团队管理者就是要做大量的决策,把握方向。做技术的都希望可以玩新技术的,团队成员由于不用担责通常都是激进派,但作为决策者必须衡量其利弊,不能过于保守,也不能过于激进。当然说起来简单,要做好是需要大量的经验积累及学习的。

总结

时间匆匆,一不小心就已经五年工作经验,这一路也是跌跌撞撞的过来,以上都是个人的感悟总结,当然以上也有很多是“道理都懂”,自己也没能做好的。要说对这几年的工作生涯很满意那是不可能的,不过也不至于充满遗憾,毕竟成长嘛,还是有的。希望以后的道路更精彩,未来可期。

如果希望再多的交流,可以关注我的公众号换取微信号,乐意解答相关问题。


【软考】系统架构设计师(高级)考试经验回顾分享

前言

全文以过程回顾为主,跳转到“备考攻略”小节可成功闪避唠叨攻击

早在2013年还在大三的时候便随大众考了「软件设计师(中级)」证书,时隔多年在2019年11月9日再次踏入软考的考场参加「系统架构设计师(高级)」的考试,最终结果是侥幸的以49/50/46成绩低分飘过。

由于当时备考时也没看见多少关于系统架构设计师考试的文章,所以既然难得通过了,那也顺手记录一下这个过程做个分享吧。考试过后一直忙于工作没有闲暇进行回顾总结,现在已经有点记忆模糊,只能尽力回忆了。

考「系统架构设计师」的念头是在2019年初时萌发的,当时感到工作上一直在往前走,但没什么里程碑节点,于是定下一个目标,不管是否有用,要在2019年尝试考「系统架构设计师」证书,一来是藉此系统性的梳理系统建设的相关知识,二来也看这个证书能否换取一点补贴,毕竟现在的中级证书竟然在当前带有一点国企性质的公司中,在半年前突然开始有一丁点薪酬补贴。

备考过程

「系统架构设计师」一年只有一次报名机会,与中级证书的一年两次不一样,所以一定不要错过报考时间,2019年是11月份考试,而报考时间是在8月中旬,当时我早早写在了我的GTD列表中。

我的备考时间也算是挺早的,5月中旬我已经开始购买备考书籍,当时先买的是《系统架构设计师考试全程指导(第2版)》,考虑到对应的题册最新只到2016年,所以打算迟一点再买看是否会更新,等到8月底感觉光看知识点已经不行了于是才买的《系统架构设计师2009至2016年试题分析与解答》。

虽然很早开始买书,但是由于疯狂加班,加上一定的懒惰性,其实真实花在备考的时间不太多。5月底到6月中旬的时候是比较用心的在看,7月份想着还有比较多时间所以投入的精力比较少,8、9月份碰上工作上的一个突发项目,连续没日没夜没周末的加班,直接损失了两个月的复习时间,剩下的10月到考前11月都是工作上比较忙碌,处于只能挤时间复习的状态。

考试的题型主要分三场,早上选择题、下午是案例分析、论文(我之前也没特意了解,一直以为只是选择题+论文,没想到原来总共是有三场考试的)。我到10月前基本都还是在通读教材并做章节后的练习题,10月份才开始做真题,10月中下旬才开始练习论文,直到考前大概也只做了两套多的题目,还是没做全的。

备考感受

最开始报考「系统架构设计师」的目的是希望通过对这个教材的学习把相关的技术知识梳理一遍,因为觉得工作至今的技能增长点还是比较散,没有一个系统性的学习。但是当我翻阅教材开始学习时还是比较失望的,书本的内容过于空洞,给人的感觉更像是凑字数,仿佛是定好章节大纲后相关人员往对应的章节填充足够的资料、素材就了事了,阅读过程非常枯燥无味,知识内容也是大多脱离实际。

当然,教材的内容也不是一无是处的,哪怕像是阅读百度百科,也是能了解了一下对应的知识点会有哪些内容。但是,如果真的只是为了梳理知识点的话,其实只看教材的目录便可,根据目录的框架作为知识学习的方向,相关的内容自己带着疑问去搜索相关资料文章的效果会更好。考这个证书是有用的,但如果是抱着通过考证的学习过程去提升梳理自己的知识网络,我觉得大概率是会失望的

另外,让我在学习过程受挫的一点是,很多题目的答案其实在教材中是无法找到一一对应的知识点讲解的。例如像部分嵌入式题目提及的一些专有名词,对于做应用开发的我是完全没接触过的,而教材中无论怎么翻阅也是找不到有提及过的描述,所以还是非常考验实际的技术积累与阅历的,并不像以往的考试答案都在教材中。

考试过程

直到考前其实也没太大信心,尤其是选择题最没底,就如之前说的,选择题部分很考验知识面,不懂的领域挠破头也没用。但也是因为本来就没抱太大期望,反而心态比较放松,内心是希望可以考过,但如果不通过也不会太失望。

早上按时进入考场,万万没想到课室竟然是没挂钟的,而我手上也是没钟表,考试的时间控制只能靠监考老师的人工报时了,所以备考还是要带齐文具、准考证、钟表这些。。。

早上考的是选择题,考试时间是9:00-11:30。试卷发下来,大概翻阅了一下,考点基本上是按教材提纲的顺序制定的,并不意外。通常前边几道题会有涉及到内存分页相关的计算题,难度会较大,但是这次的并没太大难度,而继续往下做时,一些超出技术领域的题目,也是相对容易能够判断出明显的正确项,所以总体做下来比想象中要好。而且仔细计算一下,总共75道题,及格线45分,那总共可以错30道题,这样一想,感觉通过的可能性其实也挺大的。秉承着改多错多的原则,在10点钟做完所有题目后,我检查了一次答题卡没有填涂错误后便开始睡觉了,这一睡就是一个钟。。。

由于感觉最难的选择题仿佛也有挺大希望及格,所以到了下午的考试我就开始认真对待了。

13:30-15:00的考试是案例分析。案例分析是可选题目的形式,其中有一道题我做到一半觉得还是把握不大,所以换去了另外一道虽然也不确定但是稍微好一点的题目。

15:20-17:20是论文。在几道题目中我选了最有把握的“负载均衡的三种实现方式”这题目,只要没理解错题目,那这个基本就是一般面试过程都会问到的题目,作答还是比较容易的,只是有可能因为本来题目就简单,所以要拿高分也不容易。定下题目后短时间内列好提纲,然后开始写摘要,摘要部分稍微超出了建议的字数,不知最后分数是否因为这里有影响。然后就开始奋笔疾书的按之前准备好的模板套内容,连续不停的写下2000多字,具体多少字不记得,反正是达标但也没超多少,因为真的憋不出了。整个过程时间还是非常紧迫的,几乎一刻都不能停,手再酸都还是要继续,到后来只剩半小时的时候紧张起来提了一下速,字迹也开始变得潦草,最终写完也只剩出十来分钟。

备考攻略

  1. 开始学习教材前,建议是先浏览一遍真题,知道大概有哪些题型,会考哪些知识点。
  2. 多看目录架构,了解总体的知识点方向,做到脉络清晰,不要陷在某个知识点不能自拔,影响总体进度。
  3. 学习每一章节前建议先分析课后习题,带着疑问去学习每一章节(我开始时打算学完一个章节后再做对应的课后习题,想通过这样的方式检验自己的学习过程是否到位,后来发现这样效率非常低,一来学习过程容易分神,做题还是要回去再看一遍知识点;二来这些题目真的不一定在课本中有的,反复定位查找答案位置的过程很耗时)。
  4. 案例分析的题目基本是靠做题目去实践,认真弄懂几套真题的案例分析基本就上手了。
  5. 论文一定要套模板,考试时没有太多时间让你慢慢构思,要在限时内写出足够多的字数已经是非常大的挑战了,2500字写下来绝对会手酸的。备考过程找几篇示例文章,分析一下文章结构,作者是如何开始自我介绍和项目介绍,如何引出考题内容,如何展开分析,从哪些方面可以加入凑字数的内容,如何进行总结,把这些结构甚至内容梳理好,最后形成自己的模板,到时论文就按这个模板进行填充内容即可,最好是把模板的内容背一背、抄一抄,做到考试时不在一些凑字数的语句上卡壳。如果有条件,最好是练上一遍两遍提前感受一下2500字的难度,如果时间不允许,那也要多找几个主题,每个主题大概花半小时练习提纲的构思,思考如何行文。
  6. 通读教材是最花时间的,而教材的内容基本上是面向早上的选择题,至于案例分析、论文这些基本都是从真题分析中便可掌握,所以千万不要把所有时间投入到性价比较低的通读教材,一定要保证有一定的时间练习案例分析和论文。

总结

这次考试难度相对偏低所以侥幸过关,以上只是对自己整个过程的回顾记录,仅供参考。虽然备考的过程比较粗糙,但也算是完成了年初定下的一个目标,算是对自己的一个交代。当然,要认清的是,虽然这个证书的名字是「系统架构设计师」,但显然考过了并不意味着自己就是架构师,这只是一个微薄的起点,后续还需要多鞭策自己,以这个知识架构为基础多积累、多实践,朝着真正的架构师的方向去努力。