2021-12随笔

好久没有写文章,上次发文章还是2019年了。这次决定写写随笔记录一下内心想法。
最近和一个部门老板聊,问了个人职业规划的问题,我一时答不上来(前面有好心人已经提醒老板会问的)。让我不禁陷入人生大思考:我以后应该成为什么样的人?我没有想过。

因为机缘巧合进入了重点中学,考上了不错的学校。虽然成绩不能保研,但是借助平时学习的技术,刷算法题,也算混进了大公司。去年告别了学校生活,在上海租了一个小单间,开启了沪漂生活。一开始对未来工作还是充满期待的,期待能接触一流技术,进一步提升自己的能力。可来到后,团队环境不如我想的那样,工位很大,每个人都在忙自己的事情。成员没有太多交流,对接的人员都是在北京,初来乍到的我跟不会有太多话语,只能完成导师给的一些任务,发发日报。半年后,我怀着紧张的心情进行了转正汇报,我还问导师以前存在转正不过的情况吗?TA说不会的,你能力这么强。确实,我完成了一点小需求,解决了一点历史遗留的问题你就发大拇指,能不能过肯定跟老板商量好了。上海团队渐渐的跑路了很多人,我就像工具人完成需求。高工说这个要迁移,然后我就开始迁移。那个要下线,然后我就开始下线。原来的旧模块存在zk订阅点回退的问题,一有问题就会告警,晚上多次电话被告警打爆,只能崩溃打开电脑进行重启应用。告警邮件、短信也塞满手机,导师说直接拉黑屏蔽就行了。后来组内高工写了一个用mysql保存订阅点的新模块,完成新旧切换后,不需要再理会电话告警,我就在休息时间直接静音所有来电,也再没在半夜起来重启应用,终于能睡个安稳觉了。日常很多都是不痛不痒的工作,我都能很好的完成,因为并没有新使用什么东西,设计可以参考以前的代码。渐渐地我能独立设计和开发模块,这还是很开心的,虽然这个模块的流量并不高,但是需要足够灵活,支持B端的功能。在高工指导下,一个t5设计好关键部分,我顺着设计思路将大部分功能实现了,成为了这个模块的commiter。很快借助这个项目和我以前积极的表现,老板给我在小窗口晋升了。虽然晋升涨幅没有预想的高,但是因为所在事业群本来风评就不行,也就算了。

但说来也奇怪,最后一个学期去年因疫情没能回到学校让我赶上了,今年第一次调整为小窗口让我赶上了,今年调整年终奖发放日也赶上了。工作内容就是查客户提来的case,做PM提的一些有的没的需求。业务主导型,没有太多的技术挑战,让人看不到技术提升,感觉一直在写业务了。让我感觉不能再待在这了,已经没有太大的机会提升能力了,只是会把我变成更熟练的工具人。工作后身边的人少了起来,本来就不善交流的我更是觉得一个人的时间多了。而且组内缺少交流的氛围,没有锻炼公开演讲的能力,可以说对外输出的机会很难得。要不是每天找我查case,能够语音给他讲解,我一天都可以不说话,感觉大脑那块区域已经很久没有得到锻炼了。希望以后能有输出的机会,不管是文字的形式,还是其他形式,或许定时更新博客能帮我提升文字能力,以输出倒逼输入,养成学习的能力。每日回到住所,虽然内心想学习更多的内容,但是一天工作已经够多了,精力已经耗费完了,只想看看视频娱乐一下。

回到正题,个人职业规划,或者说程序员职业规划应该说什么样的?我在网络上搜索,并没有发现有能直接参考的。每个人的实际情况不一样,都有自己的优点和缺点。对于深入研究技术的人,可能他的最终目标是架构师,主导整个项目甚至是公司的架构。对于善于结合技术和人文的人,可能选择做产品经理。对于能进行管理的人,可以做到小组leader或者是部门老板。(以上是个人猜测,并没有实际研究可行性)宏观上来说,不仅是互联网行业的职业生涯,所有行业的职业生涯,都应该是有类似的模板。一开始从一线基层做起,了解这些业务的底层逻辑,亲力亲为,可以像工具人一样完成上级派下来的任务,也可以有思考地完成同样的任务。然后渐渐地可以成为中流砥柱,不仅自己可以完成任务了,还能指导新员工完成任务,可以分派任务给其他人完成,成为一个“包工头”,向上级汇报工作完成情况,也是会或多或少完成具体的一些任务。到了下一个级别,就不再是会接触一线的事情,可能是团队leader,具体做的事情我就不清楚了。写了这么多,其实我的职业规划还是没写清楚。我感觉我应该会走技术路线,我尽可能解决技术难题,而且与机器打交道也是我比较擅长的。能接触到目前一流技术,了解他内部的实现原理甚至实现代码都会让我感到技术的魅力。代码洁癖强制我写出好理解的代码,逻辑一定要清晰,并考虑一些拓展性,将类似的东西归类封装,这是对工作负责,不是只为了完成任务而省事,不让自己留下来的东西被称为“屎山”。对于一些所谓“屎山”,可能没有时间去优化整体的逻辑,但我会将这次的修改内容进行一些优化、封装,减少下次我再来修改相同内容的成本。关注技术也不是仅仅只关注代码,对于实际上在跑的业务也需要有思考,怎么用我们的技术支撑线上业务,也是一个重要的问题。纯研究的技术,如果没有和业务有机结合,不能带来实际上的收益,那么公司也就看不到你技术的用途,项目很可能被砍掉。我希望以后能够继续深入研究技术,广泛学习技术,并找到自己很感兴趣的内容可以做出业内先进成果。多和高t交流,以他们为榜样,找到接下来发展的目标。

这篇文章是没有啥主题的,很多地方我心血来潮就写了,中间还间断了几天,已经记不清当时的思路了。记忆是最大的人类障碍,看来还是得多做记录,不然留下来的就只有遗忘。或许我应该定时更新到什么地方,目前是只会在我的github page上出现,简书已经不会再更新了,等我文笔好一点,能产出更有质量的文章再发吧。一直关注的up主狂阿弥,说道他有做手账的习惯,不管是手写,还是直接贴图,都能记录那一天有意义的事情,不至于以后遗忘。一年过去了,再翻翻以前的记录,就仿佛重新过了那一天。我也感觉要做做记录,不然好像一年啥事也没做,时间就这样溜走了,其实还是能有很多事情需要记录的。

2021-12于上海浦东某地

EOF


解决钉钉服务器出口IP不确定问题:自签名根证书+hosts文件+nginx反向代理

问题来源

企业内部应用一般部署在内网,没有固定的公网IP,这样在访问钉钉的API时就会被拦截下来。钉钉的服务器出口IP只支持一个统配符,出现不匹配的情况就会被拦截。

解决方案比较

当然存在多种解决方案,各有差别,但是本质都需要一个公网服务器,公网搭设一个代理服务:

  1. 在代码中使用代理。对代码具有一定的侵入性。
  2. 使用系统代理。这样需要用PAC来选择代理的网址,对一些程序可能无效。
  3. 使用iptables转发到代理软件。仅限于linux可用。

这里我介绍一种用反向代理的方法解决没有固定IP的方案。内网的windows服务器只需要一个根证书、改Hosts文件即可。

解决方案

先决条件

  1. 公网服务器且系统为Linux、固定的公网IP

步骤

  1. 生成自签名的根证书 + 域名证书
    OpenSSL 自签 CA 及 SSL 证书参考这篇文章即可。注意把域名改成oapi.dingtalk.com,将csr文件转换为pem文件。

  2. 在公网配置Nginx的反向代理,使用如下配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
server {
# SSL configuration
listen 443 ssl;
listen [::]:443 ssl;
ssl on;
ssl_certificate /home/ubuntu/certs/oapi.dingtalk.com.pem; # 公钥路径
ssl_certificate_key /home/ubuntu/certs/oapi.dingtalk.com.key; # 私钥路径
ssl_session_timeout 5m;
ssl_protocols SSLv2 SSLv3 TLSv1 TLSv1.2;
ssl_ciphers "HIGH:!aNULL:!MD5 or HIGH:!aNULL:!MD5:!3DES";
ssl_prefer_server_ciphers on;
server_name oapi.dingtalk.com;
location ~ / {
proxy_set_header Host $host;
#proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass https://oapi.dingtalk.com;
}
}
  1. 在内网服务器,把根证书导入到受信任的根证书颁发机构

  2. 在内网服务器,设置Hosts文件

1
<公网ip>  oapi.dingtalk.com
  1. 重启nginx,即可在内网服务器上访问反代的钉钉API

简要分析原理

本地hosts文件强制解析到自有服务器上,中间的证书是自签名认证的,服务器获取到请求后转发给真正的钉钉服务器,这样出口IP就确定了下来,能过验证。注意根证书的私钥不要泄露了,否则带来中间人攻击的风险。


Linux 和 Windows 双系统共用蓝牙设备

背景

我安装的双系统,有时需要切换到Linux下工作,有时又需要在Windows下处理事务。我使用的蓝牙键盘是Logitech K380,可以同时容纳三个设备,但是每次在一个系统下配置使用后,重启到另外一个系统便不能再连接,需要重新配对。本文参考了网络上其他的文章,可先看参考文章。话不多说,直接开始!

首先,了解一下原因。蓝牙配对是根据设备的MAC地址和随机生成的密钥来连接的,相同设备的MAC地址是相同的,但是系统随机生成的密钥不同,所以解决的思路就是把密钥改成相同的即可。

由于我Windows已经配对了,我参考南浦月的博客步骤如下:

  1. Windows配对键盘(生成配置文件)
  2. Linux下配对键盘(读取key)
  3. 回到Windows修改配对key

使用dumplive在Linux下读取Windows注册表

1
2
3
sudo mount /dev/sda3 /mnt          #假设 sda3 是 Windows 系统盘
cd /mnt/Windows/System32/config #注意路径大小写
dumphive system ~/system.reg

这里直接参考原博客即可,但是我的Win10(版本1803)对应的键值与原文中的

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\BTHPORT\Parameters\Keys<本机蓝牙 MAC><鼠标蓝牙 MAC>

不一样,使用grep -Pn 'BTHPORT.*(\\[\da-f]{12}){2}' ~/system.reg 正则无法匹配,我这里的键值是

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\BTHPORT\Parameters\Keys\441ca8xxxxx(本机蓝牙MAC)

最后的密钥在键为键盘蓝牙MAC下。
剩余步骤直接按原文操作即可。

修改Linux下蓝牙配对信息

1
2
3
sudo su
cd /var/lib/bluetooth/60:57:XX:XX:XX:XX # 进入本机蓝牙设备的配对信息
cd EB:50:XX:XX:XX:XX # 进入键盘蓝牙目录

修改该目录下的info文件,把key改对即可。注意去掉冒号,全大写,这里与原文鼠标的配置文件不同。(不需要反转字符)

参考

https://blog.nanpuyue.com/2018/040.html


Java并发之并发容器ConcurrentHashMap

为什么要使用ConcurrentHashMap?

  1. HashMap线程不安全,多个线程同时putVal会造成死循环(在扩容过程中,链表成环)
    扩容过程:遍历旧桶,对每个桶里的链表entry,使用头插法放入对应新桶,即与原来的顺序相反。头插法过程中会改变头节点,当一个线程resize进度快于另外一个线程时,就会出现前一个链表插入后的后继仍然时原来的后继节点,这就出现了环。参考
  2. Hashtable效率底下,(依靠synchronized来同步方法)不能多读。
  3. ConcurrentHashMap利用锁分段技术,用多把锁提升了并发访问,同时扩容也只涉及到单个段,不涉及全部的段

ConcurrentHashMap结构

参考

Java并发编程的技术


Java并发之线程池

线程池介绍

线程池为线程生命周期的开销和资源不足问题提供了解决方案。通过对多个任务重用线程,线程创建的开销被分摊到了多个任务上。

使用线程池的好处

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

类图

Executor 接口

只有一个方法void execute(Runnable command),用来提交一个任务,根据不同的Executor实现,可能是创建一个新工作线程或者是复用工作线程运行
根据线程池的容量或阻塞队列的容量决定是否放入阻塞队列中,或者拒绝接受传入的任务。

ExecutorService接口

继承自Executor,并提供了管理终止的方法shutdown()(不再接受新任务,等待任务结束)shutdownNow()(除此之外还尝试终止运行中的任务)
Future submit(Runnable command) 提供一个能查询结果的接口

ScheduledExecutorService接口

ScheduledExecutorService扩展ExecutorService接口并增加了schedule方法。调用schedule方法可以在指定的延时后执行一个Runnable或者Callable任务。ScheduledExecutorService接口还定义了按照指定时间间隔定期执行任务的scheduleAtFixedRate()方法和scheduleWithFixedDelay()方法。

ThreadPoolExecutor分析

继承自AbstractExecutorService,也是实现了ExecutorService接口。
重要字段:

1
2
3
4
5
6
7
8
9
10
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int CAPACITY = (1 << COUNT_BITS) - 1;

// runState is stored in the high-order bits
private static final int RUNNING = -1 << COUNT_BITS;
private static final int SHUTDOWN = 0 << COUNT_BITS;
private static final int STOP = 1 << COUNT_BITS;
private static final int TIDYING = 2 << COUNT_BITS;
private static final int TERMINATED = 3 << COUNT_BITS;

ctl是对线程池的运行状态和线程池中有效线程的数量进行控制的一个字段, 它包含两部分的信息: 线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount),这里可以看到,使用了Integer类型来保存,高3位保存runState,低29位保存workerCount。COUNT_BITS 就是29,CAPACITY就是1左移29位减1(29个1),这个常量表示workerCount的上限值,大约是5亿。
为什么要一个Integer表示两个值:在多线程的环境下,运行状态和有效线程数量往往需要保证统一,不能出现一个改而另一个没有改的情况,如果将他们放在同一个 AtomicInteger中,利用 AtomicInteger 的原子操作,就可以保证这两个值始终是统一的。Doug Lea大神牛逼!

1
2
3
4
5
RUNNING :能接受新提交的任务,并且也能处理阻塞队列中的任务;
SHUTDOWN:关闭状态,不再接受新提交的任务,但却可以继续处理阻塞队列中已保存的任务。在线程池处于 RUNNING 状态时,调用 shutdown()方法会使线程池进入到该状态。(finalize() 方法在执行过程中也会调用shutdown()方法进入该状态);
STOP:不能接受新任务,也不处理队列中的任务,会中断正在处理任务的线程。在线程池处于 RUNNING 或 SHUTDOWN 状态时,调用 shutdownNow() 方法会使线程池进入到该状态;
TIDYING:如果所有的任务都已终止了,workerCount (有效线程数) 为0,线程池进入该状态后会调用 terminated() 方法进入TERMINATED 状态。
TERMINATED:在terminated() 方法执行完后进入该状态,默认terminated()方法中什么也没有做。
ctl计算方法
1
2
3
4
private static int runStateOf(int c)     { return c & ~CAPACITY; }
private static int workerCountOf(int c) { return c & CAPACITY; }
private static int ctlOf(int rs, int wc) { return rs | wc; }
private static boolean isRunning(int c) { return c < SHUTDOWN; }

构造方法

关键参数:

  1. corePoolSize:核心线程数量

  2. maximumPoolSize:最大线程数量

  3. workQueue:等待队列,当任务提交时,如果线程池中的线程数量大于等于corePoolSize的时候,把该任务封装成一个Worker对象放入等待队列
    当有新任务用execute()提交时会执行以下判断:

    1. 运行线程少于corePoolSize则创建新的线程来处理任务,即使线程池中其他线程是空闲的
    2. 当线程池中的线程数量大于等于corePoolSize且小于maximumPoolSize,则当workQueue满的时候才创建新的线程去处理任务
    3. 若设置的corePoolSize和maximumPoolSize相同,则创建的线程池大小固定,workQueue未满时直接放到workQueue,等待有空闲的线程去取任务
    4. 当运行线程数大于等于maximumPoolSize时,若workQueue已满,则执行handler来拒绝任务
      有如下的阻塞队列:
    5. SynchronousQueue(CachedThreadPool中使用)必须匹配生产者和消费者才会结束阻塞过程,否则一直阻塞到其他的线程执行take/offer,超时返回false。
    6. LinkedBlockingQueue 无界队列,链表实现。如果使用这种方式,那么线程池中能够创建的最大线程数就是corePoolSize,而maximumPoolSize就不会起作用了。当线程池中所有的核心线程都是RUNNING状态时,这时一个新的任务提交就会放入等待队列中。
    7. ArrayBlockingQueue 有界队列。使用该方式可以将线程池的最大线程数量限制为maximumPoolSize,这样能够降低资源的消耗,但同时这种方式也使得线程池对线程的调度变得更困难,因为线程池和队列的容量都是有限的值,所以要想使线程池处理任务的吞吐率达到一个相对合理的范围,又想使线程调度相对简单,并且还要尽可能的降低线程池对资源的消耗,就需要合理的设置这两个数量。
  4. keepAliveTime:线程池维护线程所允许的空闲时间。
    线程池维护线程所允许的空闲时间。当线程池中的线程数量大于corePoolSize的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime;

  5. threadFactory:用于创建自定义线程,指定优先级、名称。

  6. handler:它是RejectedExecutionHandler类型的变量,表示线程池的饱和策略。如果阻塞队列满了并且没有空闲的线程,这时如果继续提交任务,就需要采取一种策略处理该任务。

    1. AbortPolicy:直接抛出异常,这是默认策略
    2. CallerRunsPolicy:用调用者所在的线程来执行任务;
    3. DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
    4. DiscardPolicy:直接丢弃任务;

execute方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0) // 防止唯一的线程挂掉,无法从工作队列取数据
addWorker(null, false);
}
else if (!addWorker(command, false))
reject(command);
}

大概进行过程如下,addWorker中也有判断线程数的逻辑,加上这里双重校验线程池状态,所以有点混乱:

  1. 如果workerCount < corePoolSize,则创建并启动一个线程(addWorker)来执行新提交的任务;
  2. 如果workerCount >= corePoolSize,且线程池内的阻塞队列未满,则将任务添加到该阻塞队列中;
  3. 如果workerCount >= corePoolSize && workerCount < maximumPoolSize(addWorker内检查)且线程池内的阻塞队列已满,则创建并启动一个线程来执行新提交的任务;
  4. 如果workerCount >= maximumPoolSize,并且线程池内的阻塞队列已满, 则根据拒绝策略来处理该任务, 默认的处理方式是直接抛异常。
    即是,尝试下面的操作,满足则提交运行,新建线程或者是放入队列中:
  5. 核心线程数是否已满?
  6. 阻塞队列是否已满?
  7. 最大线程数是否已满?
  8. 都不满足则拒绝任务

addWorker方法

addWorker方法的主要工作是在线程池中创建一个新的线程并执行,firstTask参数 用于指定新增的线程执行的第一个任务,core参数为true表示在新增线程时会判断当前活动线程数是否少于corePoolSize,false表示新增线程前需要判断当前活动线程数是否少于maximumPoolSize。
主要思路:

  1. 第一层for,检查运行状态。获取当前线程池的运行状态,如果大于等于SHUTDOWN,则表示不再接收新的任务。并且以下条件有一个不满足则返回false:
    1. 线程池的状态等于SHUTDOWN(则可继续执行阻塞队列中的任务)
    2. firstTask为空(不能接受新的任务)
    3. 阻塞队列不为空(队列中没有任务,不需要再添加线程)
  2. 第二层for,尝试增加workCount,获取当前的线程数,若大于最大线程数(maximumPoolSize)不能再创建返回false。
  3. 否则尝试增加workCount(AtomicInteger,底层是CAS),成功跳出for,进入创建进程的代码块
  4. 失败则重新获取线程池的状态,于开始记录下的值对比,不等说明运行状态被改变,继续第一层for。相等时继续第二层for
  5. 此时已经完成了workCount的增加,开始执行Worker的创建代码。new Worker(firstTask)
  6. 获取一把可重入锁(mainLock),再次判断线程池的状态(是RUNNINGSHUTDOWNfirstTask为空),不符合则跳出try块,执行清理刚才的线程工作(addWorkerFaild(w))。返回false
  7. 否则添加刚才创建的worker到workers(HashSet)里,并开启调用worker里的thread成员start,开启线程执行,更新largestPoolSize(记录出现的最大线程数量),返回true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
private boolean addWorker(Runnable firstTask, boolean core) {
retry:
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);
// Check if queue empty only if necessary.
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;
for (;;) {
int wc = workerCountOf(c);
if (wc >= CAPACITY ||
wc >= (core ? corePoolSize : maximumPoolSize))
return false;
if (compareAndIncrementWorkerCount(c))
break retry;
c = ctl.get(); // Re-read ctl
if (runStateOf(c) != rs)
continue retry;
// else CAS failed due to workerCount change; retry inner loop
}
}
boolean workerStarted = false;
boolean workerAdded = false;
Worker w = null;
try {
w = new Worker(firstTask);
final Thread t = w.thread;
if (t != null) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
int rs = runStateOf(ctl.get());
if (rs < SHUTDOWN ||
(rs == SHUTDOWN && firstTask == null)) {
if (t.isAlive()) // precheck that t is startable
throw new IllegalThreadStateException();
workers.add(w);
int s = workers.size();
// largestPoolSize记录着线程池中出现过的最大线程数量
if (s > largestPoolSize)
largestPoolSize = s;
workerAdded = true;
}
} finally {
mainLock.unlock();
}
if (workerAdded) {
t.start();
workerStarted = true;
}
}
} finally {
if (! workerStarted)
addWorkerFailed(w);
}
return workerStarted;
}

Worker类

线程池中的每一个线程被封装成一个Worker对象,ThreadPool维护的其实就是一组Worker对象

1
2
3
private final class Worker
extends AbstractQueuedSynchronizer
implements Runnable

继承了AQS、实现了Runnable接口,并有firstTask(记录保存传入的任务),thread(在调用构造函数时用ThreadFactory创建的,保存处理任务的线程)。构造函数中执行了this.thread = getThreadFactory().newThread(this);新建一个线程,newThread方法传入的参数是this,因为Worker本身继承了Runnable接口,也就是一个线程,所以一个Worker对象在启动的时候会调用Worker类中的run方法。
Worker继承了AQS,使用AQS来实现独占锁的功能。不使用ReentrantLock(可重入):

  1. lock方法一旦获取了独占锁,表示当前线程正在执行任务中
  2. 如果正在执行任务,则不应该中断线程
  3. 如果该线程现在不是独占锁的状态,也就是空闲的状态,说明它没有在处理任务,这时可以对该线程进行中断
  4. 线程池在执行shutdown方法或tryTerminate方法时会调用interruptIdleWorkers方法来中断空闲的线程,interruptIdleWorkers方法会使用tryLock方法来判断线程池中的线程是否是空闲状态;
  5. 之所以设置为不可重入,是因为我们不希望任务在调用像setCorePoolSize这样的线程池控制方法时重新获取锁。如果使用ReentrantLock,它是可重入的,这样如果在任务中调用了如setCorePoolSize这类线程池控制的方法,会中断正在运行的线程。

Worker.runWorker方法

run方法中调用了该方法来执行任务。主要工作是:

  1. while循环不断地通过getTask()方法获取任务;
  2. getTask()方法从阻塞队列中取任务;
  3. 如果线程池正在停止,那么要保证当前线程是中断状态,否则要保证当前线程不是中断状态;
  4. 调用task.run()执行任务;
  5. 如果task为null则跳出循环,执行processWorkerExit()方法;
  6. runWorker方法执行完毕,也代表着Worker中的run方法执行完毕,销毁线程。
    这里的beforeExecute方法和afterExecute方法在ThreadPoolExecutor类中是空的,留给子类来实现。

getTask方法

getTask方法用来从阻塞队列中取任务
这里重要的地方是第二个if判断,目的是控制线程池的有效线程数量。由上文中的分析可以知道,在执行execute方法时,如果当前线程池的线程数量超过了corePoolSize且小于maximumPoolSize,并且workQueue已满时,则可以增加工作线程,但这时如果超时没有获取到任务,也就是timedOut为true的情况,说明workQueue已经为空了,也就说明了当前线程池中不需要那么多线程来执行任务了,可以把多于corePoolSize数量的线程销毁掉,保持线程数量在corePoolSize即可。
什么时候会销毁?当然是runWorker方法执行完之后,也就是Worker中的run方法执行完,由JVM自动回收。
getTask方法返回null时,在runWorker方法中会跳出while循环,然后会执行processWorkerExit方法。

processWorkerExit方法

至此,processWorkerExit执行完之后,工作线程被销毁,以上就是整个工作线程的生命周期,从execute方法开始,Worker使用ThreadFactory创建新的工作线程,runWorker通过getTask获取任务,然后执行任务,如果getTask返回null,进入processWorkerExit方法,整个线程结束,如图所示:

tryTerminate方法

tryTerminate方法根据线程池状态进行判断是否结束线程池。

  1. 当前线程池的状态为以下几种情况时,直接返回,不能结束线程池:
    1. RUNNING,因为还在运行中,不能停止;
    2. TIDYING或TERMINATED,因为线程池中已经没有正在运行的线程了;
    3. SHUTDOWN并且等待队列非空,这时要执行完workQueue中的task;
  2. 如果线程数量不为0,则中断一个空闲的工作线程,并返回
  3. 获取this.mainLock锁,尝试设置状态TIDYING( 线程数已经为0,调用完terminated,即改变为TERMINATED),调用terminated()后设置状态为TERMINATED

shutdown方法

shutdown方法要将线程池切换到SHUTDOWN状态,并调用interruptIdleWorkers方法请求中断所有空闲的worker,最后调用tryTerminate尝试结束线程池。

interruptIdleWorkers方法

interruptIdleWorkers遍历workers中所有的工作线程,若线程没有被中断tryLock成功,就中断该线程。

shutdownNow方法

shutdownNow方法执行完之后调用tryTerminate方法,该方法在上文已经分析过了,目的就是使线程池的状态设置为TERMINATED。
shutdownNow方法与shutdown方法类似,不同的地方在于:

  1. 设置状态为STOP;
  2. 中断所有工作线程,无论是否是空闲的;
  3. 取出阻塞队列中没有被执行的任务并返回。
    shutdownNow方法执行完之后调用tryTerminate方法,该方法在上文已经分析过了,目的就是使线程池的状态设置为TERMINATED。

问题

为什么需要持有mainLock?因为workers是HashSet类型的,不能保证线程安全。

在runWorker方法中,执行任务时对Worker对象w进行了lock操作,为什么要在执行任务的时候对每个工作线程都加锁呢?
下面仔细分析一下:

  • 在getTask方法中,如果这时线程池的状态是SHUTDOWN并且workQueue为空,那么就应该返回null来结束这个工作线程,而使线程池进入SHUTDOWN状态需要调用shutdown方法;
  • shutdown方法会调用interruptIdleWorkers来中断空闲的线程,interruptIdleWorkers持有mainLock,会遍历workers来逐个判断工作线程是否空闲。但getTask方法中没有mainLock;
  • 在getTask中,如果判断当前线程池状态是RUNNING,并且阻塞队列为空,那么会调用workQueue.take()进行阻塞;
  • 如果在判断当前线程池状态是RUNNING后,这时调用了shutdown方法把状态改为了SHUTDOWN,这时如果不进行中断,那么当前的工作线程在调用了workQueue.take()后会一直阻塞而不会被销毁,因为在SHUTDOWN状态下不允许再有新的任务添加到workQueue中,这样一来线程池永远都关闭不了了;
  • 由上可知,shutdown方法与getTask方法(从队列中获取任务时)存在竞态条件;
  • 解决这一问题就需要用到线程的中断,也就是为什么要用interruptIdleWorkers方法。在调用workQueue.take()时,如果发现当前线程在执行之前或者执行期间是中断状态,则会抛出InterruptedException,解除阻塞的状态;
  • 但是要中断工作线程,还要判断工作线程是否是空闲的,如果工作线程正在处理任务,就不应该发生中断
  • 所以Worker继承自AQS,在工作线程处理任务时会进行lock,interruptIdleWorkers在进行中断时会使用tryLock来判断该工作线程是否正在处理任务,如果tryLock返回true,说明该工作线程当前未执行任务,这时才可以被中断。

总结

  1. 分析了线程的创建,任务的提交,状态的转换以及线程池的关闭;
  2. 这里通过execute方法来展开线程池的工作流程,execute方法通过corePoolSize,maximumPoolSize以及阻塞队列的大小来判断决定传入的任务应该被立即执行,还是应该添加到阻塞队列中,还是应该拒绝任务。
  3. 介绍了线程池关闭时的过程,也分析了shutdown方法与getTask方法存在竞态条件;
  4. 在获取任务时,要通过线程池的状态来判断应该结束工作线程还是阻塞线程等待新的任务,也解释了为什么关闭线程池时要中断工作线程以及为什么每一个worker都需要lock。

参考

https://juejin.im/entry/58fada5d570c350058d3aaad


Java并发包之ReentrantLock、AbstractQueuedSynchronizer

ReentrantLock介绍

ReentrantLock重入锁,是实现Lock接口的一个类,也是在实际编程中使用频率很高的一个锁,支持重入性,表示能够对共享资源能够重复加锁,即当前线程获取该锁再次获取不会被阻塞。
synchronized作用相同(隐式支持重入性)
**synchronized实现:**JVM中对对象头的操作。获取自增、释放自减方式实现重入。monitorenter monitorexit

重入性实现原理

1
2
3
public class ReentrantLock implements Lock, java.io.Serializable {
private final Sync sync;
abstract static class Sync extends AbstractQueuedSynchronizer {

可知内部有个Sync对象,继承自AQS。lock方法和unlock方法都是代理的Sync对象。默认构造函数是Sync非公平锁。要理解可重入锁,则就是理解这几个Sync的实现。

可重入如何解决:

  1. 在线程获取锁的时候,如果已经获取锁的线程是当前线程的话则直接再次获取成功
  2. 由于锁会被获取n次,那么只有锁在被释放同样的n次之后,该锁才算是完全释放成功

Sync抽象类

继承AQS,重写了nonfairTryAcquire方法,分析可知其流程很简单,利用state字段记录获取的锁数量。当无锁时直接尝试获取,注意只尝试一次没有自旋CAS。当有锁时检查是否当前线程获取的锁,是直接则锁数量+1,没有用到CAS。
获取锁的过程【nonfairTryAcquire

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
final boolean nonfairTryAcquire(int acquires) {
//1. 如果该锁未被任何线程占有,该锁能被当前线程获取
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
} //2.若被占有,检查占有线程是否是当前线程
else if (current == getExclusiveOwnerThread()) {
// 3. 再次获取,计数加一
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}

释放也是比较简单,先判断是否是当前获取的锁,再直接锁的数量减少,若减少到0则标记当前获取线程为null。注意只有获取锁的时候才用CAS,其他的情况包括:其他线程获取锁,同一线程获取的情况是单线程直接set即可!
释放锁的过程:【tryRelease

1
2
3
4
5
6
7
8
9
10
11
12
13
14
protected final boolean tryRelease(int releases) {
//1. 同步状态减1
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
//2. 只有当同步状态为0时,锁成功被释放,返回true
free = true;
setExclusiveOwnerThread(null);
}
// 3. 锁未被完全释放,返回false
setState(c);
return free;

重入锁的释放必须得等到同步状态为0时锁才算成功释放,否则锁仍未释放。

NonfairSync实现类

继承之Sync抽象类,只重写了lock方法和tryAcquire方法。
同理,无锁时尝试获取,有锁时请求获取一把,这里会进入到AQS中,AQS会把当前线程加入同步队列并调用LockSupport.park挂起当前线程

1
2
3
4
5
6
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}

释放时则是调用了AQS的release,其又执行了Sync抽象类的tryRelease方法,释放完所有锁时会unpark等待队列第一个线程。

FairSync实现类

唯一的区别就是在尝试获取锁的时候,会判断同步队列当前是否有节点在等待、当前节点是否有前驱节点,若有则表示有线程比当前线程更早地请求获取锁,则拒绝本次获取锁的请求,加入到同步队列,并挂起线程。符合FIFO。

这段代码的逻辑与nonfairTryAcquire基本上一致,唯一的不同在于增加了hasQueuedPredecessors的逻辑判断,方法名就可知道该方法用来判断当前节点在同步队列中是否有前驱节点的判断,如果有前驱节点说明有线程比当前线程更早的请求资源,根据公平性,当前线程请求资源失败。公平锁每次都是从同步队列中的第一个节点获取到锁,而非公平性锁则不一定,有可能刚释放锁的线程能再次获取到锁。

公平锁和非公平锁利弊

  1. 公平锁每次获取到锁为同步队列中的第一个节点,保证请求资源时间上的绝对顺序,而非公平锁有可能刚释放锁的线程下次继续获取该锁,则有可能导致其他线程永远无法获取到锁,造成“饥饿”现象
  2. 公平锁为了保证时间上的绝对顺序,需要频繁的上下文切换,而非公平锁会降低一定的上下文切换,降低性能开销。因此,ReentrantLock默认选择的是非公平锁,则是为了减少一部分上下文切换,保证了系统更大的吞吐量

AbstractQueuedSynchronizer

AbstractQueuedSynchronizer是被很多类用于实现同步(CountDownLatch、CycliBarrier、ReentrantLock、ReentrantReadWriteLock),ReentrantReadWriteLock 可以看成是组合式,因为ReentrantReadWriteLock也就是读写锁允许多个线程同时对某一资源进行读。
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在上层已经帮我们实现好了。

Lock接口

concurrent包的关键接口,提供了和synchronized相同的功能,不过要显式加解锁,但需要在finally块内保证解锁。

AQS(AbstractQueuedSynchronizer)同步器

用于构建锁和同步器的框架,很多juc中都用其来构建同步器。(ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask)

核心思想

底层:CAS,在C++内使用汇编CPU指令

设计模式:模板方法

一些方法开放给子类进行重写,而同步器给同步组件所提供模板方法又会重新调用被子类所重写的方法

1
2
3
4
protected final boolean tryAcquire(int acquires) {
// throw new UnsupportedOperationException(); // 父类实现
return nonfairTryAcquire(acquires); // NonfairSync子类实现
}

AQS中的模板方法acquire又调用到了子类重写的方法

1
2
3
4
5
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}

总结

  1. 同步组件(这里不仅仅是可重入锁,还包括CountDownLatch等)的实现依赖于同步器AQS,在同步组件实现中,使用AQS的方式被推荐定义继承AQS的静态内存类;
  2. AQS采用模板方法进行设计,AQS的protected修饰的方法需要由继承AQS的子类进行重写实现,当调用AQS的子类的方法时就会调用被重写的方法;
  3. AQS负责同步状态的管理,线程的排队,等待和唤醒这些底层操作,而Lock等同步组件主要专注于实现同步语义;
  4. 在重写AQS的方式时,使用AQS提供的getState(),setState(),compareAndSetState()方法进行修改同步状态

Java并发知识整理

笔试题

线程间协作方式: wait notify notifyAll

都是__Object类的final native方法__

  1. 调用wait()能阻塞当前线程,必须拥有当前对象的monitor
  2. notify()能唤醒一个正等待对象的monitor的线程
  3. notifyAll()能唤醒等待monitor的线程

由1可知,调用wait()方法必须在同步块、方法中调用。
调用wait()后则交出当前对象的monitor,进入等待状态,(注意与Thread.sleep的区别)
使用notify()唤醒任意一个等待该monitor的线程,也必须在同步块中使用。
唤醒后不会立即获得monitor,等待退出synchronized块,释放对象锁后才能得到锁执行。


JVM知识整理

前言

与计网的那篇一样,整理我看过的JVM知识。我刷完了B站的视频,配合《深入理解Java虚拟机》使用。最有意思是书中的实战部分,内存结构的时候,触发不同内存区域的OOM!没系统整理时,别问,一问就GG。哈哈,每天尽力更新整理,攒面试人品了!

思维导图


内容太多了,先不展开导图

Java内存区域

导图

内存三大块:

1. 堆

分为年轻代(Eden、From Survivor、To Survivor,大小比例8:1:1)、老年代。
分区原因具体涉及到__GC__

存放内容

对象实例
数组

转化过程:

新对象在eden区分配(内存不够会在老年代分配),一次GC存活则进入s0、s1中,存活15代进入老年代

异常抛出

OOM

2. 方法区(Non-heap)

又称永久代

存放内容:

类信息
常量
静态变量
JIT编译代码
PS:JDK8移除了方法区,把这些数据放到了直接内存的元数据区
常量池信息,存放编译器生成的字面量和符号引用,JDK7移动到堆上存储

异常抛出:

OOM

3. 栈

分为虚拟机栈、本地方法栈。
64位长度long和double占用两个局部变量空间(Slot)其他的占用1个

存放内容
  1. 局部变量表(编译期完成,对应基本数据类型、对象引用)
  2. 操作数栈(字节码相关)
  3. 动态链接
  4. 方法出口
  5. 常量引用
  6. 小对象(无逃逸时,自动回收)
异常

OOM(无法动态扩展时)
SOF(大于允许深度时)

4. 程序计数器

存放当前线程字节码指令地址,无OOM抛出

5. 直接内存

1. NIO

基于通道(channel)和缓存区(buffer)
直接使用Native函数分配堆外内存(__DirectByteBuffer__对象引用这块内存)

  1. 避免Java堆、Native堆来回复制数据
  2. 不受Java堆限制(但受本机总内存限制)

垃圾回收

算法分类

  1. 引用计数法
    原理:维护对象的引用计数,为0时回收
    问题:

    1. 性能开销
    2. 循环引用问题
  2. 标记-清除法
    原理:从根节点出发,标记所有可达对象,把不可达对象删除

  3. 标记-压缩法
    原理:与标记-清除法相同,增加了移动到一侧,清空边界以外的点。以消除碎片

  4. 复制算法

    原理:两块相同大小的空间,每次把存活对象放到另一块,清空本块。并把大对象放到老年代。
    问题:

    1. 浪费一半空间
    2. 不适合存活过多的内存区域(老年代)

分代思想

少量对象,适合复制算法
大量存活,适合标记清理、压缩

一些概念

  1. 什么是可触及性?
    以下是可触及的:
    1. 栈中的引用对象
    2. 方法区的静态成员
    3. 常量的引用
    4. JNI方法栈中的引用对象
  2. 什么时候可复活?
    finalize()中复活对象(当然只会复活一次)
  3. 为什么避免使用finalize?
    1. 无法确定GC的时间
    2. 为什么不使用更确定性的try catch finally呢?
  4. 什么是Stop-The-World现象?
    多半是由GC引起的出现全局暂停的现象,(其他原因:Dump线程,死锁检查,堆Dump)
  5. Stop-The-World有什么危害?
    1. Java长时间无响应(老年代满出现这种情况)
    2. 考虑有集群的时候,会误判宕机,而出现主备切换(扯远了)

术语

name 别名 含义
FUll GC Major GC 清理整个堆
Old GC 清理老年代内存
Mxinor GC Young GC 清理年轻代内存

具体算法

串行收集器(-XX:UseSerialGC)

毫无疑问,串行的效率最高(回收垃圾的效率),但是带来了较长的停顿。使用该参数则表示:
1. 新生代、老年代使用串行回收
2. 新生代使用复制算法
3. 老年代使用标记-压缩

并行收集器(-XX: UseParNewGC)

复制算法的实现,多线程并行复制,当然是在新生代使用。

并行收集器(-XX: UseParallelGC,-XX: UseParallelOldGC)

新生代使用复制算法,老年代使用标记-压缩(默认不并行,使用后面的才并行)

CMS(Concurrent Mark Sweep)并发标记清除算法

这个就要详细说说了。是标记-清除算法的并发实现。
过程如下:
1. 初始标记(串行,暂停用户线程)
由根直接能标记到的对象,速度快
2. 并发标记(与用户线程并行)
标记全部可达对象
3. 重新标记(串行)
暂停整个应用程序,修正标记,准备清理了
4. 并发清理(与用户线程并行)
清理垃圾
5. 并发重置
开始下一个循环
特点:
1. 停顿时间短,多次与用户线程并行
2. 并发时,系统吞吐量降低(GC线程与应用线程一起工作,应用吞土量当然降低了)
3. 清理不彻底(清理时与用户线程并行,期间产生的垃圾只能下次GC回收)
使用注意事项:
1. 老年代使用(对象变化较小,重新标记、清理期间做的工作更加有效)
2. 不能在空间快满的时候清理,会出现Concurrent mod failure错误 。此时会使用串行收集器,得不偿失。
-XX:CMSInitiatingOccupancyFraction 设置触发GC的阈值
几个参数:
-XX: UseConcMarkSweepGC(使用CMS)
-XX: UseCMSCompactAtFullCollection(Full GC后进行碎片整理)
-XX: CMSFullGCBeforeCompaction(进行几次GC后再进行碎片整理)
-XX:ParallelCMSThreads(CMS线程数量)

如何减轻GC压力??

  1. 软件架构的设计
  2. 代码编写
  3. 堆如何分配

计算机网络知识整理

计算机网络面试题

前言

3月份面头条、腾讯被问到很多的计网题。但是自己未学过,也只是在网上看些博客。不得不说博客质量真是参差不齐,甚至某博客平台文章一键转载,可以说是知识污染。朋友推荐看《计算机网络教程:自顶而下方法》也刷得懵懂,又去B站找了考研视频刷了几天,加上自己看视频、看书做的笔记,才对计网有了了解,区分了以前不懂、模糊的概念。这里做整理,为自己攒人品!

思维导图

思维导图

协议分层

OSI参考模型,来源计算机网络教程:自顶而下方法

应用层

HTTP协议

这里东西比较杂,就挑我面试的问题进行整理了

HTTP1.0、1.1中的区别

WebSocket 握手

DNS解析过程

DNS服务器分类

  1. 根DNS服务器:13台,分布在全球
  2. 顶级服务器:com org edu
  3. 权威服务器:提供域名管理服务,维护域名解析记录

查询过程

查询过程

来源见水印

权威性

  1. 权威DNS:由域名解析商建设。在域名注册商设置的DNS服务器,对特定域名本身的管理(增、删、改)维护域名解析记录
  2. 非权威DNS:缓存DNS记录,缓存命中直接返回IP,未命中则逐级递归查询,由网络运营商建设,提供域名查询解析服务。

传输层

多路复用与多路分解

  • 多路复用:应用层所有的应用进程都可以通过传输层再传输给网络层
  • 多路分解:传输层从网络层接收到数据后交付给指定应用进程
  • TCP套接字:四元组(源IP,源端口,目的IP,目的端口)
  • UDP套接字:二元组(目的IP,目的端口)
  1. 服务器提供并行TCP套接字有限,原因:四元组资源耗尽
  2. 多个报文段到达主机后,使用元组定向到不同的套接字

TCP

1.特点

2.首部

3.状态转换图

状态转换图

4.连接与断开

5.流量控制

6.拥塞控制

7.差错控制、可靠传输

UDP

1.特点

2.首部

3.与TCP区别,及应用场景

网络层

数据链路层

物理层

一些面试题

  1. TCP三次握手,四次挥手?详细流程(包括每个状态)为什么需要三次握手?为什么需要四次挥手?为什么TIME_WAIT要等待2MSL?
  2. OSI七层模型与TCP/IP四层模型,各层的作用?
  3. DNS域名系统
  4. ARP地址解析协议
  5. TCP与UDP的区别、使用场景
  6. 滑动窗口协议
  7. TCP的拥塞控制
  8. CDN内容分发网络
  9. Session是什么?什么作用?特点?
  10. HTTP1.1 新特性
  11. HTTP状态码,12345各代表什么含义,重要的一些状态码要记住
  12. HTTP请求报文、响应报文格式
  13. HTTP八种请求方法
  14. HTTP与HTTPS

参考资料

  1. 计算机网络教程:自顶而下方法
  2. [全网最全王道计算机网络]计算机网络王道讲书学习视频

Servlet学习总结

前言

Spring Boot 中的spring-boot-web-starter中默认配置的Web容器就是Tomcat,而Tomcat是实现了Servlet规范的Web容器,以前在项目中经常用到,但是由于Spring Boot的约定先于配置大大隐藏了Tomcat的复杂性,还有Servlet的一些底层实现,导致在项目用到一些Servlet的东西却不知道其接口之间的关系。加上最近找实习也遇到面试官问这些问题,之前零散在网上看的不系统,回答的时候有点懵,于是花了一天去较为系统的了解这个Java Web中的重要接口。

思维导图

先看看我导图,然后再自底向上一一道来,归纳得不全,只有常见的接口,接口的实现也没有时间细看。
Servlet

什么是Servlet?

Servlet(server applet)是JavaEE(位于javax.servlet)中的编程规范,用在浏览器与Java之间访问交互,只需要实现了Servlet就可以在任意符合其规范的Web容器应用服务器(Tomcat JBoss Wildfly)中运行你的后端代码。从而实现了一次编写到处部署(面向接口编程的好处)!

Servlet有哪些常见的接口?

Servlet接口

留给程序员去实现的一个重要接口,编写业务逻辑,SQL查询之类的

  1. void init(ServletConfig config)
  • servlet 初始化方法,在用户访问时会实例化,该方法会被首次调用,可用于资源连接、打log
  1. void destory()
  • 对象被销毁时调用,放一些资源关闭的一些代码
  1. void service(ServletRequest req,ServletResponse res)
  • 最重要的一个方法,当请求到来的时候会实例出Request Response并调用该方法,常常在这里实现业务逻辑了

ServletConfig接口

用于初始化Servlet对象时使用,已由Tomcat实现。

  1. 读取web.xml中的配置信息__init-param__表示,可以用于配置数据库连接等信息。
    2 . 获取ServletContext

ServletContext接口

一个完整的webapp的应用上下文,已由Tomcat实现。
启动时创建,服务关闭时被摧毁。可存放__context-param__环境变量、运行时全局共享的一些数据。

HttpServlet抽象类

继承自GenericServlet(implements Servlet)的抽象类,提供了一些通用的实现:

  1. ServletConfig在init时保存为引用
  2. 在service实现HTTP请求方式的解析和分发调用算法
  3. doGet、__doPost__等方法默认抛出405错误(不支持的请求方式)
  4. 实现HTTP请求头的缓存信息解析
  5. 强制把ServletRequest转换成HttpServletRequest调用service方法

HttpServletRequest接口

继承自__ServletRequest__,添加了HTTP协议的接口,在__service__方法中使用。添加了:

  1. url的参数获取(表单、url)
  2. 获取remoteIp
  3. 获取转发器(res.getRequestDispatcher("/b").forward(req,res))
  4. 重定向(res.sendRedirect)与转发器的区别
  5. getCookie
  6. getSession

HttpServletResponse

继承自ServletResponse,同样拓展了HTTP相关的东西,如:

  1. sendError发送HTTP状态码和信息
  2. getOutPutStream
  3. addCookie

HttpSession接口

可用__HttpServletRequest.getSession()__获取当前连接的会话。

  1. 获取sessionId
  2. 获取过期时间
  3. setAttribute、getAttribute、removeAttribute存放会话数据

Cookie接口

可用__HttpServletRequest.getCookie()__获取当前连接的cookie,__res.addCookie__发送给浏览器cookie

  1. setPath,以最后的斜杠匹配,默认为当前uri发送(/a/b/c匹配/a/b/)
  2. setMaxAge,过期时间(=0直接删除,<0不存储,>0x秒失效)

运行时接口对应关系

  1. 一个Servlet对象对应一个Config,在web.xml定义的每个servlet的配置
  2. 一个webapp对应ServletContext,所有servlet共享同一个,在web.xml配置整个webapp的配置
  3. 一个请求对应HttpServletRequest,HttpServletResponse,每次请求创建不同的对象
  4. 一个会话对应一个HttpSession,可包含用户的多个请求

各接口的生命周期?

Servlet/HttpServlet

  1. 启动时默认不会被实例化(除非配置load-up-startup)
  2. 用户访问地址
  3. Web容器解析出对应uri,在容器上下文寻找对应的servlet
  4. 找到则调用其service方法
  5. 没找到则通过web.xml文件的配置获取完整类型,通过反射实例化
  6. 实例化时会执行无参构造方法
  7. 传入ServletConfig到init方法
  8. 最后调用service方法
  9. 销毁:web容器关闭、webapp重新部署、长时间无访问时,则调用destroy()做销毁前的准备

ServletContext

解析web.xml时创建,服务启动时被创建,关闭时销毁。

HttpServletRequest HttpServletResponse

一次请求对应一个对象,完成请求则销毁

我该选择哪个Servlet类去实现?

HttpServlet。Servlet接口定义了基本方法,GenericServlet是实现了部分方法的抽象类,查看源码可知:

  1. 实现init(ServletConfig config),保存了config的引用,并设计一个空的init()供重写
  2. 实现service(ServletRequest request,ServletResponse response),提供service(HttpServletRequest request, HttpServletResponse response)供重写,避免每次进行转型调用

HttpServlet是继承GenericServlet的抽象方法,提供了HTTP的更多实现,包括

  1. 在service方法中解析HTTP请求方式,分发GET到doGet,分发POST到doPost。
  2. 提供doXX的默认实现,发送405/400的错误,表示不支持的请求方式。子类需要重写这些方法去支持(巧妙!)
  3. doGet方法调用前,进行了缓存检查,当未过期时返回304 not modify 表示资源未更改

Servlet GenericServlet HttpServlet 体现了什么设计模式?有什么好处?

模板方法。HttpServlet是一个模板类,实现了核心算法骨架,doGet doPost 具体实现步骤要在子类中完成。

特点:doXX,doYY

作用:

  • 核心算法保护
  • 核心算法复用
  • 不改变算法前提下重新定义算法步骤的具体实现