秋招学长3

秋招历程

博主是4月份左右开始准备秋招(岗位:大数据和java开发),期间跌跌撞撞,走了不少弯路,但是结果还不错:在众多面试中博主有幸拿到了:腾讯、百度、美团、今日头条、keep 、度小满、猫眼、流利说、猿辅导等公司的offer。

秋招期间的艰辛只有经历过的同学才能体会,自己总结了下秋招准备期间的一些疑问(可能会比较长,花了大半天一个一个字码出来的,没有废话):

文章将会按照下面的顺序来组织:
求职定位
可以大大提高简历投递效率的小工具
求职时间轴(什么时候开始准备?)
如何准备:书籍推荐、主要考察的知识点
自己求职期间的教训
关于实习(实习真正重要性,并不是一定得去实习)

2018.4-2018.9,秋招准备了整整半年。整个准备期间有过迷茫、沮丧、自我怀疑等一些负面情绪。现在回过头来看,这些负面情绪大多因为对自己的定位不准确造成的。从另外一个角度看,求职面试从某种层面讲其实也是一种“应试”,因为在面试主要是对:基础知识、项目、系统设计、数据库、框架源码等的考察。我切身体会:“有考试的地方就会有技巧”。诚然,我们不能把我们的职业走向完全压在“投机取巧”上,技巧只是“锦上添花”。求职面试是长期积累的过程,“求职技巧”可以帮助你更好的把握机遇,更好的向面试官展示自己。

求职定位
你要找哪个领域的工作:软件开发(java还是C++)、测试开发、算法、大数据、Android、IOS…?职业定位“越早越好”,否则后期可能会因为准备时间不足而手忙脚乱。如果你曾经接触过上面好几个领域,那么求职定位对你越发重要,求职之前一定要清晰的知道自己今后想从事哪一个领域的工作。求职定位模糊,在求职后期可能会出现一种尴尬的情况:你懂的领域比身边同学广,但是就某一个领域而言和任何一个同学相比都没有优势,这在求职市场上并没有任何优势。另外,在投递简历的时候,一家公司其实只能投一个岗位,即使某家公司允许投递多个志愿,实质上还是优先第一志愿。这也说明公司在招聘时目标明确,面试你的面试官只是负责某一个职位/领域的面试,也就是面试官需要招聘对这个领域有较深刻理解的同学,而不是想要招聘一个接触过多个领域,但是每个领域都是“半桶水”的同学。换句话说,求职初期的自我定位十分重要,尤其是对哪些博学的同学而言。对某一个领域有知识的深度,知识的广度这时会是一个加分项;只有高度没有深度在求职市场上并不占有优势。清晰自己的定位,明白自己的优势和不足在求职市场上显得非常重要。如果不明确自己求职定位,焦虑可能会一直伴随你的求职过程。求职定位清晰:你只和同一职位的同学竞争;准备过程目的性较强;专注某一个领域的求职会比广撒网对与某个领域的理解更深。
从开学到求职,期间你必然会接触到不同领域,但是该选择哪个领域呢?从后往前看,临近招聘的前几个月,你只能选择你最熟悉、最有把握的领域(不太熟悉的领域,你的准备时间可能不够),这时你的选择的余地较小;时间线再往前推,此时选择余地会很大。接触过不同的领域,对各个领域也有一些自己的认知,结合职业的发展方向、待遇等即可大致做出选择。必要时可以咨询学长、师兄、师姐、父母、长辈、朋友的建议。切记职业定位一定要清晰,最好在求职之前半年确定。

工欲善其事,必先利其器
如何及时投递简历?介绍几个求职必备微信公众号(排名分先后):牛客网、校招日历、19应届生、内推军、招聘消息汇总、头号内推圈、offer先生。公司很多,而且每个公司开始校招/内推的时间不同,上述几个公众号会及时跟踪各个公司校招/内推开始时间,并且会及时发布消息,有了这几个公众号就不必担心忘记投递某些公司了。另外,这个网页包含了足够多的企业校招/内推起止时间:牛客网首页->求职->校招日程、笔试日程,好好利用这个网页,这将大大节省你的时间。
另外,大多数公司要求在线填写简历,这将花费大量的时间。这里介绍一个牛客网工具:牛客简历助手。它是牛客网开发的一个“扩展程序”,就是浏览器中的一个小工具,支持一键填写简历,可以大大提高简历投递的效率。

什么时候开始准备?
每个人情况不一样,准备的需要的时间也必定不同。所以,从后往前:7月份逐渐有公司开始内推了,7月下旬-8月底又是一波内推的高潮。内推可以理解为“提前批校招”,但是内推大多是不需要笔试的,内推只要过了简历筛选,就可以有面试的机会。一定要抓住内推的机会,内推招的人数可能会比较多一些,内推可以理解为各个公司的“抢人大战”,一般来说内推之后还会留一部分名额给到9月以及之后的校招,相对于一个公司有两次机会:内推和校招。但是也有可能内推招满了,此时就校招就没有名额了,比如说阿里巴巴,阿里巴巴今年在7,8两个月基本把人全部招完了,所以阿里巴巴今年校招几乎不招人了(如果想进阿里巴巴,最好尽早走内推渠道)。所以呢,6月底-7月初你要完成第一遍的复习——该看的书、源码、项目应该要比较熟练了。当然,人是会遗忘的,所以7月之后,重点准备第二轮复习以及通过自己的面试、网络上的面经查漏补缺。越到后面:简历投递(大多是在线简历填写)、面试(有的需要到对应酒店面试)、电话/视频面试等将会占用很大一部分时间,尤其是9月份的面试,9月份的面试大多需要去酒店面试,路上将占据很大不一部分时间,加上坐车的劳累等,希望大家有个心理准备。相对而言,内推较为轻松了,大多是电话和视频面试,不用到处奔波,好好抓住7月到八月中旬的内推黄金时期。如何拆分利用求职路上的时间呢?建议平时的笔记最好使用具有在线同步功能的软件,这样在车上也可以用对应的手机APP复习。身边同学用的APP主要有两种:有道云笔记(免费)、为知笔记(60元一年;按月的话是6块一个月,个人感觉挺好用)。

如何准备:书籍推荐、考察点

这里以java为例,面试中的考察点:java基础、jdk源码、JVM、并发、分布式问题/消息中间件(zookeeper、kafka)、后台框架(SSM等)、MySQL(索引结构B+树、MVCC原理、主从、SQL语句)、NoSQL(HBASE)、Redis(常用数据结构、某些数据结构源码、Redis集群、分布式锁)、算法(大部分是牛客网、LeetCode原题,论刷算法题的重要性)、操作系统基础、Linux、计算机网络、设计模式、项目。对于大数据而言:spark、Hadoop。另外,对于项目一定要很熟,无一例外,在面试中你必然会遇到与这类似的问题:在项目中比较有挑战的事情、在项目中你遇到过哪些问题,然后是怎么解决的。在HR面的时候还会遇到:自己的优缺点、如何学习新知识等。

书籍推荐:深入理解Java虚拟机(周志明)、计算机网络(谢希仁:OSI七层模型每层干嘛的、UDP、TCP区别、拥塞控制、流量控制、三次握手四次回收等)、高性能MySQL、HeadFirst设计模式(一个模式一定要在JDK或者框架中找到应用场景,方便拓展)、图解HTTP(可以不用,看上面的计算机网络可以了)、java高并发程序设计和java并发编程的艺术(先看前面一本,再看后面一本,前者更通俗易懂)、从Paxos到zookeeper分布式一致原理与实践(因为现在企业项目大多是分布式的,而zookeeper在高可用的分布式系统中运用很多)、java EE互联网轻量级框架整合开发、深入理解计算机系统。对于算法岗位而言:统计学习方法(李航)、机器学习(周志华)、机器学习实战(最好要有对应的项目、论文、比赛名次等,因为算法岗位竞争较大否则简历筛选都过不了)。对于大数据开发而言:Spark大数据处理技术、Hadoop权威指南。

对于redis学习,可以网络上找找博客、视频之类的;Linux的话靠平时的一些积累和面经,Linux面试主要问题:常用命令、软硬链接、进程间通信、如何查看系统内存、如何查看某个进程使用了多少内存、如何查磁盘使用情况、虚拟内存(swap)、ps命令的使用等;操作系统:进程线程的区别、内核空间和用户空间的区别等,操作系统那本书很厚,如果实在没时间看可以上牛客网找操作系统相关的面经救急。

另外,java基础部分主要是java源码的阅读,比如:ArrayList、LinkedList、HashMap、ConcurrentHashMap、java.concurrent包下的锁:ReentrantLock(关键是AQS原理)、CountDownLatch、CyclicBarrier、线程的ThreadLocal、线程join、wait等实现方法等。

除了这些,数据库的设计范式、数据库如何分库分表、API的流量控制算法(漏桶、令牌桶,非常重要但是简单)、秒杀系统的设计、大数据的处理技巧(数据量远大于内存大小)等一些系统问题。

上面这些,之后都会有文章总结。博主在求职过程中花费大量时间总结学习上面这些知识,不希望后面的求职者重复“造轮子”。秋招结束后会逐渐把这些总结分享出来,希望能够帮助到后面的求职者。

最后,切记看书,而不是背书,对知识点一定得有自己的理解。

珍惜最后的提问机会
珍惜最后的提问机会,尤其是最后一面技术面的提问机会(不是HR面)。最后一面技术面的面试官大多是部门leader,也就是说技术最后一面的面试官最了解你以后要做的工作。虽然你现在手里可能offer不多,但是相信我,最后你手里一定会有3个以上的offer,到那时你如何选择呢?选择大多根据:平台、薪资、部门是做什么的(即以后的发展前景)。前两个都可以在网络上找到,至于部门信息,最快捷准确获取部门信息的渠道就是你的技术面最后一面面试官(大概率是你以后的部门leader),所以一定要珍惜你的提问机会。那么该问一些什么呢?非技术最后一面,可以大致问问部门是做什么的,多久之后出结果等。至于技术面最后一面:你应该问你关心的事情,我关心的点主要有:部门技术栈、部门有多少人、入职后有哪些可选的方向、对应届生的培训相关政策等。你要明白,这是你的第一份工作,你应该问你所关心在意的那些点(除了薪资待遇,因为这是归HR管)。在最后选择offer的时候,这些信息显得尤为重要。

心态:不卑不亢
求职注定是一个艰辛的过程,在这个过程中难免和身边的大佬们对比,由此可能会产生自我怀疑等负面情绪。这个时候可以去跑跑步,调整调整心态。在求职面试过程中,你一定要坚信,每个人都有自己的归宿,后拿到的offer并不意味着比先拿到的offer差,这不是鸡汤,身边大多同学在九月底拿到自己满意的offer。放宽心,再给自己一段时间,坚信“一份耕耘一份收获”。这个自我怀疑的过程也是求职之后的一份收获。

补充一点:
关于实习

每年3月份左右开学,这之后的二十天左右将会进入实习招聘的高峰期,各大公司开始暑假实习生面试招聘。个人建议大家不论暑假真的能否去实习,大家都应该投递几家公司的实习招聘,原因如下:
实习生的面试相对校招更简单(一般没有笔试),不用过于担心自己没有准备好。后面你会发现:“没有任何一个时候你是完全准备好的”!
实习的各种面试是非常有助于个人知识结构的提升的,实习的面试有助于知识点的查漏补缺,发现自己的不足,一定要重视!!!
如果实习面试过了,即使不能实习,校招会优先面试,有的公司会跳过一面和笔试,直接二面。
建议尽量去实习,可以丰富简历。如果老师不让去也没什么,我们老师也…不让去实习。

从零开始——互联网学习路线(上)

从零开始—互联网学习路线(上)
学习路线分上中下三篇,本文是第一篇。其他两篇这两天会陆续发布。欢迎大家关注订阅。有建议欢迎评论区留言~。
下面的所有的学习资料博主都已经分类整理好了,资料是博主以及身边同学学习时使用的资料,资料获取方式见文末,免费分享。

本文主要分为三个部分:
如何学习java基础
如何学习javaEE
你关心的项目问题

01
java基础学习
建议初学者看视频学习,不推荐看书。入门视频选择非常重要,最好是通俗易懂、深入浅出的教学视频。如果入门视频选的不好,不知所云,容易产生厌倦心理:“从入门到放弃”。关于java书籍,前期的学习个人不推荐直接看书:书本较为枯燥、不直观、容易分心、可能坚持不下来。
02
javaEE入门学习
上面的基础部分的学习主要是为后阶段打好基础。javaEE是java开发学习路上举足轻重的一员,那么javaEE该如何学习呢?框架那么多,该学哪些呢?从哪个框架开始学习呢?从博主以及身边同学的面试来看,javaEE主要需要掌握以下几个部分:servlet、jsp、hibernate、mybatis、springMvc、spring,有余力的同学可以学习spring boot,它是轻量级的spring,互联网公司使用较多,学完spring之后,学习spring boot就很简单了。框架学习顺序,在整理的资料中有写。

servlet和jsp属于基础,高层框架都是建立在servlet和jsp的基础之上,博主和身边同学建议学习。虽然现在项目中很少直接使用jsp和servlet,但是框架都是在这基础之上进行了封装,学习servlet和jsp可以帮助你更好的理解框架,而不是只会配置,调API,不知原理。另外,servlet在面试中问的很多,jsp面试问的少,但是后面做项目的时候你得会写简单的页面啊,否则项目都搭不起来,会产生严重挫败感。

03
你关心的项目问题
怎么找项目呢?
微信后台回复“资料”,里面有很多项目,从里面或者网上找都可以。另外,博主也帮大家整理了一份资料。

怎么选项目?
首先最好是使用SSM框架的项目,SSH用的不太多,不推荐;
分布式的项目最好是,不是也没关系,大部分网络上的项目都不是分布式的;
最好不要找商城,因为商城已经烂大街 了。
数据库最好mysql,另外面试时数据库表设计也会是常问问题,大家学习的时候注意一下。

怎么做项目?
很多同学问做项目怎么才能避免只是跟着视频敲了一遍代码,好像什么都没学到的感觉?
javaEE的挑战在哪里呢?在没有分布式、高并发场景下,项目显得很“low”(大概率就是写简单的页面,控制器查数据库准备数据、页面从域对象里面取出数据填充、返回给浏览器),但初学者应该如何学习呢?

要回答这一点,首先明白面试官问项目问的是什么?之前头条面试的时候,面试官提到一点:头条为什么这么喜欢问算法呢?“应届生简历上的项目与生产环境相差很大,不太实用,所以我们不太问项目,偏向算法、基础的考察”。面试官其实很清楚你的疑问,但是他们还是会问项目,面试官考察的到底是什么呢?考察的是你解决问题的能力。在面试中,项目中有的业务/问题可能你之前没有想过,但是面试官却给你假设了某个你之前没考虑到的场景。你说我之前没有考虑到这种情况;面试官会说,没有关系的,以前没有想过也没有关系,现场想想怎么解决。也就是面试官考察的是你解决问题的能力。

在大多数面试中,项目部分大多会延升到分布式、高并发场合。因为用户一多、数据量一多,问题就来了,简单的业务在高并发,大数据的场景下瞬间就会产生很多问题。比如,新浪微博的分层评论/点赞如何实现、项目中的数据库如何设计、比如说你的分布式中的某一台机器损坏了,怎么解决服务不可用问题;再比如说淘宝每天订单量很多,数据库如何设计:分库分表策略;比如说文件上传时上传期间断网了,那么你如何实现下次在上次的基础之上继续上传,而不是全部重新上传?比如说,360的开机打败百分之多少用户,这又怎么实现(360用户肯定很多,使用尽量小的代价实现)。上面这些基本都是面试中的问题,说了这么多,我们在跟着视频做项目的时候,究竟应该注意什么?

你之所以会有上面“我仅仅是跟着视频敲完了代码”的感觉(首先初学者有这种感觉是很正常的),因为视频的项目大多不是分布式、没有海量用户、不用考虑容灾问题。怎么弥补这一点呢?在跟着视频做项目的时候,视频中的老师提出一个问题之后,你想想解决思路,如果把场景延时到几亿的用户呢?海量的数据呢?这些问题至关重要,并且你需要把这些问题记录下来。

项目相关面试问题中一定会有:“你在做这个项目过程中遇到哪些问题,你是怎么解决的?”、“项目的亮点在哪里?(和第一个问题一样回答)”。面试不像考试,“亮点”不是说非得独一无二,你可以转到你遇到了哪些问题,你是怎么解决的,或者还没解决的问题可以问问面试官的意见,这样在下次面试中,上一位面试官的解决方案就是你的了。

所以,项目这块就是这样通过面试不断积累的过程,这也是为什么极力建议在实习招聘的时候投递简历,无论你最终可不可以去实习。因为这就是你“升级打怪”积累的过程,前期多投小公司,积累“升级打怪”的经验。后期在面对BAT、TMD等一些大boss的时候你才能不虚,才能顺利通关。上面说的在做项目时把场景延时到分布式、并发场景,这很难,只是建议,不要因噎废食,通过面试积累项目经验,后面你会发现,很多公司针对你项目提的问题都差不多,最后你会发现原来你认为“没有什么东西”的项目其实问题一大堆,这些问题的发现主要靠面试积累。最后强调一次,一定要把项目中遇到或者是你想到的问题记录下来,一定要。另外,有同学可能会说:“我在做项目的时候没有遇到问题,或者没有难点,那怎么办?”

首先,你再看一遍这篇文章;其次,没有问题,我们要学会创造问题。比如说:你知道java会有OOM,并且你知道OOM怎么排查,你完全可以把OOM这个问题的排查过程认为这就是你遇到的问题;再比如说,JDK7以及之前的HashMap在并发情况下会发生死循环,如果你知道这个原理,也知道怎么解决,这也可以成为你的debug的经历;再比如说,linux自启动问题,一个项目部署到linux上,肯定要配置自启动,如果你知道怎么做,这又可以是一个问题。上面这些例子只是希望大家明白:没有问题可以创造问题。当然,项目业务相关的问题或者开始你没有考虑高并发,在考虑之后出现的问题等一些业务相关的问题可能会给面试官留下更好的印象,我只想传达:“你做项目过程中不可能没有任何问题”。项目业务紧密相连的问题,其中一个问的比较多的问题是:如果其中某个业务在处理过程中失败了,你应该怎么处理?

在web项目中一般用户的行为都会交由线程池处理,如果在处理某个业务的过程中发生了异常,导致这个任务没有处理完,这时你要怎么处理?这里这样举例的意思是:大家要随机应变,要学会创造问题。只要你知道问题解决的方案,面试官怎么可能知道你在做项目的时候究竟有没有遇到过这个问题呢?问题来源于想象。最后,注意积累项目问题,在每一场面试中,面试官会不断提出问题,面试完后,下来找到这些问题的答案或者和同学讨论。这样每次面试下来你的问题库里面又多了几个高质量的问题。一定积累之后,面试会很顺利~。不要担心面试官对你的项目提不出任何问题,如果是这样,这大概率是面试官的能力问题。

总之,项目面试是一个积累过程,前期多投小公司的实习和秋招,为后期“升级打怪”积累经验。项目面试无法一蹴而就,是一个积累的过程。

从零开始——互联网学习路线(中)

在上篇文章中,博主详细介绍了java基础的学习和javaEE的学习,并且分享了配套的学习资料。在这篇文章主要介绍第二阶段的学习,第二阶段主要是面试书籍,你可能会问视频看完了,为什么还要看书啊,为什么不直接看书呢?对初学者来说,看视频比较直观,能够直接感受到我学这个到底有什么用,能给初学者一个方向。而且,技术类书籍一般都很厚很厚,如果直接看书,可能会导致“从入门到放弃”…所以呢,推荐先看视频。另一个重要的原因是,求职面试本质上还是考试,有考试的地方就有应试技巧,而一些在求职者口中口耳相传的书,就是应试技巧。说来读者不要不信,很多面试官在基础考察的时候都是直接是根据书来问,因为这些面试官也是从学生时代过来的,而他们可能和你看的是同一本书。也就是如果你和面试官的知识体系结构一样的话,面试官问出来的问题,你自然可以回答得很好。下面是书籍推荐:

01
java基础
java核心卷I:java核心卷II可以买也可以不买。java核心卷I只看前9章,其他不用看。身边同学都认为这本书不适合初学者,但是如果你之前看过java学习视频,那么这本书很适合你。这本书主要让你对java知识有个系统的学习,建立起自己的知识体系结构。系统的体系结构在求职面试显得尤为主要,不仅仅是指java基础。所以大家一定要学会总结,零散的知识碎片对面试十分不利。

02
多线程、并发
实战java高并发程序设计和java并发编程的艺术:java高并发程序设计,这本书主要是为了看第二本书做铺垫,直接看第二本书可能会很吃力。实战java高并发程序设计主要看:前4章、5.1、5.2、5.3、5.10、5.11和第6章。第二本书“java并发编程的艺术”除了6.4和10.4相对不是重点,其余每一章都是考点、每一章都是,记住这句话。6.4和10.4建议看一下,不是重点,但是建议看。大家记住并发编程的艺术是重点,基本上上面提到的那些章都是重点。

另外,大家自行找博客补充下协程的概念,公众号后面也会有讲解。近期各大公司都有研究使用协程,面试大概率会问到。协程其实就是单线程里面实现多任务调度,因为是单线程,所以不用锁,自然没有锁的竞争那些问题,效率更高。大家可以去了解下,大概率会被问到。并发编程的艺术可能看第一遍可能迷迷糊糊,都不知道到底讲了什么,这本书断断续续可能要看三遍或者以上,所以一遍看不懂不要灰心,因为大家都这样。

03
java虚拟机
深入理解Java虚拟机:只要看:第2章、第3章、第4章、第5章简单看一看、第六章看6.1和6.2、第7章以及第12和13章。12和13属于并发里面的补充。上面这些都是重点,面试的典型问题,包括之前讲过的GC,内存模型、调优、常用命令、类加载、OOM和stackOverflow等。还有就是对象的生命周期一些,这本书大多是记忆类的,大家多多总结,多翻几遍~

04
数据结构
大话数据结构:这本书通俗易懂,第5章可以不看,其余建议看。第五章,怎么说呢,我和周围同学面试都没被问到过KMP算法,笔试中可能会遇到,但是KMP算法可以解决的问题DP大概率也可以解决,而且KMP算法不简单,对自己要求高的同学可以看看。第七章图,图在面试中基本不会问,但是在笔试中大概率会碰到,所以还得看。但是放心面试中几乎很少问到图的。

05
设计模式
Head First设计模式:建议看前13章,第13章实际是讲MVC模式,这个也要掌握,附录中的模式不想看就别看了。这本书“废话”比较多,图文也很详细,每一种设计模式都有具体的案例,可以帮助你更好的理解设计模式。

学习完一种设计模式后,最好能够找到JDK或者Spring或其他框架源码中的应用,这有助于理解,加深记忆;更重要的是,如果面试官在问你设计模式的时候,你能详细说出几种常用的设计模式,并且给出在JDK或spring或其他框架源码中的应用,以及该模式解决了什么问题之类的,这肯定是加分项,面试官会认为你知识体系结构很完善,对你的印象肯定更加深刻。

常问的设计模式问题有:单例、适配器、装饰者、代理、组合、策略、模板方法、观察者、工厂方法。这几种是重点,其他的模式依旧建议看看,即前13章都建议看。百度面试曾经问过一个问题:装饰者模式、静态代理和动态代理模式的异同;为什么spring的AOP不使用装饰者模式实现等问题。再次强调一点,上面提到的每一种模式必须能够举出一两种应用场景,即JDK、Spring或者其他框架源码的那个地方应用了这些设计模式,并且要能手写出代码实现。百度面试就是这么问的,一定要能举出案例,也问过手写观察者模式;也有很多公司要求在草稿纸上画出各个设计模式的UML图,这个也希望大家掌握,希望引起大家重视。

06
分布式
从Paxos到Zookeeper分布式一致性原理与实践:第1章、第2章,第4章,第五章,第6章,7.1节、7.4节、7.5节,7.6节、7.7节、7.9节,8,4节,8,5节。书名比较长,这本书很重要,因为现在的网站都是分布式,高可用(一台机器坏了会自动由另外一台机器对外提供服务)、分布式锁、分布式队列等等一些相关功能都可以使用zookeeper实现。另外,zookeeper在很多框架中的很多:HBASE、Hadoop、kafka、YARN等等(后面这些框架只是举例)。

在前一篇文章中曾提到过,在项目相关面试问题中,面试官有很大概率会把你的项目往分布式上面延展,而zookeeper可以解决大部分的分布式问题,互联网公司用的也很多。

zookeeper重点掌握:两阶段、三阶段提交、Paxos算法、zookeeper的应用场景(非常非常主要,第六章)、leader选举、watcher机制(最好读一下watcher机制的源码,公众号后面也会有讲解)。第五章是告诉你怎么使用zookeeper的,zookeeper有哪些用途,虽然面试不会直接考,但是必须得看,否则你学了zookeeper,你还不知道zookeeper是怎么使用的…这里的意思是,第五章的那些API你不要去记也没必要,你只要知道它有这么个用途,能决绝什么问题就可以了,具体的API说个名字或者名字说不出来也没关系,面试官一般不会纠结与API的名字,更想提到你对某个知识点自己的理解。第五章那些知识点可能是你项目面试问题的答案,因为zookeeper应用场景很多,但是往大了说就几个大类场景,看数一定要学会自己总结,自己总结的印象更深刻。第五章大概看看浏览就好,最好跟着打一个zookeeper集群,自己亲手操作一下,也不难。
06
数据库
数据库只需要学:MySQL、Redis,对大数据有了解的建议看看HBASE(使用了zookeeper),其他数据库不要学。在上一篇文章中给大家总结了资料,里面有MySQL和Redis的视频,建议看视频学习,视频之后,MySQL推荐“高性能MySQL”,注意这本书好像并不适合初学者,建议先看MySQL视频。Redis看完视频后建议看看博客,Redis主要问:常用数据结构、集群、哨兵、用在哪些场合、解决了什么问题、持久化AOF和RDB。最后,简单的SQL一定要会,面试也有让手写简单的SQL,就SQL立案表层查询那些。

一篇文章搞定ArrayList和LinkedList所有面试问题

在面试中经常碰到:ArrayList和LinkedList的特点和区别?
个人认为这个问题的回答应该分成这几部分:
1介绍ArrayList底层实现
2介绍LinkedList底层实现
3两者个适用于哪些场合
本文也是按照上面这几部分组织的。

ArrayList的源码解析

成员属性源码解析

public class ArrayList<E> 
    extends AbstractList<E>
    implements List<E>, RandomAccess
    ,Cloneable, java.io.Serializable
{
    private static final long 
       serialVersionUID 
       = 8683452581122892189L;

    //默认容量是10
    private static final int 
             DEFAULT_CAPACITY = 10;


    //当传入ArrayList构造器的容量为0时
    //用这个数组表示:容器的容量为0
    private static final Object[] 
           EMPTY_ELEMENTDATA = {};

接上面

/*
主要作为一个标识位,在扩容时区分:
默认大小和容量为0,使用默认容量时采取的
是“懒加载”:即等到add元素的时候才进行实际
容量的分配,后面扩容函数讲解还会提到这点
*/
private static final Object[] 
DEFAULTCAPACITY_EMPTY_ELEMENTDATA={};

//ArrayList底层使用Object数组保存的元素
transient Object[] elementData; 

//记录当前容器中有多少元素
private int size;

构造器源码解析

/*
最常用的构造器之一,实际上就是创建了一个
指定大小的Object数组来保存之后add的元素
*/
public ArrayList(int initialCapacity){
    if (initialCapacity > 0) {
       //初始化保存数据的Object数组
        this.elementData
        =new Object[initialCapacity];
    } else if(initialCapacity==0) {
      //标识容量为0:EMPTY_ELEMENTDATA
        this.elementData 
                  = EMPTY_ELEMENTDATA;
    } else {
        throw new 
        IllegalArgumentException(
        "Illegal Capacity: "+
                initialCapacity);
    }
}


/*
无参构造器,指向的是默认容量大小的Object
数组,注意使用无参构造函数的时候并没有
直接创建容量 为10(默认容量是10)的Object
数组,而是采取懒加载的策略:使用
DEFAULTCAPACITY_EMPTY_ELEMENTDATA,
这个默认数组的容量是0,所以得区分是
默认容量,还是你传给构造器的容量参数大小
本身就是0。在真正执行add操作时才会创建
Object数组,即在扩容函数中有处理默认容量
的逻辑,后面会有详细分析。
*/
    public ArrayList() {
        //这个赋值操作仅仅是标识作用
       this.elementData = 
     DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }

   //省略一部分不常用代码函数

add方法源码解析

/*
add是ArrayList最常用的接口,逻辑很简单
*/
public boolean add(E e) {
 /*
 主要用于标识线程安全,即ArrayList只能
在单线程环境下使用,在多线程环境下会出现并发
安全问题,modCount主要用于记录对ArrayList的
修改次数,如果一个线程操作ArrayList期间
modCount发生了变化即,有多个线程同时修改当前
这个ArrayList,此时会抛出
“ConcurrentModificationException”异常,
这又被称为“failFast机制”,在很多非线程安全的
类中都有failFast机制:HashMap、 LinkedList
等。这个机制主要用于迭代器、加强for循环等相关
功能,也就是一个线程在迭代一个有failfast机制
容器的时候,如果其他线程改变了容器内的元素,
迭代的这个线程会抛 
出“ConcurrentModificationException”异常
*/
    modCount++;

/*
add操作的核心函数,当使用无参构造器时并没有
直接分配大小为10的Object数组,这里面有对应
的处理逻辑。
*/   
    //进入该函数
    add(e, elementData, size);
    return true;
}


private void add(E e,Object[] elementData
                , int s) {
    /*
    如果使用无参构造器:开始时length为0,
    s也为0.grow()核心函数,扩容/初始化操作
    */
    if (s == elementData.length)
        elementData = grow();
    elementData[s] = e;
    size = s + 1;
}

grow相关方法源码解析

private Object[] grow() {
    //继续追踪
    return grow(size + 1);
}


private Object[] grow(int minCapacity){
    /*
  使用数组复制的方式,扩容:将elementData
  所有元素复制到一个新数组中,这个新数组的
  长度是newCapacity()函数的返回值,之后再
  把这个新数组赋值给elementData,完成扩容
  操作
    */
    //进入newCapacity()函数
    return elementData = 
    Arrays.copyOf(elementData,
          newCapacity(minCapacity));
}


//返回的是扩容后数组的长度
private int newCapacity(int minCapacity){
    int oldCapacity=elementData.length;
    //扩容后的容量为原来容量的1.5倍
    int newCapacity = oldCapacity 
            + (oldCapacity >> 1);
    if (newCapacity-minCapacity <=0){
        if (elementData ==
        DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
             //默认容量的处理
             return Math.max(
           DEFAULT_CAPACITY, minCapacity);

      /*
    minCapacity是int类型,有溢出的可能,也就
    是ArrayList最大大小是Integer.MAX_VALUE
      */
        if (minCapacity<0) //overflow
           throw new OutOfMemoryError();
        
        //返回新容量
        return minCapacity;
    }

  /*
  MAX_ARRAY_SIZE=Integer.MAX_VALUE-8,
  当扩容后大于MAX_ARRAY_SIZE ,返回 
  hugeCapacity(minCapacity),
  其实就是Integer.MAX_VALUE
    */
    return (newCapacity-MAX_ARRAY_SIZE
         <= 0)? newCapacity
        : hugeCapacity(minCapacity);
}


private static int hugeCapacity
                (int minCapacity){
    if (minCapacity < 0) // overflow
        throw new OutOfMemoryError();
    return (minCapacity>MAX_ARRAY_SIZE)
        ? Integer.MAX_VALUE
        : MAX_ARRAY_SIZE;
}

ArrayList的failfast机制

//最后看下ArrayList的failFast机制
private class Itr implements 
                Iterator<E>{
    //index of next element to return
    int cursor;      
    // index of last element returned;  
    int lastRet = -1; -1 if no such
    /*
    在迭代之前先保存modCount的值,
    modCount在改变容器元素、容器
    大小时会自增加1
    */
    int expectedModCount=modCount;

    // prevent creating a synthetic
    // constructor
    Itr() {}

    public boolean hasNext() {
        return cursor != size;
    }

    
    @SuppressWarnings("unchecked")
    public E next() {
       /*
       使用迭代器遍历元素的时候先检查
       modCount的值是否等于预期的值,
       进入该函数
       */
        checkForComodification();
        int i = cursor;
        if (i >= size)
            throw new 
            NoSuchElementException();
        Object[] elementData =
          ArrayList.this.elementData;
        if (i >= elementData.length)
            throw new 
          ConcurrentModificationException();
        cursor = i + 1;
        return (E)elementData[lastRet=i];
    }

    
  /*
    可以发现:在迭代期间如果有线程改变了
    容器,此时会抛出
    “ConcurrentModificationException”
    */
   final void checkForComodification(){
        if (modCount!=expectedModCount)
            throw new 
          ConcurrentModificationException();
    }

ArrayList的其他操作,比如:get、remove、indexOf其实就很简单了,都是对Object数组的操作:获取数组某个索引位置的元素,删除数组中某个元素,查找数组中某个元素的位置…所以说理解原理很重要。

上面注释的部分就是ArrayList的考点,主要有:初始容量、最大容量、使用Object数组保存元素(数组与链表的异同)、扩容机制(1.5倍)、failFast机制等。

LinkedList源码分析

成员属性源码分析

public class LinkedList<E>
    extends AbstractSequentialList<E>
    implements List<E>, Deque<E>
    ,Cloneable, java.io.Serializable
{
  MAX_ARRAY_SIZE=Integer.MAX_VALUE-8,
    /*
    LinkedList的size是int类型,但是后面
    会看到LinkedList大小实际只受内存大小
    的限制也就是LinkedList的size大小可能
    发生溢出,返回负数
    transient int size = 0;

    //LinkedList底层使用双向链表实现,
    //并保留了头尾两个节点的引用
    transient Node<E> first;//头节点


    transient Node<E> last;//尾节点
    //省略一部分无关代码

    //下面分析LinkedList内部类Node

内部类Node源码分析

private static class Node {
E item;//元素值
Node next;//后继节点

//前驱节点,即Node是双向链表
Node<E> prev;

Node(Node<E> prev, E element
, Node<E> next) {//Node的构造器
    this.item = element;
    this.next = next;
    this.prev = prev;
}

}

构造器源码分析

//LinkedList无参构造器:什么都没做
public LinkedList() {}

其他核心辅助接口方法源码分析

/*
LinkedList的大部分接口都是基于
                这几个接口实现的:
1.往链表头部插入元素
2.往链表尾部插入元素
3.在指定节点的前面插入一个节点
4.删除链表的头结点
5.删除除链表的尾节点
6.删除除链表中的指定节点
*/

//1.往链表头部插入元素
private void linkFirst(E e) {
    final Node<E> f = first;
    final Node<E> newNode = 
            new Node<>(null, e, f);
    first = newNode;
    if (f == null)
        last = newNode;
    else
        f.prev = newNode;
    size++;
    modCount++;//failFast机制
}

  
//2.往链表尾部插入元素
void linkLast(E e) {
    final Node<E> l = last;
    final Node<E> newNode = 
        new Node<>(l, e, null);
    last = newNode;
    if (l == null)
        first = newNode;
    else
        l.next = newNode;
    size++;
    modCount++;//failFast机制
}

//3.在指定节点(succ)的前面插入一个节点
void linkBefore(E e, Node<E> succ) {
    // assert succ != null;
    final Node<E> pred = succ.prev;
    final Node<E> newNode 
        = new Node<>(pred, e, succ);
    succ.prev = newNode;
    if (pred == null)
        first = newNode;
    else
        pred.next = newNode;
    size++;
    modCount++;//failFast机制
}

//4.删除链表的头结点
private E unlinkFirst(Node<E> f){
    //assert f==first && f!=null;
    final E element = f.item;
    final Node<E> next = f.next;
    f.item = null;
    f.next = null; //help GC
    first = next;
    if (next == null)
        last = null;
    else
        next.prev = null;
    size--;
    modCount++;//failFast机制
    return element;
}


//5.删除除链表的尾节点
private E unlinkLast(Node<E> l) {
    //assert l==last && l!=null;
    final E element = l.item;
    final Node<E> prev = l.prev;
    l.item = null;
    l.prev = null; // help GC
    last = prev;
    if (prev == null)
        first = null;
    else
        prev.next = null;
    size--;
    modCount++;//failFast机制
    return element;
}


//6.删除除链表中的指定节点
E unlink(Node<E> x) {
    // assert x != null;
    final E element = x.item;
    final Node<E> next = x.next;
    final Node<E> prev = x.prev;

    if (prev == null) {
        first = next;
    } else {
        prev.next = next;
        x.prev = null;
    }

    if (next == null) {
        last = prev;
    } else {
        next.prev = prev;
        x.next = null;
    }

    x.item = null;
    size--;
    modCount++;//failFast机制
        return element;
    }

常用API源码分析

//LinkedList常用接口的实现
public E removeFirst() {
    final Node<E> f = first;
    if (f == null)
        throw 
        new NoSuchElementException();
    //调用 4.删除链表的头结点 实现
        return unlinkFirst(f);
  }

public E removeLast() {
    final Node<E> l = last;
    if (l == null)
        throw 
        new NoSuchElementException();
     //调用 5.删除除链表的尾节点 实现
         return unlinkLast(l);
    }

   public void addFirst(E e) {
       //调用 1.往链表头部插入元素 实现
       linkFirst(e);
    }

   public void addLast(E e) {
       //调用 2.往链表尾部插入元素 实现
       linkLast(e);
}

public boolean add(E e) {
    //调用 2.往链表尾部插入元素 实现
    linkLast(e);
    return true;
    }

public boolean remove(Object o) {
    if (o == null) {
        for (Node<E> x = first;
         x != null; x = x.next) {
            if (x.item == null) {
         //调用 6.删除除链表中的
         //指定节点 实现
                unlink(x);
                return true;
            }
        }
    } else {
        for (Node<E> x = first
        ; x != null; x = x.next) {
            if (o.equals(x.item)) {
                //调用 6.删除除链表中的
                //指定节点 实现
                unlink(x);
                return true;
            }
        }
    }
    return false;
    }


//省略其他无关函数

failfast机制

//迭代器中的failFast机制
private class ListItr 
        implements ListIterator<E> {
    private Node<E> lastReturned;
    private Node<E> next;
    private int nextIndex;

    /*
    在迭代之前先保存modCount的值,
    modCount在改变容器元素、容器大小时
    会自增加1
    */
    private int expectedModCount
                 = modCount;

    ListItr(int index) {
        next = (index == size) 
            ? null : node(index);
        nextIndex = index;
    }

    public boolean hasNext() {
        return nextIndex < size;
    }

    public E next() {
        /*
        使用迭代器遍历元素的时候先检查
        modCount的值是否等于预期的值,
        进入该函数
        */
        checkForComodification();
        if (!hasNext())
            throw 
            new NoSuchElementException();

        lastReturned = next;
        next = next.next;
        nextIndex++;
        return lastReturned.item;
    }

     /*
    可以发现:在迭代期间如果有线程改变了容器,
    此时会抛出
    “ConcurrentModificationException”
    */
     final void checkForComodification(){
          if (modCount!=expectedModCount)
              throw new 
           ConcurrentModificationException();
        }

LinkedList的实现较为简单:底层使用双向链表实现、保留了头尾两个指针、LinkedList的其他操作基本都是基于上面那六个函数实现的,另外LinkedList也有failFast机制,这个机制主要在迭代器中使用。

数组和链表各自的特性

 数组和链表的特性差异,本质是:连续空间存储和非连续空间存储的差异。主要有下面两点:     

ArrayList:底层是Object数组实现的:由于数组的地址是连续的,数组支持O(1)随机访问;数组在初始化时需要指定容量;数组不支持动态扩容,像ArrayList、Vector和Stack使用的时候看似不用考虑容量问题(因为可以一直往里面存放数据);但是它们的底层实际做了扩容;数组扩容代价比较大,需要开辟一个新数组将数据拷贝进去,数组扩容效率低;适合读数据较多的场合。
LinkedList:底层使用一个Node数据结构,有前后两个指针,双向链表实现的。相对数组,链表插入效率较高,只需要更改前后两个指针即可;另外链表不存在扩容问题,因为链表不要求存储空间连续,每次插入数据都只是改变last指针;另外,链表所需要的内存比数组要多,因为他要维护前后两个指针;它适合删除,插入较多的场景LinkedList还实现了Deque接口。

一篇文章彻底读懂HashMap之HashMap源码解析(上_)

就身边同学的经历来看,HashMap是求职面试中名副其实的“明星”,基本上每一加公司的面试多多少少都有问到HashMap的底层实现原理、源码等相关问题。
在秋招面试准备过程中,博主阅读过很多关于HashMap源码分析的文章,漫长的拼凑式阅读之后,博主没有看到过一篇能够通俗易懂、透彻讲解HashMap源码的文章(可能是博主没有找到)。秋招结束后,国庆假期抽空写下了这篇文章,用一种通俗易懂的方式向读者讲解HashMap源码,并且尽量涵盖面试中HashMap的所有考察点。希望能够对后面的求职者有所帮助~

这篇文章将会按以下顺序来组织:
1HashMap源码分析(JDK8,通俗易懂)
2HashMap面试“明星”问题汇总,以及明星问题答案

下面是JDK8中HashMap的源码分析,在下文源码分析中:

    注释多少与重要性成正比

    注释多少与重要性成正比

    注释多少与重要性成正比

HashMap的成员属性源码分析

public class HashMap<K,V> 
   extends AbstractMap<K,V>
   implements Map<K,V>,
   Cloneable, Serializable {

    private static final long serialVersionUID
                   = 362498820763181265L;

    //HashMap的初始容量为16,HashMap的
    //容量指的是存储元素的数组大小,
    //即桶的数量
    static final int DEFAULT_INITIAL_CAPACITY 
                     = 1 << 4; 

    //HashMap的最大的容量
    static final int MAXIMUM_CAPACITY
                         = 1 << 30;                   

接上面:

//下面有详细解析
static final float DEFAULT_LOAD_FACTOR
                     = 0.75f;
//当某一个桶中链表的长度>=8时,链表结构会转换成
//红黑树结构,其实还要求桶的中数量>=64,后面会提到
static final int TREEIFY_THRESHOLD = 8;

//当红黑树中的节点数量<=6时,红黑树结构会转变为
//链表结构
static final int UNTREEIFY_THRESHOLD = 6;

//上面提到的:当Node数组容量>=64的前提下,如果
//某一个桶中链表长度>=8,则会将链表结构转换成
//红黑树结构
static final int MIN_TREEIFY_CAPACITY = 64;
} 

DEFAULT_LOAD_FACTOR:HashMap的负载因子,影响HashMap性能的参数之一,是时间和空间之间的权衡,后面会看到HashMap的元素存储在Node数组中,这个数组的大小这里称为“桶”的大小。另外还有一个参数size指的是我们往HashMap中put了多少个元素。当size>桶的数量*DEFAULT_LOAD_FACTOR的时候,这时HashMap要进行扩容操作,也就是桶不能装满。DEFAULT_LOAD_FACTOR是衡量桶的利用率:
DEFAULT_LOAD_FACTOR较小时(桶的利用率较小),这时浪费的空间较多(因为只能存储桶的数量DEFAULT_LOAD_FACTOR个元素,超过了就要进行扩容),这种情况下往HashMap中put元素时发生冲突的概率也很小,所谓冲突指的是:多个元素被put到了同一个桶中;冲突小时(可以认为一个桶中只有一个元素)put、get等HashMap的操作代价就很低,可以认为是O(1);
DEFAULT_LOAD_FACTOR很大时,桶的利用率较大的时候(注意可以大于1,因为冲突的元素是使用链表或者红黑树连接起来的),此时空间利用率较高,这也意味着一个桶中存储了很多元素,这时HashMap的put、get等操作代价就相对较大,因为每一个put或get操作都变成了对链表或者红黑树的操作,代价肯定大于O(1),所以说DEFAULT_LOAD_FACTOR是空间和时间的一个平衡点;
DEFAULT_LOAD_FACTOR较小时,需要的空间较大,但是put和get的代价较小;
DEFAULT_LOAD_FACTOR较大时,需要的空间较小,但是put和get的代价较大)。
扩容操作就是把桶的数量2,即把Node数组的大小调整为扩容前的2倍,至于为什么是两倍,分析扩容函数时会讲解,这其实是一个trick,细节后面会详细讲解。Node数组中每一个桶中存储的是Node链表,当链表长度>=8的时候并且Node数组的大小>=64,链表会变为红黑树结构(因为红黑树的增删改查复杂度是logn,链表是n,红黑树结构比链表代价更小)。

HashMap内部类——Node源码分析

//Node是HashMap的内部类
static class Node<K,V> 
     implements Map.Entry<K,V> {
        final int hash; 
        final K key;//保存map中的key
        V value;//保存map中的value
        Node<K,V> next;//单向链表
        
        //构造器
        Node(int hash, K key, V value 
             ,Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

HashMap的内部类Node:HashMap的所有数据都保存在Node数组中那么这个Node到底是个什么东西呢?
Node的hash属性:保存key的hashcode的值:key的hashcode ^ (key的hashcode>>>16)。这样做主要是为了减少hash冲突当我们往map中put(k,v)时,这个k,v键值对会被封装为Node,那么这个Node放在Node数组的哪个位置呢:index=hash&(n-1),n为Node数组的长度。那为什么这样计算hash可以减少冲突呢?如果直接使用hashCode&(n-1)来计算index,此时hashCode的高位随机特性完全没有用到,因为n相对于hashcode的值很小,计算index的时候只能用到低16位。基于这一点,把hashcode高16位的值通过异或混合到hashCode的低16位,由此来增强hashCode低16位的随机性。

HashMap hash函数分析

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : 
     (h = key.hashCode()) ^ (h >>> 16);
    }

HashMap允许key为null,null的hash为0(也意味着HashMap允许key为null的键值对),非null的key的hash高16位和低16位分别由由:key的hashCode高16位和hashCode的高16位异或hashCode的低16位组成。主要是为了增强hash的随机性减少hash&(n-1)的随机性,即减小hash冲突,提高HashMap的性能。所以作为HashMap的key的hashCode函数的实现对HashMap的性能影响较大,极端情况下:所有key的hashCode都相同,这是HashMap的性能很糟糕!

HashMap tableSizeFor函数源码分析

static final int tableSizeFor(int cap) {
    //举例而言:n的第三位是1(从高位开始数), 
    int n = cap - 1;

    n |= n >>> 1; 
    n |= n >>> 2; 
    n |= n >>> 4; 
    n |= n >>> 8; 
    n |= n >>> 16; 

    return (n < 0) ? 1
         : (n >= MAXIMUM_CAPACITY) 
         ? MAXIMUM_CAPACITY : n + 1;
}

在new HashMap的时候,如果我们传入了大小参数,这是HashMap会对我们传入的HashMap容量进行传到tableSizeFor函数处理:这个函数主要功能是:返回一个数:这个数是大于等于cap并且是2的整数次幂的所有数中最小的那个,即返回一个最接近cap(>=cap),并且是2的整数次幂的数。
具体逻辑如下:一个数是2的整数次幂,那么这个数减1的二进制就是一串掩码,即二进制从某位开始是一 串连续的1。所以只要对的对应的掩码,掩码+1一定是2的整数次幂,这也是为什么n=cap-1的原因。
举例而言,假设:
n=00010000_00000000_00000000

n |= n >>> 1;//执行完后
//n=00011000_00000000_00000000

n |= n >>> 2;//执行完后
//n= 00011110_00000000_00000000

n |= n >>> 4;//执行完后
//n= 00011111_11100000_00000000

n |= n >>> 8;//执行完后
//n= 00011111_11111111_11100000

n |= n >>> 16;//执行完后
//n=00011111_11111111_11111111

返回n+1,(n+1)>=cap、为2的整数次幂,并且是与cap差值最小的那个数。最后的n+1一定是2的整数次幂,并且一定是>=cap。
整体的思路就是:如果n的二进制的第k为1,那么经过上面四个‘|’运算后[0,k]位都变成了1,即:一连串连续的二进制‘1’(掩码),最后n+1一定是2的整数次幂(如果不溢出)。

HashMap成员属性源码分析

//我们往map中put的(k,v)都被封装在Node中,
//所有的Node都存放在table数组中
transient Node<K,V>[] table;

//用于返回keySet和values
transient Set<Map.Entry<K,V>> entrySet;

//保存map当前有多少个元素
    transient int size;

//failFast机制,在讲解ArrayList
//和LinkedList一文中已经讲过了
transient int modCount;

threshold属性分析

int threshold;//下面有详细讲解

//负载因子,见上面对DEFAULT_LOAD_FACTOR
//参数的讲解,默认值是0.75
final float loadFactor;

threshold也是比较重要的一个属性:
创建HashMap时,该变量的值是:初始容量(2的整数次幂),之后threshold的值是HashMap扩容的门限值:即当前Nodetable数组的长度* loadfactor。举个例子而言,如果我们传给HashMap构造器的容量大小为9,那么threshold初始值为16,在向HashMap中put第一个元素后,内部会创建长度为16的Node数组,并且threshold的值更新为160.75=12。具体而言,当我们一直往HashMap put元素的时候,如果put某个元素后,Node数组中元素个数为13,此时会触发扩容(因为数组中元素个数>threshold了,即13>threshold=12),具体扩容操作之后会详细分析,简单理解就是,扩容操作将Node数组长度2;并且将原来的所有元素迁移到新的Node数组中。

HashMap构造器源码分析

//构造器:指定map的大小,和loadfactor
public HashMap(int initialCapacity
            , float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException
        ("Illegal initial capacity: " +
                  initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0    
       || Float.isNaN(loadFactor))
        throw new IllegalArgumentException(
        "Illegal load factor: " + loadFactor);
       //保存loadfactor
       this.loadFactor = loadFactor;

    /*注意,前面有讲tableSizeFor函数,
    该函数返回值:>=initialCapacity、
    返回值是2的整数次幂,并且得是满足
    上面两个条件的所有数值中最小的那个数。
     */
    this.threshold = 
         tableSizeFor(initialCapacity);
}
/*
只指定HashMap容量的构造器,
loadfactor使用的是
默认的值:0.75
   */
public HashMap(int initialCapacity) {
    this(initialCapacity
          , DEFAULT_LOAD_FACTOR);
}

//无参构造器,默认loadfactor:0.75,
//默认的容量是16
public HashMap() {
    this.loadFactor 
          = DEFAULT_LOAD_FACTOR; 
}
//其他不常用的构造器就不分析了

从构造器中我们可以看到:HashMap是“懒加载”,在构造器中值保留了相关保留的值,并没有初始化table数组,当我们向map中put第一个元素的时候,map才会进行初始化!

HashMap的get函数源码分析

//入口,返回对应的value
public V get(Object key) {
    Node<K,V> e;
        
    //hash函数上面分析过了
    return (e = getNode(hash(key), key))
            == null
            ? null : e.value;
    }
  get函数实质就是进行链表或者红黑树遍历搜索指定key的节点的过程;另外需要注意到HashMap的get函数的返回值不能判断一个key是否包含在map中,get返回null有可能是不包含该key;也有可能该key对应的value为null。HashMap中允许key为null,允许value为null。

getNode函数源码分析

//下面分析getNode函数
final Node<K,V> getNode(int hash
                 , Object key) {
    Node<K,V>[] tab;
    Node<K,V> first, e;
    int n; K k;
    if ((tab = table) != null
        && (n = tab.length) > 0
        &&(first=tab[(n-1)&hash])
        != null) {
        if (first.hash == hash&&  
           ((k = first.key) == key
            || (key != null 
            && key.equals(k))))
            //一次就匹配到了,直接返回,
            //否则进行搜索
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                //红黑树搜索/查找
                return ((TreeNode<K,V>)first)
                    .getTreeNode(hash, key);
            do {
                //链表搜索(查找)
                if (e.hash == hash &&
                    ((k = e.key) == key
                    || (key != null 
                    && key.equals(k))))
                    return e;//找到了就返回
            } while ((e = e.next) != null);
        }
    }
    return null;//没找到,返回null
}

注意getNode返回的类型是Node:当返回值为null时表示map中没有对应的key,注意区分value为null:如果key对应的value为null的话,体现在getNode的返回值e.value为null,此时返回值也是null,也就是HashMap的get函数不能判断map中是否有对应的key:get返回值为null时,可能不包含该key,也可能该key的value为null!那么如何判断map中是否包含某个key呢?见下面contains函数分析。getNode函数细节分析:
(n-1)&hash:当前key可能在的桶索引,put操作时也是将Node存放在index=(n-1)&hash位置。
getNode的主要逻辑:如果table[index]处节点的key就是要找的key则直接返回该节点; 否则:如果在table[index]位置进行搜索,搜索是否存在目标key的Node:这里的搜索又分两种:链表搜索和红黑树搜索,具体红黑树的查找就不展开了,红黑树是一种弱平衡(相对于AVL)BST,红黑树查找、删除、插入等操作都能够保证在O(lon(n))时间复杂度内完成,红黑树原理不在本文范围内,但是大家要知道红黑树的各种操作是可以实现的,简单点可以把红黑树理解为BST,BST的查找、插入、删除等操作的实现在之前的文章中有
BST java实现讲解,红黑树实际上就是一种平衡的BST。

contains函数源码分析:

public boolean containsKey(Object key) {
    //注意与get函数区分,我们往map中put的
    //所有的<key,value>都被封装在Node中,
    //如果Node都不存在显然一定不包含对应的key
    return getNode(hash(key), key) != null;
}   

一篇文章彻底读懂HashMap之HashMap源码解析(下)

JDK8 HashMap源码解析,一篇文章彻底读懂HashMap(下)

put函数源码解析

//put函数入口,两个参数:key和value
public V put(K key, V value) {
    /*下面分析这个函数,注意前3个参数,后面
    2个参数这里不太重要,因为所有的put
    操作后面的2个参数默认值都一样 */
    return putVal(hash(key), key, 
              value, false, true);
    }
    
//下面是put函数的核心处理函数
final V putVal(int hash, K key, V value
               , boolean onlyIfAbsent
               ,boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; 
    int n, i;
    /*上面提到过HashMap是懒加载,所有
    put的时候要先检查table数组是否已经
    初始化了,没有初始化得先初始化table
    数组,保证table数组一定初始化了 */
    if ((tab = table) == null
       || (n = tab.length) == 0)
        //这个函数后面有resize函数分析
        n = (tab = resize()).length;

    /*到这里表示table数组一定初始化了
    与上面get函数相同,指定key的Node,
    会存储在在table数组的i=(n-1)&hash
    下标位置,get的时候也是从table数组
    的该位置搜索 */
    if ((p = tab[i = (n - 1) & hash])
         == null)
        /*如果i位置还没有存储元素,则把
        当前的key,value封装为Node,
        存储在table[i]位置  */
        tab[i] = newNode(hash, key
                    , value, null);
    else {
         //下面部分代码接上这部分
     }

接上面else部分:

/*
如果table[i]位置已经有元素了,
则接下来的流程是:
首先判断链表或者二叉树中是否
已经存在key的键值对?
存在的话就更新它的value;不存在
的话把当前的key,value插入到
链表的末尾或者插入到红黑树中
如果链表或者红黑树中已经存在
Node.key等于key,则e指向该Node,
即e指向一个Node:该Node的key属性
与put时传入的key参数相等的那个Node,
后面会更新e.value
 */

Node<K,V> e; K k;
/*
为什么get和put先判断p.hash==hash,
下面的if条件中去掉hash的比较逻辑
也是正确?因为hash的比较是两个整数
的比较,比较的代价相对较小,key是泛型,
对象的比较比整数比较代价大,所以先比较
hash,hash相等再比较key
*/
if(p.hash == hash &&
  ((k = p.key) == key 
  || (key != null
  && key.equals(k))))
  /*
  e指向一个Node:该Node的key
  属性与put时传入的key参数相等
  的那个Node
              */
      e = p;
 else if (p instanceof TreeNode)
    /*
    红黑树的插入操作,如果已经存在
    该key的TreeNode,则返回该
    TreeNode,否则返回null
     */
     e = ((TreeNode<K,V>)p)
         .putTreeVal(this
         , tab, hash, key, value);
 else {
 /*
 table[i]处存放的是链表,接下来和
 TreeNode类似在遍历链表过程中先判断
 当前的key是否已经存在,如果存在则令
 e指向该Node;否则将该Node插入到链
 表末尾,插入后判断链表长度是否>=8,
 是的话要进行额外操作
  */

     //binCountt最后的值是链表的长度
     for (int binCount = 0;
                  ;++binCount) {
         if ((e = p.next) == null) {
        /*
        遍历到了链表最后一个元素,接下来
        执行链表的插入操作,先封装为Node,
        再插入p指向的是链表最后一个节点,
        将待插入的Node置为p.next,
        就完成了单链表的插入
          */
             p.next = newNode(hash, key
                       , value, null);
             if (binCount 
                >= TREEIFY_THRESHOLD - 1)
             /*
             TREEIFY_THRESHOLD值是8,
             binCount>=7,然后又插入了一个新节
             点,链表长度>=8,这时要么进行扩容
             操作,要么把链表结构转为红黑树结构。
             我们接下会分析treeifyBin的源码实现
                                         */
             treeifyBin(tab, hash);
             break;
         }

         /*
          当p不是指向链表末尾的时候:先判断
          p.key是否等于key,等于的话表示
          当前key已经存在了,令e指向p,
          停止遍历,最后会更新e的value;
          不等的话准备下次遍历,
          令p=p.next,即p=e。 
                      */
         if (e.hash == hash &&
             ((k = e.key) == key 
             || (key != null
             && key.equals(k))))
             break;
         p = e;
     }
 }


 if (e != null) {
 /*
表示当前的key在put之前已经
存在了,并且上面的逻辑保证:
e已经指向了之前已经存在
的Node,这时更新
e.value就好。
      */

     //更新oldvalue
     V oldValue = e.value;

     /*
     onlyIfAbsent默是false,
     evict为true。
     onlyIfAbsent为true表示:
     如果之前已经存在key这个键值对了,
     那么后面再put这个key时,忽略这个
     操作,不更新先前的value。
     这里了解就好 
               */
     if (!onlyIfAbsent 
         || oldValue == null)
         //更新e.value
         e.value = value;

     /*
    这个函数的默认实现是“空”,
    即这个函数默认什么操作都
    不执行,那为什么要有它呢?
    这其实是个hook/钩子函数,
    主要要在LinkedHashMap
    (HashMap子类)中使用,
    LinkedHashMap重写了这
    个函数。以后会有讲解
    LinkedHashMap的文章。
             */
     afterNodeAccess(e);
     //返回旧的value
     return oldValue;
 }
 }

 //如果是第一次插入key这个键,
//就会执行到这里
 ++modCount;//failFast机制

 /*
 size保存的是当前HashMap中保存
 了多少个键值对,HashMap的size
 方法就是直接返回size之前说过,
 threshold保存的是当前table数
 组长度*loadfactor,如果table
 数组中存储的Node数量大于
 threshold,这时候会进行扩容,
 即将table数组的容量翻倍。
 后面会详细讲解resize方法。
   */
 if (++size > threshold)
     resize();

 //这也是一个hook函数,作用和
 //afterNodeAccess一样
     afterNodeInsertion(evict);
     return null;
 }  

treeifyBin源码解析

//将链表转换为红黑树结构,在链表的
//插入操作后调用
final void treeifyBin
              (Node<K,V>[] tab
               , int hash) {
    int n, index; 
    Node<K,V> e;

    /*MIN_TREEIFY_CAPACITY值
    是64,也就是当链表长度>8的
    时候,有两种情况:如果table
    数组的长度<64,此时进行扩容
    操作;如果table数组的长度>64,
    此时进行链表转红黑树结构的操作.
    具体转细节在面试中几乎没有问的,
    这里不细讲了,大部同学认为链表长度
    >8一定会转换成红黑树,这是不对的!
    */
    if (tab == null || 
    (n = tab.length)
     < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e=tab[index=(n-1) 
                       & hash])
             != null) {
        TreeNode<K,V> hd = null, 
                      tl = null;
        do {
            TreeNode<K,V> p =
                replacementTreeNode(e
                , null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
} 

HashMap的resize函数源码分析

重点中的重点,面试谈到HashMap必考resize相关知识,整体思路介绍:

有两种情况会调用当前函数:

1.之前说过HashMap是懒加载,第一次hHashMap的put方法的时候table还没初始化,这个时候会执行resize,进行table数组的初始化,table数组的初始容量保存在threshold中(如果从构造器中传入的一个初始容量的话),如果创建HashMap的时候没有指定容量,那么table数组的初始容量是默认值:16。即,初始化table数组的时候会执行resize函数

2.扩容的时候会执行resize函数,当size的值>threshold的时候会触发扩容,即执行resize方法,这时table数组的大小会翻倍。

注意我们每次扩容之后容量都是翻倍( *2),所以HashMap的容量一定是2的整数次幂,那么HashMap的容量为什么一定得是2的整数次幂呢?(面试重点)。

要知道原因,首先回顾我们put key的时候,每一个key会对应到一个桶里面,桶的索引是这样计算的: index = hash & (n-1),index的计算最为直观的想法是:hash%n,即通过取余的方式把当前的key、value键值对散列到各个桶中;那么这里为什么不用取余(%)的方式呢?

原因是CPU对位运算支持较好,即位运算速度很快。另外,当n是2的整数次幂时:hash&(n-1)与hash%(n-1)是等价的,但是两者效率来讲是不同的,位运算的效率远高于%运算。

基于上面的原因,HashMap中使用的是hash&(n-1)。这还带来了一个好处,就是将旧数组中的Node迁移到扩容后的新数组中的时候有一个很方便的特性:

HashMap使用table数组保存Node节点,所以table数组扩容的时候(数组扩容一定得是先重新开辟一个数组,然后把就数组中的元素重新散列(rehash)到新数组中去。

这里举一个例子来来说明这个特性:下面以Hash初始容量n=16,默认loadfactor=0.75举例(其他2的整数次幂的容量也是类似的),默认容量:n=16,二进制:10000;n-1:15,n-1二进制:01111。某个时刻,map中元素大于16*0.75=12,即size>12。此时会发生扩容,即会新建了一个数组,容量为扩容前的两倍,newtab,len=32。

接下来我们需要把table中的Node搬移(rehash)到newtab。从table的i=0位置开始处理,假设我们当前要处理table数组i索引位置的node,那这个node应该放在newtab的那个位置呢?下面的hash表示node.key对应的hash值,也就等于node.hash属性值,另外为了简单,下面的hash只写出了8位(省略的高位的0),实际上hash是32位:node在newtab中的索引:

index = hash % len=hash & (len-1)

=hash & (32 - 1)=hash & 31

=hash & (0x0001_1111);

再看node在table数组中的索引计算:

i = hash & (16 - 1) = hash & 15

= hash & (0x0000_1111)。

注意观察两者的异同:

i = hash&(0x0000_1111);

index = hash&(0x0001_1111)

上面表达式有个特点:

index = hash & (0x0001_1111)

= hash & (0x0000_1111)

| hash & (0x0001_0000)

= hash & (0x0000_1111) | hash & n)

= i + ( hash & n)

什么意思呢:

hash&n要么等于n要么等于0;也就是:inde要么等于i,要么等于i+n;再具体一点:当hash&n==0的时候,index=i;

当hash&n==n的时候,index=i+n;这有什么用呢?当我们把table[i]位置的所有Node迁移到newtab中去的时候:

这里面的node要么在newtab的i位置(不变),要么在newtab的i+n位置;也就是我们可以这样处理:把table[i]这个桶中的node拆分为两个链表l1和类:如果hash&n==0,那么当前这个node被连接到l1链表;否则连接到l2链表。这样下来,当遍历完table[i]处的所有node的时候,我们得到两个链表l1和l2,这时我们令newtab[i]=l1,newtab[i+n]=l2,这就完成了table[i]位置所有node的迁移/rehash,这也是HashMap中容量一定的是2的整数次幂带来的方便之处。

下面的resize的逻辑就是上面讲的那样。将table[i]处的Node拆分为两个链表,这两个链表再放到newtab[i]和newtab[i+n]位置.

resize方法源码解析

final Node<K,V>[] resize() {
    //保留扩容前数组引用
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) 
              ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //正常扩容:newCap = oldCap << 1
        else if ((newCap = oldCap << 1)
                 < MAXIMUM_CAPACITY 
                 && oldCap 
                 >= DEFAULT_INITIAL_CAPACITY)
            //容量翻倍,扩容后的threshold
            //自然也是*2
            newThr = oldThr << 1; 
    }
    else if (oldThr > 0) 
    // initial capacity was placed 
    //in threshold
       newCap = oldThr;
    else {
       // zero initial threshold 
       //signifies  using defaults
       //table数组初始化的时候会进入到这里
  
       //默认容量
        newCap = DEFAULT_INITIAL_CAPACITY;
        //threshold
        newThr = (int)(DEFAULT_LOAD_FACTOR
               * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        float ft = (float)newCap*loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY 
        && ft < (float)MAXIMUM_CAPACITY ?
               (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;//更新threshold
    @SuppressWarnings({"rawtypes"
                 ,"unchecked"})
    //扩容后的新数组
    Node<K,V>[] newTab = (Node<K,V>[])
                         new Node[newCap];
    table = newTab;//执行容量翻倍的新数组
    if (oldTab != null) {
    //之后完成oldTab中Node迁移到table中去
            //见下面
            }
        }
    }
    return newTab;
}    
}

接上面部分

//之后完成oldTab中Node迁移到table中去
for (int j = 0; j < oldCap; ++j) {
    Node<K,V> e;
    if ((e = oldTab[j]) != null) {
        oldTab[j] = null;
        if (e.next == null)
        /*j这个桶位置只有一个元素,直接
        rehash到table数组 */
            newTab[e.hash 
                  & (newCap - 1)] = e;
        else if (e instanceof TreeNode)
      /*如果是红黑树:也是将红黑树拆分为
      两个链表,这里主要看链表的拆分,
      两者逻辑一样*/
            ((TreeNode<K,V>)e).split(
            this, newTab, j, oldCap);
        else { 
            //链表的拆分
            //第一个链表l1
            Node<K,V> loHead = null
                    , loTail = null;

            //第二个链表l2
            Node<K,V> hiHead = null
                      , hiTail = null;
            Node<K,V> next;
            do {
                next = e.next;
                if ((e.hash & oldCap)
                    == 0) {
                /*rehash到table[j]位置
                将当前node连接到l1上  */
                    if (loTail == null)
                        loHead = e;
                    else
                        loTail.next = e;
                    loTail = e;
                }
                else {
                  //将当前node连接到l2上
                    if (hiTail == null)
                        hiHead = e;
                    else
                        hiTail.next = e;
                    hiTail = e;
                }
            } while ((e = next) != null);

            if (loTail != null) {
                //l1放到table[j]位置
                loTail.next = null;
                newTab[j] = loHead;
            }
            if (hiTail != null) {
           //l1放到table[j+oldCap]位置
                hiTail.next = null;
                newTab[j + oldCap] = hiHead;
            }
        }
    }
}

HashMap面试“明星”问题汇总,及答案

你知道HashMap吗,请你讲讲HashMap?
这个问题不单单考察你对HashMap的掌握程度,也考察你的表达、组织问题的能力。个人认为应该从以下几个角度入手(所有常见HashMap的考点问题总结):
size必须是2的整数次方原因
get和put方法流程
resize方法
影响HashMap的性能因素(key的hashCode函数实现、loadFactor、初始容量)
HashMap key的hash值计算方法以及原因(见上面hash函数的分析)
HashMap内部存储结构:Node数组+链表或红黑树
table[i]位置的链表什么时候会转变成红黑树(上面源码中有讲)
HashMap主要成员属性:threshold、loadFactor、HashMap的懒加载
HashMap的get方法能否判断某个元素是否在map中
HashMap线程安全吗,哪些环节最有可能出问题,为什么?
HashMap的value允许为null,但是HashTable和ConcurrentHashMap的valued都不允许为null,试分析原因?
HashMap中的hook函数(在后面讲解LinkedHashMap时会讲到,这也是面试时拓展的一个点)

上面问题的答案都可以在上面的源码分析中找到,下面在给三点补充:
1HashMap的初始容量是怎样影响HashMap的性能的?
假如你预先知道最多往HashMap中存储64个元素,那么你在创建HashMap的时候:如果选用无参构造器:默认容量16,在存储16loadFactor个元素之后就要进行扩容(数组扩容涉及到连续空间的分配,Node节点的rehash,代价很高,所以要尽量避免扩容操作);如果给构造器传入的参数是64,这时HashMap中在存储64loadFactor个元素之后就要进行扩容;但是如果你给构造器传的参数为:(int)(64/0.75)+1,此时就可以保证HashMap不用进行扩容,避免了扩容时的代价。

2HashMap线程安全吗,哪些环节最有可能出问题,为什么?
我们都知道HashMap线程不安全,那么哪些环节最优可能出问题呢,及其原因:没有参照这个问题有点不好直接回答,但是我们可以找参照啊,参照:ConcurrentHashMap,因为大家都知道HashMap不是线程安全的,ConcurrentHashMap是线程安全的,对照ConcurrentHashMap,看看ConcurrentHashMap在HashMap的基础之上增加了哪些安全措施,这个问题就迎刃而解了。后面会有分析ConcurrentHashMap的文章,这里先简要回答这个问题:HashMap的put操作是不安全的,因为没有使用任何锁;HashMap在多线程下最大的安全隐患发生在扩容的时候,想想一个场合:HashMap使用默认容量16,这时100个线程同时往HashMap中put元素,会发生什么?扩容混乱,因为扩容也没有任何锁来保证并发安全,另外,后面的博文会讲到ConcurrentHashMap的并发扩容操作是ConcurrentHashMap的一个核心方法。

3HashMap的value允许为null,但是HashTable和ConcurrentHashMap的value 都不允许为null,试分析原因?
首先要明确ConcurrentHashMap和Hashtable从技术从技术层面讲是可以允许value为null;但是它是实际是不允许的,这肯定是为了解决一些问题,为了说明这个问题,我们看下面这个例子(这里以ConcurrentHashMap为例,HashTable也是类似)。
HashMap由于允value为null,get方法返回null时有可能是map中没有对应的key;也有可能是该key对应的value为null。所以get不能判断map中是否包含某个key,只能使用contains判断是否包含某个key。
看下面的代码段,要求完成这个一个功能:如果map中包含了某个key则返回对应的value,否则抛出异常:

if (map.containsKey(k)) {
   return map.get(k);
} else {
   throw new KeyNotPresentException();
}

如果上面的map为HashMap,那么没什么问题,因为HashMap本来就是线程不安全的,如果有并发问题应该用ConcurrentHashMap,所以在单线程下面可以返回正确的结果
如果上面的map为ConcurrentHashMap,此时存在并发问题:在map.containsKey(k)和map.get之间有可能其他线程把这个key删除了,这时候map.get就会返回null,而ConcurrentHashMap中不允许value为null,也就是这时候返回了null,一个根本不允许出现的值?

但是因为ConcurrentHashMap不允许value为null,所以可以通过map.get(key)是否为null来判断该map中是否包含该key,这时就没有上面的并发问题了。

一篇文章彻底搞定所有GC面试问题

众所周知,在C++,内存的管理是程序员的任务,包括对象的创建和回收(内存的申请和释放),而在java中,我们可以通过以下四种方式创建对象(面试考点):
1new关键字创建对象
2clone方法克隆产生对象
3反序列化获得对象
4通过反射创建对象

而在java中对象的回收主要是GC完成:GC会在合适的时间被触发,完成垃圾回收,将不需要的内存空间回收释放,避免无限制的内存增长导致的OOM。由此可以看出,GC在java相关的应用程序中重要性,这也是为什么面试官热衷GC相关的面试问题。大部分面试,GC相关问题都是这样开始的:“你知道GC吗”?、“你了解GC机制吗”?
上面的类似提问该从何处着手呢?往下看之前,建议读者先思考:你是如何组织这个问题的回答的?这类似很“宽泛”的问题,其实并不容易回答好,会给人一种:我明明知道相关知识点,但是却又好像无话可说。比如说GC,它就是用来垃圾回收的啊,但是这样一句话不能让面试官充分了解你,你也成功的把话“聊死”了,反正不会是面试的加分项…这类宽泛的问题不仅仅考察你对知识点的掌握,其实也考察读者的文字组织、交流沟通能力~
如果博主遇到类似“宽泛”的问题,我会先预设:提出这个问题的面试官对问题的相关知识点“一无所知”。在这个前提下,我会依次从以下五个方面组织该问题的回答(这也是本文后续的主要内容):
1GC作用
2GC在什么时候
3对谁
4做了什么事情
5GC的种类及各自的特点
我们学习语文的时候,经常会遇到总结段落/文章大意的题目,记得当时语文老师是这么说的:同学们应该按照“谁,在什么时候,对谁,做了什么事情”来组织问题的答案。在这里也是一样,问题其实就是要求我们总结概括GC。
下面依次回答上面5个问题:

GC作用:

这个比较简单:在适当时候帮助回收JVM中的“垃圾”,接下来你可以接着说:这句话可以分为以下三个方面回答:什么时候、对谁(怎么定义“垃圾”),做了什么(如何回收)——这也就成功将话题向下面三点展开了:

什么时候:

也就是GC会在什么时候触发,主要有以下几种触发条件:
执行System.gc()的时候:建议执行Full GC,但是JVM并不保证一定会执行
新生代空间不足(下面会详细展开)
老年代空间不足(下面会详细展开)
什么意思呢?对象大都在Eden区分配内存,如果某个时刻JVM需要给某一个对象在Eden区上分配一块内存,但是此时Eden区剩余的连续内存小于该对象需要的内存,Eden区空间不足会触发minor GC。触发minor GC前会检查之前每次Minor GC时晋升到老年代的平均对象大小是否大于老年代剩余空间大小,如果大于,则直接触发Full GC;否则,查看HandlePromotionFailure参数的值,如果为false,则直接触发Full GC;如果为true(默认为true,表示允许担保失败,虽然剩余空间大于之前晋升到老年代的平均大小,但是依旧可能担保失败),则仅触发Minor GC,如果期间发生老年代不足以容纳新生代存活的对象,此时会触发Full GC 。
老年代满了,会触发Full GC(回收整个堆内存)。关于老年代:
分配很大的对象:大对象直接进入老年代,经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够多的连续空间;
长期存活的对象将进入老年代;
如果survivor空间中相同年龄所有对象大小的总和大于survivor空间的一半,年龄大于等于该年龄的对象就可以直接进入老年代;
CMS GC在出现promotion failure和concurrent mode failure的时候
上面这三种情况会导致“老年代“满”,会触发full GC。

对谁:

 对不再使用的对象,怎么判别一个对象是否还活着呢?这时可以从“引用计数法”讲到“可达性分析算法”。

引用计数法:给对象添加一个引用计数器,每当有地方引用它时,计数器加1;当引用失效时,计数器减1。引用计数法实现简单,判定效率高,但是它很难解决对象之间的互相循环引用(引用环问题)的问题。主流的java虚拟机没有选用引用计数法来管理内存。
可达性分析算法(主流实现判断对象是否“活着”算法):算法的基本思路就是以一系列的称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连的时候(即该对象不可达),则证明此对象是不可用的。在java中,可作为GC Roots的对象包括以下几种:栈中引用的对象(栈帧中的本地变量表)、方法区中类静态属性引用的对象、方法区中常量引用的对象。

在“可达性分析算法”中标记为不可达的对象,并非是“非死不可”的,还有回旋的余地。要宣告一个对象死亡,至少要经过两次标记的过程:如果对象在进行可达性分析后发现没有与GC Roots相连的引用链,那它将会被第一次标记并进行筛选,筛选的条件是此对象是否有必要执行finalize方法。如果对象没有覆盖finalize方法或者该方法已经执行过了,则被视为“没有必要执行”,宣告死亡。剩下的对象将被加入一个低优先级的队列中执行finalize方法。这里的执行指的是会触发这个方法,并不保证执行完该方法(只保证虚拟机会触发该方法),否则如该方法存在死循环,该队列就已经卡死了,GC也瘫痪了,所以只保证触发该方法。Finalize是对象逃脱死亡的最后一次机会(可以在finalize方法中重新与引用链上的任何一个对象建立关联)。在触发finalize方法之后,GC将对该队列中的对象进行第二次标记,如果此时该对象仍不在引用链上,该对象就会被回收。如果第二次标记前,该对象成功与引用链上的对象建立了连接,它会被移出“即将回收的集合”,自救成功。注:任何一个对象的finalize方法只会被系统调用一次,即在finalize方法中最多能实现一次自救。另外,finalize方法在jdk9中被标记为“废弃”方法了,不建议使用。

做了什么

不可达的对象,如何被回收:
1标记-清除法:在标记(可达性算法标记)完成后统一回收所有被标记的对象。它是最基础的算法,后续算法都是基于它的不足而改进,主要不足有:效率问题,标记和清除效率都不高;另外一个是空间问题,标记清除后会产生大量不连续的内存碎片,碎片太多可能导致在需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。标记清除算法回收后的内存图,如下所示:
在这里插入图片描述

2复制算法:为了解决标记清除算法的效率问题,“复制算法”出现了。“复制算法”将可用内存分为大小相同的两部分,每次只使用其中的一块,当使用的那一块内存快用尽时,就将还存活的对象复制到另外一块内存上,然后把已经使用过的内存空间一次性清理掉。这样就是每次都对整个搬去内存进行回收,也不用考虑内存碎片等复杂问题,只需要移动指针,按顺序分配内存即可,实现简单,运行高效。但是代价就是每次只能使用一半的内存,代价有点高。现代商业虚拟机都是采用这种手机算法来回收新生代的。实际上新生代中的对象98%都是“朝生夕死”所以远远用不着每次仅仅使用一般的内存。新生代中将内存划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和使用的Survivor中还存活的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚刚使用过的Survivor空间。Hotspot默认Eden/Survivor=8,即每次可以使用新生代中90%的容量(80%Eden + 10%Survivor),只有10%会被“浪费”。当然我们没法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时(超过10%的对象存活),需要依赖其他内存进行分配担保(这里指老年代),放不下的存活对象将进入老年代。
3标记-整理法:复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键是,如果不想有空间的浪费,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以老年代一般不采用“复制算法”(没有担保人)。根据老年代的特点,提出了“标记-整理法”:标记过程不变,仍使用“可达性分析算法”,标记完后不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。如下图所示:
在这里插入图片描述
4分代收集算法:JVM在实际垃圾回收中实际使用的是分代收集算法:根据对象存活周期的不同将内存划分为:新生代和老年代。在新生代每次都只有少量对象存活,选用复制算法;老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记-整理法或是标记-清理法进行回收。

上面的不同算法在JVM中有不同的垃圾回收器的实现,在JVM中主要有下面几种收集器:
在这里插入图片描述
新生代收集器有:Serial收集器、ParNew收集器、Parallel Scavenge收集器;老年代收集器:Cocurrent Mark Sweep(CMS)收集器、Serial Old(MSC)收集器、Parallel Old收集器。另外就是G1收集器,G1独自管理整个内存,不再分新生代和老年代了。上图中,如果两个收集器之间有连线,表示他们可以兼容使用;无连线则表示它们不能一起工作(不兼容)。
下面介绍下这几种收集器的特征:
1Serial收集器(新生代收集器):复制算法、Serial:串行的意思。由名字就可知这是一个单线程的收集器,“单线程”的意义并不仅仅说明它只会使用一个cpu或是一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到垃圾收集结束。“Stop the world”是由虚拟机在后台自动发起和完成的,在用户不可见的情况下把用户正常工作的线程全部停掉,意味着“你的计算机每工作一小时就会暂停响应5分钟。但是实际上它依然是虚拟机运行在client模式下的默认新生代收集器。它也有着优于其他收集器的地方:简单而高效。在用户的桌面应用场景中,分配各虚拟机管理的内存一般不会很大,收集几十兆甚至一两百兆的新生代,停顿时间可以控制在几十毫秒最多一百多毫秒以内,只要是不平凡发生这点停顿是可以接收的。所以Serial收集器对于运行在Client模式下的虚拟机来说是一个很好的选择。
2ParNew收集器(新生代收集器):它其实就是Serial收集器的多线程版本,复制算法,除了使用多条线程进行垃圾收集外,其余行为包括Serial收集器可用的所有控制参数。收集算法、Stop The World、回收策略等都与Serial收集器完全一样。Serial和parNew两个收集器都可以并且只可以与老年代的CMS和serial old GC一起工作。
3Parallel Scavenge收集器(新生代):它是使用复制算法的收集器,它可以和parallel old和serial old一起工作。它的关注点与其他收集器不同,CMS等收集器是尽可能的缩短垃圾收集时用户线程的停顿时间。Parallel Scavenge收集器关注的是吞吐量,目标是达到一个可控制的吞吐量,吞吐量=运行用户代码时间 /(运行用户代码时间+垃圾收集时间),即为CPU运行用户代码的时间与CPU总消耗时间的网速。停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验;而高吞吐量则可以高效率的利用CPU时间,尽快完成程序的运算任务,适合在后台运算运算并且不需要太多交互的任务。Parallel Scavenge收集器提供了设置最大垃圾收集停顿时间:-XX:MaxGCPauseMills(收集器将尽量保证内存回收时间不超过设定值,但是注意这是以牺牲吞吐量和新生代空间为代价的:把它设置得太小:系统将会调整新生代空间,因为回收300M新生代肯定比回收500M快,但是GC的频率也随之增大了)和吞吐量大小:-XX:GCTimeRatio的参数以及一个开关参数UseAdaptiveSizePolicy,可以自动优化调整新生代(-xnm)大小、Eden与Survivor比值(-XX:SurvivorRatio)、晋升老年代大小(-XX:PretenuredThreshold)等细节参数,虚拟机会根据当前系统运行情况当太调整这些参数已提供最合适的停顿时间或者最大的吞吐量,这种方式称为“GC自适应”的调节策略。如果对收集器运作原理不太了解,手动优化存在困难时,使用Parallel Scavenge收集器把内存优化管理的任务交给虚拟机(只需要设置基本内存数据:-Xmx、最大垃圾收停顿时间(更关注停顿时间)或者吞吐量(更关注吞吐量))。自适应调节策略”也是Parallel Scavenge收集器与ParNew收集器的一个重要区别。
4Serial Old是Serial收集器的老年代版本,它同样是是一个单线程收集器。使用“标记-整理”算法,主要意义也是给Client模式下的虚拟机使用。
5Parallel Old是Parallel Scavenge收集器的老年代版本,采用“标记-整理算法”。在注重吞吐量以及CPU资源敏感的场合可以优先考虑:Parallel Scavenge + Parallel Old组合。
6CMS(Concurrent Mark Sweep)收集器:一种以获取最短回收停顿时间为目标的收集器(希望系统停顿时间最短,以给用户带来较好的体验)。从名字中的“Mark Sweep”可以看出CMS收集器是基于“标记-清除”算法实现的,它的运作过程可分为4个步骤:初始标记、并发标记、重新标记、并发清除。其中,初始标记、重新标记这两个步骤仍然需要“Stop the World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;并发标记阶段就是进行GC Roots Tracing的过程;而重新标记阶段,则是为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这一阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记时间短。整个过程只有初试标记和重新标记需要“stop the world”,具有并发、低停顿优点。但是它由三个明显缺点:1.CMS收集器对CPU资源非常敏感:在并发阶段虽然不会导致用户线程停顿,但是会因为占用了一部分线程(CPU资源)而导致应用程序变慢,总吞吐量降低;CMS收集器无法收集浮动垃圾:可能出现“Concurrent Mode Failure”失败而导致来一次Full GC的产生(这时会使用serial odl作为CMS的临时替代收集器)。CMS并发清理阶段用户线程还在运行,期间自然会有新的垃圾产生,只能等待下一次GC时在清理,这部分垃圾称为“浮动垃圾”。另外,由于在垃圾收集阶段用户线程还需要运行,那也就是还需要预留足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎被完全填满了在进行收集,需要预留一部分空间供并发收集期间的程序运作使用;CMS是一款基于“标记-清除”算法实现的收集器,这意味着GC后会有大量的空间碎片产生。空间碎片过多将会给大对象分配带来很大的麻烦,往往会出现老年代还有很大的空间剩余,但是无法找到足够大的连续空间分配给当前对象,从而不得不提前触发一次Full GC。对此CMS提供了一个参数,用于在触发Full GC时开启内存碎片的合并整理过程,内存整理过程是无法并发的,空间碎片问题没有了,但是停顿时间不得不变长。
7G1收集器:是当今收集器技术发展的最前沿超过之一。G1是一款面向服务端应用的垃圾收集器,具有如下特点:并发与并行:可以充分利用多CPU、多核环境来缩短“Stop the world”的时间;分代收集:G1可以不需要其他收集器配合就可以独立管理整个GC堆,但它能够采取不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧的对象以获得更好的收集效果;空间整合:与CMS的“标记-清理”算法不同,G1从整体来看是基于“标记-整理算法”,从局部(两个region之间)看是基于“复制”算法实现的。但无论如何,这两种算法意味着G1运作期间不会产生内存碎片,这种特性有利于程序长时间运行;可预测的停顿:这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在在垃圾手机上的时间不得超过N毫秒,这几乎是实时的java垃圾收集器的特征了。在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,使用G1收集器时,它将整个java堆划分为多个大小相等的独立区域,虽然还有新生代和老年代的区别,但是新生代和老年代不再是物理隔离了,它们都是一部分Region(不需要连续)的集合。G1优先回收价值最大的Region(有限时间内获取尽可能高的效率)。G1收集器的运作大致可划分为以下几个步骤:初始标记、并发标记、最终标记、筛选回收。初试标记阶段仅仅是只是标记下GC Roots能直接关联到的对象,这阶段需要停顿线程,但是耗时很短;并发标记:从GC Roots开始对堆中对象进行可达性分析,找出活的对象,这部分耗时较长,但是可以与用户程序并发执行。最终标记:为了修正在并发标记期间因用户程序继续运行而导致标记产生变动的那一部分标记记录,这阶段需要停顿线程,但是可以并行执行。

另外,注意:jdk9及更新的版本中默认的是G1收集器;jdk8默认收集器:新生代GC:Parallel Scanvage收集器;老年代使用:parallel old收集器。(个人感觉这是加分项)

头条面试算法

给你一个有序整数数组,数组中的数可以是正数、负数、零,请实现一个函数,这个函数返回一个整数:返回这个数组所有数的平方值中有多少种不同的取值。举例:
nums = {-1,1,1,1},
那么你应该返回的是:1。因为这个数组所有数的平方取值都是1,只有一种取值
nums = {-1,0,1,2,3}
你应该返回4,因为nums数组所有元素的平方值一共4种取值:1,0,4,9
在往下看之前,请先进行思考,如果当时是你在面试,你会给出什么样的结题思路?下面会给出两种解法,最优解:时间复杂度:O(n)、空间复杂度O(1)。无论有没有思路,在往下看之前一定要有自己的思考。

第一种也是最为直接、简单的思路:把nums数组中所有数的绝对值,全部计算完之后再统计有多少种不同的取值。
实现代码如下:

public int handle(int[] nums) {
    if(nums==null  || nums.length==0)
        return 0;
    HashSet<Integer> set 
         = new HashSet<Integer>();
    for (int number : nums)
        set.add(Math.abs(number));
    return set.size();
}

上面的实现也很简单,主要是利用HashSet的去重特性,最后直接返回set的size。
但是呢,仅仅给出这种解法是过不了面试的,昨天面试的同学在面试结束之后才想到更优的解法,所以…当然这个同学已经拿到了很好的offer—网易。举这个案例只想和学弟学妹们在强调一遍:算法的重要性

上面直接使用set的解法没有利用题目中有序条件,这也是优化的方向。
那如何利用好有序这个条件呢?
解法2
解思路法如下:
绝对值相等可能有哪些情况呢?
两个同符号数的绝对值相等,这也意味着在有序数组中这个两个数是相邻的,这时只需要移动指针跳过相邻相等的数即可。
两个异符号数的绝对值相等,所以我们需要维护两个指针,一个指针从前往后移动,一个从后往前移。原因是前面的负数绝对值可能与后面整数的绝对值相等,我们需要比较前后两个指针绝对值的大小。
综上,我们需要维护两个指针,i开始指向数组第一个元素,i=0;j开始指向数组最后一个元素,j = nums.length-1。i,j指针的另外含义是:数组中索引小于i和大于j的元素都已经被处理了;i,j指向的是未处理元素中绝对值最大的两个元素。当i>j的时候表明所有的元素都已经被处理了,循环结束。
那么如何移动指针呢?
如果nums[i]与nums[j]的绝对值相等,此时执行i++,直到nums[i]的绝对值不等于nums[j]的绝对值(跳过相邻重复元素);j也是做类似的移动,只不过j是向前移,j–;最后计数器加1。
如果nums[i]的绝对值大于nums[j]的绝对值,我们移动指针i,并且计数器加1。原因?因为i和j指向未处理元素中绝对值最大的那个,nums[i]的绝对值已经是未处理元素中绝对值最大的两个数,也就是不可能存在一个未被处理的元素,它的绝对值与nums[i]的绝对值相等。所以这种情况下我们移动i指针。注意这里所说的移动指的是:执行i++直到nums[i]的绝对值发生了改变(跳过相邻相等的元素)。
如果nums[j]的绝对值大,那么与上面类似,移动j指针。
具体代码实现如下:

public int handle (int[] nums) {
    if(nums==null  || nums.length==0)
        return 0;
// result的缩写,最后的返回值
    int res = 0;
//i是前指针;j是后指针   
    int i = 0;
    int j = nums.length - 1;
    while (i <= j) {
        int num1=Math.abs(nums[i]);
        int num2=Math.abs(nums[j]);
        if (num1 > num2) {//移动i
        // 这两个数的绝对值不相等
            res += 1;
            while(i<=j &&
        Math.abs(nums[i])==num1)
//过滤掉相邻的绝对值相等的数
                i++;
        } else if (num1 < num2) {
// 这两个数的绝对值不相等    
            res += 1;
            while(i<=j &&
            Math.abs(nums[j])==num2)
            //过滤掉相邻的绝对值相等的数    
            j--;
        } else {
            res += 1;
            while(i<=j &&//去重
            Math.abs(nums[i])==num1)
                i++;
            while(i<=j &&//去重
            Math.abs(nums[j])==num2)
                j--;
        }
    }
    return res;
}

对本题有疑问的同学,欢迎在评论区留言探讨~

总结

面试中最常考的算法题主要是:数组、二叉树。期中呢,HashMap、HashSet等辅助数据结构使用比较多,“双指针法”在有序数组相关算法题中使用较多;无序数组,使用HashMap和HashSet较多;二叉树大多使用递归、dfs来解题。
另外,在实际面试中,算法也是这样一步步优化:最先提出的方案没有那么“优”也没有关系,如果面试官不满意,继续在此基础上进行优化,大多时候面试官会提示优化的方向。当然能直接给出优化方案最好~

百度面试原题Median of Two Sorted Arrays

难易程度:★★★★
重 要 性: ★★★★★
该算法题在百度、猿辅导多家公司的面试中都出现过。
面试中经常要求手写代码求解此类问题!并且本文介绍的算法可以应用于其他类似问题的求解。
在这里插入图片描述
即:给定两个排好序的数组nums1和nums2,找出两个数组合并后的中位数。本文介绍一种O(log(Math.min(len1,len2)))复杂度的解法,这也是面试官期望的解法。本题思路如下:设nums1的长度为len1;nums2的长度为len2:
i将长度的len1的数组一份为二,i的取值可以为0~len1,共len1+1种取值;每个i取值都将nums1数组一分为二,划分后的两个子数组的长度为:i和len1-i。(下面有图解)。
j将长度为len2的数组一份为二,j的取值可以为0~len2,共len2+1种取值,每个j取值都将nums2数组一分为二,互粉后的两个子数组的长度为:j和len2-j。(下面有图解)
中位数将数组整体一份为二,并且具有如下特点:中位数将两个数组合并后的有序数组划分为两个等长或者长度相差为1的两部分(中位数左边和右边的子数组长度相等或者相差1):
如图:
i
nums1[i-1] / nums1[i]

nums2[j-1] / nums2[j]
j

上图的两个’/'分别代表i和j的切分位置,即i和j的取值;对于求解中位数而言,我们只需保证:
1nums1[i-1]<=nums2[j]
2nums2[j-1]<=nums1[i]
3并且:当len1+len2为偶数时,左右两边数组长度相等:i+j = (len1-i)+(len2-j) ,即i+j = (len1+len2)/2;当len1+len2为奇数时,假设将多出的那个数划分到左侧,即左侧比右侧长度大一:i+j = (len1-i)+(len2-j)+1 ,即i+j=(len1+n+1)/2。所以有,不管len+len2是奇数还是偶数:
i+j = (len1+n+1)/2。

综上,解决这个问题只需要找到满足以下三个条件的i:
1nums1[i-1]<=nums2[j]
2nums2[j-1]<=nums1[i]
3i+j = (len1+n+1)/2

前两个条件的含义是i和j左半部分的所有元素小于i和j右半部分的所有元素,即中位数的左边的最大元素必须小于等于中位数右边的最小元素。第三个条件含义是:右半部分长度与与半部分长度相等或者相差1(总长度为偶数时中位数是两个数的平均值,此时数组可以等分;总长度为奇数的时候,中位数只有一个值,此时数组不可以等分)。如果我们找到了满足上面三个条件的i和j:
len1+len2为偶数时(中位数为两个数平均值),中位数为:
(Math.max(nums1[i-1],nums2[j-1])
+Math.min(nums1[i],nums2[j])/2.0)
len1+len2为奇数时,中位数为:
Math.max(nums1[i-1],nums2[j-1])
因为左边比右边多一个元素,左边多出的那个元素就是中位数。
所以,我们只要搜索一个i的取值,同时有j = (len1+len2+1)/2 - i,使得满足下面两个条件:
nums1[i-1]<=nums2[j]
nums2[j-1]<=nums1[i]
找到满足条件的i和j之后,我们可以根据上面分析的结论,找出中位数。
对于i的取值,我们可以使用二分搜索,所以这个问题在O(log(Math.min(len1,len2)))复杂度就可以解决;另外需要注意i和j取值边界问题:
因为i的取值是限定在[0,len1]范围进行搜索,所以索引i不会有越界问题;但是对于j就有可能出现索引越界的可能,对此只需要保证:len1<=len2就可避免索引j的越界问题,证明如下:

条件:len1 <= len2 和j = (len1 + len2 + 1) / 2 - i(上面推导过整个表达式),i在[0,len1]区间搜索:
j >= (len1 + len2+ 1) / 2 - len1
= (len2-len1+1) / 2 >= 0
j<=(len2+len2+1) / 2 <=l en2
综上:当len1 <= len2时:0 <= j< = n,即当满足len1<=len2时,索引j的取值一定在[0,len2],得证。

分析总结:
我们的目标是找到一个i、 j=(len1+len2+1)/2-i,使得切分后两个数组左边子数组长度之和和右边子数组长度之和相等或者相差1,只要满足:
len1<=len2
j=(len1+len2+1)/2-i
nums1[i-1]<=nums2[j]
nums2[j-1]<=nums1[i]

class Solution {
  public double findMedianSortedArrays
  {
        int[] nums1, int[] nums2) {
        int len1 = nums1.length;
        int len2 = nums2.length;

        /*
         * 保证 len1<=len2
         */
        if (len1 > len2) {
            int[] temp = nums1;
            nums1 = nums2;
            nums2 = temp;
            len1 = nums1.length;
            len2 = nums2.length;
        }

      //只有一个数组时直接计算中位数
        if (len2 == 0)
            return 0.0;
        if (len1 == 0) {
            if (len2 % 2 != 0)
                return nums2[len2/2]
                       * 1.0;
            else {
                return(nums2[len2/2-1]
                +nums2[len2/2])/2.0;
            }
        }

        int i_min = 0;
        //i取值在[0,len1]
        int i_max = len1; 
        int aux =(len1+len2+1) 
                  / 2;
        // 二分搜索i
        while (i_min <= i_max) {
            int i=(i_min+i_max)/2;
            //上面有推导,aux是常值
            int j = aux - i;
            /*i>0即有:
            j<(len2+len2+1)/2
            =len2,即nums2[j]不
            会越界*/
            if (i>0&&nums1[i-1]
                     > nums2[j])
            /*想办法减小nums1[i-1],
           因为nums1是升序,所以减小
           nums1[i-1]就是减小i,即i
           取值缩小在前半部分区间*/                        
                i_max = i - 1;
            
           /*i<len1,
           j>(len1+n+1)/2-len1
           =(len2-len1+1)/2>=0
           所以nums2[j-1]不会越界*/
            else if (i<len1&&nums1[i]
                     < nums2[j - 1])
           /*想办法增大nums1[i],因为
           nums1是升序,所以增大nums1[i]
           就是增大i,即i取值只缩小在后半
           部分区间*/
                i_min = i + 1;   
            else {
            // 找到了满足条件的i和j了

            /* 由之前知道,如果总的数组
            长度为奇数,左边部分比右边部分
            长度多一个,当然最后的中位数
            也就是左边最大值了*/

           /*当length1+length2为奇数的时候,
           中位数就是左半部分数组的最大值,
            max_left;*/
            
            /*若数组总长度是偶数,中位数为:
            (max_left+max_right)/2
            所以至少需要先找出max_left;
            至于max_right与总长度是奇数
            还是偶数有关*/

                // 求解max_left
                int max_left = 0;
                if (i == 0)// 处理边界
                    max_left = nums2[j-1];
                else if (j == 0)// 处理边界
                    max_left = nums1[i-1];
                else
                    max_left = Math.max(
                    nums1[i-1],nums2[j-1]);
                //总长度为奇数,中位数为max_left
                if (((len1+len2)&1)!= 0){
                    return max_left*1.0;
                }

                /*总长度为偶数时,中位数为:
                (max_left+max_right)/2
                求解max_right*/
                int min_right = 0;
                if (i == len1)
                    //处理边界
                    min_right = nums2[j];
                else if (j == len2)
                    //处理边界
                    min_right = nums1[i];
                else
                    min_right = Math.min(
                       nums1[i],nums2[j]);

                return (max_left
                       +min_right)/ 2.0;
            }
        }
        return 1.0;// 只是为了通过编译
    }
}

今日头条的面试中出现过:手写实现BST

import java.util.*;

public class MyBSTImpl {
    // BST中的节点
    TreeNode root;

    static class TreeNode {
        int val;
        TreeNode left;
        TreeNode right;

        TreeNode(int x) {
            val = x;
        }
    }

    // 插入操作
    public void insertIntoBST(int val) {
        root = insertIntoBST(root, val);
    }

    private TreeNode insertIntoBST(TreeNode root, int val) {
        if (root == null) {
            root = new TreeNode(val);
            return root;
        }

        if (val < root.val)
            root.left = insertIntoBST(root.left, val);
        else if (val > root.val)
            root.right = insertIntoBST(root.right, val);
        return root;
    }

    /*
     * 待删除节点可能有四种情况:
     * 1.待删除节点:没有左孩子也没有右孩子,删除节点后return null即可
     * 2.待删除节点:只有左孩子,删除节点后return 该节点的左子树即可
     * 3.待删除节点:只有右孩子,删除节点后return 该节点的右子树即可
     * 4.待删除节点:左孩子和右孩子都不为null:用待删除节点的右子树中最小的节点值,
     *   也就是用待删除节点的右子树最左端的节点值替换待删除节点的值,然后删除待删除节点的右子树最左端的节点即可
     *   (该节点没有左孩子),因为是最左端节点。
     */

    public void deleteNode(int val) {
        root = deleteNode(root, val);
    }

    private TreeNode deleteNode(TreeNode curNode, int key) {
        if (curNode == null) {
            return null;
        }
        if (key < curNode.val) {
            curNode.left = deleteNode(curNode.left, key);
        } else if (key > curNode.val) {
            curNode.right = deleteNode(curNode.right, key);
        } else {
            // curNode为带输出节点
            if (curNode.left == null) {// 待删除节点只有右孩子或者没有孩子节点
                return curNode.right;
            } else if (curNode.right == null) {// 待删除节点只有左孩子
                return curNode.left;
            }

            // 左右孩子都有

            // 找到待删除节点右子树中最left的节点,也就是右子树中值最小的节点
            TreeNode minNode = findMin(curNode.right);

            curNode.val = minNode.val;// 更新curNode的值为待删除节点右子树中值最小的节点的值

            // 删除curNode右子树中值最left的节点
            curNode.right = deleteNode(curNode.right, curNode.val);
        }
        return curNode;
    }

    // 找到以node为根节点的所有节点中值最小的节点,也就是最左端的节点
    private TreeNode findMin(TreeNode node) {
        while (node.left != null) {
            node = node.left;
        }
        return node;
    }

    // 迭代的方式刪除
    private TreeNode deleteNode1(TreeNode root, int key) {
        TreeNode cur = root;
        TreeNode pre = null;
        while (cur != null) {
            pre = cur;
            if (key < cur.val) {
                cur = cur.left;
            } else if (key > cur.val) {
                cur = cur.right;
            } else
                break;
        }

        // cur指向待删除节点
        if (cur == null)
            return null;// 没找到待删除节点
        if (pre == null) {// 删除根节点
            root = deleteRootNode(cur);
        } else if (pre.left == cur) {// 删除左节点
            pre.left = deleteRootNode(cur);
        } else {// 删除有节点
            pre.right = deleteRootNode(cur);
        }
        return root;
    }

    private TreeNode deleteRootNode(TreeNode root) {
        if (root == null) {
            return null;
        }
        if (root.left == null) {
            return root.right;
        }
        if (root.right == null) {
            return root.left;
        }
        TreeNode next = root.right;
        TreeNode pre = root;
        // next指向待删除节点的右分支最小节点
        // pre指向next的父节点
        for (; next.left != null; pre = next, next = next.left)
            ;

        next.left = root.left;
        if (root.right != next) {// 不是要删除next节点本身
            pre.left = next.right;
            next.right = root.right;
        }
        return next;
    }

    //查找操作
    public int searchBST(int val) {
        TreeNode search = searchBST(root, val);

        return search == null ? -1 : search.val;
    }

    // 递归查找
    private TreeNode searchBST(TreeNode root, int val) {
        if (root == null || root.val == val)
            return root;

        if (val > root.val)
            return searchBST(root.right, val);
        else
            return searchBST(root.left, val);
    }

    // 迭代查找
    private TreeNode searchBST1(TreeNode root, int val) {

        if (root == null) {
            return null;
        }
        while (true) {
            if (root.val == val) {
                return root;
            } else if (root.val < val) {
                root = root.right;
            } else {
                root = root.left;
            }

            if (root == null) {
                return null;
            }
        }
    }
}

不使用第三个数交换两个数的值网易游戏的面试

难易程度:★★★

重要性:★★★★

在网易游戏的面试中出现过:要求不使用第三个数交换两个数的值,例如:a=2;b=3,不使用其他变量交换a和b的值:

//不使用第三个数交换两个数的值
private void swap1() {
        int a=10,b=12;

        a=b-a; //a=2;b=12
        b=b-a; //a=2;b=10
        a=b+a; //a=12;b=10
    }
    private void swap2() {
        int a=10,b=12;

        a=a+b;//a=22,b=12
        b=a-b;//a=22,b=10
        a=a-b;//a=12,b=10
    }

加减乘除法和sqrt函数的实现

难易程度:★★★

重要性:★★★★★
阿里蚂蚁金服的面试中出现:要求手写实现“乘除法”
度小满金融的面试中出现过:自己实现Math.sqrt函数
快手的面试中曾出现:使用位运算实现整数加法运算

BinaryOperation {
    public static void main(String[] args) throws Exception {
        System.out.println(binaryAdd(-6, -15));
        System.out.println(binaryMulti(5, 6));
        System.out.println(binaryMulti2(5, 6));
        System.out.println(binaryDiv(6, 3));
    }
    //:a+b
    // 正负数都包含在里面,不用分开处理
    private static int binaryAdd(int a, int b) {
        int s = a ^ b;// 不考虑进位的和
        int jw = a & b;// 进位

  // 下面是 s + (jw<<1) 的计算,当进位为0时,不进位的和就是最终的计算结果
        while (jw != 0) {

            // 计算s + (jw<<1)的进位
            int jw_temp = s & (jw << 1);

            // 计算s + (jw<<1)的和,不包含进位
            s = s ^ (jw << 1);

            //计算进位以及不进位的和
            jw = jw_temp;
        }
        return s;
    }

    // 计算a*b
    private static int binaryMulti(int a, int b) {
        if (a == 0 || b == 0)
            return 0;

        int res = 0;
        int base = a;
        while (b != 0) {
            if ((b & 1) != 0)
                res = binaryAdd(res, base);
            b >>= 1;
            base <<= 1;
        }

        return res;
    }

    // 计算a*b
    private static int binaryMulti2(int a, int b) {
        if (a == 0 || b == 0)
            return 0;

        if(b>a) {
            int tmp = a;
            a = b;
            b = tmp;
        }
        int res = 0;
        int shift = 0;
        while(b!=0) {
            if((b&1)!=0) {
                res += (a<<shift);
            }
            shift += 1;
            b >>= 1;
        }
        return res;
    }

    //:a / b
//模拟小时候初学除法时的运算过程,看代码之前先在草稿纸进行一次完整的除法计算
    private static int binaryDiv(int a, int b) throws Exception {
        if (b == 0)
            throw new Exception("分母不能为0");

        boolean flag = false;
        if ((a ^ b) < 0)
            flag = true;// 表示a,b异号;
        a = a >= 0 ? a : -a;
        b = b >= 0 ? b : -b;

        int res = 0;
        int aux = 0;//依次获取a的最高位
        int mask = 0x40_00_00_00;// 用来依次获取分母的最高位bit
        while (mask != 0) {
            aux = (aux << 1) | ((a & mask) == 0 ? 0 : 1);

            if (aux >= b) {
                res = (res << 1) | 1;
                aux -= b;
            } else {
                res = (res << 1) | 0;
            }
            mask >>= 1;
        }
        return flag ? -res : res;
    }

//计算:Math.sqrt(num)
//原理:牛顿迭代法:
//https://baike.baidu.com/item/%E7%89%9B%E9%A1%BF%E8%BF%AD%E4%BB%A3%E6%B3%95/10887580?fr=aladdin
private static double mySqrt(int num) {
        double x0 = num;

        double delta = 1e-12;//精度
        int count = 0;
        while(x0*x0-num>delta) {
            x0 = (x0*x0+num) / (2*x0);
        }        
        return Math.round(x0*1000)/1000.0;//保留三位小数
    }
}
  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值