面经读后感-双亲委派模型和ClassLoader部分源码阅读

发现很多看似艰深的面试题 原来在以前看不懂的书里有讲解
代码写多了,以前看不懂的东西慢慢的能理解了


原题:谈谈双亲委派模型,以及怎么被破坏的
扩展:能否自己写一个java.lang.System


双亲委派模型:

每个类加载器应该有一个成员变量作为父加载器,在加载时先让父加载器加载,父加载器抛出异常再调用本体的findClass()方法。

它是Java设计者推荐的实现,直接体现在抽象类ClassLoaderloadClass()方法里。

好处在于符合Java语言的设计,例如自行编写的java.lang.Object()类必然会被BootStrap加载器加载,并且会报错“找不到方法”。

(即使自己编写类加载器尝试加载,也会由于包名开头是java.而抛出SecurityException,体现在抽象类ClassLoaderpreDefineClass()方法)

重点在于父加载器是成员变量而不是父类,采用的是组合而不是继承。


被破坏:

第一次是由于存在一些老代码。JDK1.2前,自定义类加载器只能覆写loadClass()方法,当时没有这个模型,自然也没有父类优先的逻辑了。

在实现双亲委派时,为了兼容老代码,JDK的做法是添加一个findClass()方法,并且在loadClass()方法中调用,并且提倡用户覆写findClass()方法。

(问题,如果不需要兼容老代码可以怎么做呢?可以直接修改loadClassInternal()方法吗,这么做会有什么后果呢?)

第二次是由于模型缺陷(需求变更),由父加载器加载的代码需要调用子加载器的代码。

书中给的例子是JNDI,JNDI作为Java标准服务,代码在rt.jar中,并且由启动类加载器加载。但是它需要调用应用程序ClassPath的代码。

为了解决问题,Java团队只能引入线程上下文加载器,可以在创建线程时设一个类加载器,就可以逆向请求子加载器了。

第三次是技术迭代,OGSi的类加载器比双亲委派多了很多规则……

面经读后感-双重检查笔记

感觉看面经是个提高自己的好方式……
代码:

1
2
3
4
5
6
7
8
9
10
public static Singleton instance;
public static Singleton getInstance(){
if (instance == null){ //1
synchronized(Singleton.class) { //2
if (instance == null) //3
instance = new Singleton(); //4
}
}
return instance;
}

问题:
双线程同时执行getInstance()方法,线程1执行到第4步,而实例化对象在JVM中分为两步:分配内存+创建对象
如果在创建对象之前,线程2执行到第1步,发现内存已经分配了,返回这个引用就会出现问题。
解决:

1
public volatile static Singleton instance;

加上volatile就可以保证语句的有序性(1.4之前不行),强制实例化对象先创建 再分配内存,其他线程执行到1时,如果instance不是null则对象必然创建完成。

记吃到的Kotlin语法糖总结

最近在着手写一个TornadoFx的项目,接触了之前从未写过的Kotlin,吃到了大量的语法糖,给我一种“到处充满了Lambda”的既视感。

很多东西一个花括号就能搞定,隐藏了很多“当场实现接口”、“覆写方法”的繁琐语法,确实写起来非常的舒服,写篇文章总结一下。

目前来说,我体会到的主要有这些语法糖:

  • 构造块
    替代了构造函数,而Java中的有参构造被移到了类名右边的圆括号内:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//java
public class XXXBo{
public XXXBo(){

}
public XXXBo(int i){

}
}

//kotlin
class XXXBo(var i: Int) {
init{

}
}

顺带提一下延迟加载,Kotlin要求显式声明在声明时不赋值的变量:
(读起来可能有点拗口,意思是默认情况下如果变量声明不赋值,是一个编译期错误)

1
2
//编译错误:Property must be initialized or be abstract
//var bo:XXXBo;

只有加入lateinit 关键字才允许之后在其他地方赋值:

1
lateinit var bo:XXXBo;

延迟加载机制能和DI框架很好的配合,虽然Spring的写法是这样的:

1
var bo:XXXBo by di();

需要和延迟加载区分的一个机制是懒加载,代码是这样的:

1
2
3
val bo by lazy{
XXXBo(param1);
}

注意,懒加载用于val变量,一个非常对口的应用场景就是单例模式,如果在单例模式中使用lateinit虽然也可以实现,但是会增加不必要的逻辑。

  • 分号省略

代码略;每一行代码的分号可以省略,(不知道编译那边是怎么做的……)

  • 类型推断+使用var来声明变量

这个在类名很长的时候兼职就是拯救世界……

1
2
3
4
5
6
//java
XXXBo bo = new XXXBo();
final XXXBo bo = new XXXBo();
//kotlin
var bo = XXXBo();
val bo = XXXBo();
  • 进一步简化的try-with-resource
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//java6
try{
InputStream is = FileInputStream(filename);
}catch(Exception e){

}finally{
is.close();
}
//java7+
try (InputStream is = FileInputStream(filename)) {

}catch(Exception e){

}
//kotlin
FileInputStream(filename).use {
//使用it对象来使用这个流本身
var properties:Properties = Properties();
properties.load(it)
}

这个use()方法本质是调用close()方法,见这个回答

  • 对NPE进行了最大限度的消除:强制null检查
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//java
XXXBo bo = null;
bo.getXXX();//NPE

//kotlin
//编译错误:Null can not be a value of a non-null type XXXBo
//var bo:XXXBo = null;

var bo:XXXBo? = null;

//编译错误:Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type XXXBo
//bo.count;

if(bo !=null){
bo.count;
}
  • 对Get/Set进行最大程度的消除(数据类):

Java里其实有个叫Lombok的东西干了差不多的事,在编译器自动生成Getter Setter equals() toString()这么些方法。
不过Kotlin里调用get/set方法也可以不用方法名,而是直接.属性名。
顺带一提,Kotlin的主方法由于没有static方法,用的是伴生对象,并且方法加入@JvmStatic注解。
不过IDEA似乎没法用psvm来快速生成了,也不知道是复杂了还是简单了(

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//java
public class XXXBo{
private Integer id;
//getter setter
}
psvm{
new XXXBo().getId();
}
//kotlin
data class XXXBo(val id:Int){

}
companion object {
@JvmStatic
fun main(args: Array<String>) {
XXXBo().id;
}
}
  • 语言层面的TODO支持:
1
2
3
4
5
6
7
8
9
10
11
12
13
//java

//TODO xxx
//完全依赖IDE语法高亮,对实际编译的代码来说就是空代码

//kotlin
//标准库提供TODO函数,执行此处代码会抛出一个异常

TODO()

//@kotlin.internal.InlineOnly
//public inline fun TODO(): Nothing = throw NotImplementedError()

  • KDoc(对位JavaDoc)支持Markdown语法

详见链接

jOOQ比起MyBatis的便利之处

单位新项目用了新的持久化框架,叫jOOQ

用惯MyBatis的我一开始有些不适应,但是写过新项目之后,回去维护老项目,确实感觉基于XML的MyBatis编码效率低下。

首先,两边同样有自动生成代码的工具。
MyBatis生成的有Dao层和pojo,需要配置生成哪张表的pojo,(随口一提,公司的生成器配置可能有bug,生成XML时并不会清空之前的XML内容,需要手动清空/删除一次。)

生成的dao提供的方法有:

  • 根据主键获取
  • 插入
  • 可选插入(pojo对应字段不是null的时候才设值)
  • 更新
  • 可选更新(不是null的时候才更新值)
  • 逻辑删除

pojo则是数据库的各字段以及get/set方法,值得一提的是每个字段上会有DDL里的列备注,但是会充斥大量空行,阅读起来较为困难。

而jOOQ的代码生成器一次性生成所有表的dao层和pojo。dao层全部继承了一个叫DAOImpl的抽象类,提供了以下方法:

  • 新增单个pojo
  • 新增多个pojo(可变参数)
  • 新增多个pojo(集合)
  • 用以上三种方式更新、物理删除pojo
  • 根据主键判断是否存在
  • 获取表中记录总数
  • 获取整张表
  • 利用Java8的Optional来获取(大概是允许参数为null?)
  • 获取主键

还有一些看不太懂的方法:

  • private /* non-final */ Condition equal(Field<?>[] pk, Collection<T> ids)
  • private /* non-final */ List<R> records(Collection<P> objects, boolean forUpdate)
  • private /* non-final */ RecordListenerProvider[] providers(final RecordListenerProvider[] providers, final Object object)

而每个表不同的dao实现类也有各自的方法:

  • 根据主键(项目里就是id)获取
  • 根据多个id获取多个记录(可变参数)
  • 根据每个唯一索引获取记录
  • 根据每个列获取多条记录,也支持可变参数

比MyBatis丰富很多,唯一的缺陷是没有自动生成的逻辑删除方法,初次维护项目很容易根据直觉使用delete方法,需要自动生成Service层代码进行封装。

生成的pojo则兼容了JPA的注解(目前没有在项目里用上),这方面有点hibernate的画风? 较MyBatis生成的pojo要紧凑很多,少了很多无谓的空行。
当业务变化,建立新表,jOOQ生成的代码可以快速满足大部分业务。

说完自动生成的代码,接下来谈谈编写业务代码的复杂度。
贴一段使用MyBatis的传统项目里的单表分页查询代码:

首先需要编写XML:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="对应的dao接口类名">
<select id="selectByPageQuery"
resultMap="自动生成的Mapper里的ResultMap">
select * from 表
where is_deleted = 0
order by id desc
LIMIT #{itemIndex}, #{pageSize}
</select>

<select id="countByPageQuery" resultType="java.lang.Integer">
select count(*) from 表
where is_deleted = 0
LIMIT #{itemIndex}, #{pageSize}
</select>
</mapper>

由于多个Mapper需要公用LIMIT #{itemIndex}, #{pageSize} 这段语句(还有一些权限控制的语句),事实上代码是这样的:

1
<include refid="com.***.dao.mapper.fragment.PageMapper.pageLimit"/>

所以无法使用注解方式的SQL。
接下来是接口:

1
2
3
4
public interface 表SimpleSelectDao {
int countByPageQuery(PageQuery query);
List<自动生成的实体类> selectByPageQuery(PageQuery query);
}

其中PageQuery是公司封装的分页类,不贴代码了,可以从SQL里看出用到的字段。
然后再在Service层使用:

1
2
3
4
5
...
int count = parcelTransferRecordSimpleSelectDao.countByPageQuery(query);
query.setItemTotal(count);
List<TxParcelTransferRecord> records = parcelTransferRecordSimpleSelectDao.selectByPageQuery(query);
...

相对于基于XML的MyBatis,jOOQ在业务变动时编写代码的效率更高,同样是分页获取代码,只需要7行:

说明:
Pageable是Spring 5的分页相关类,专门解决从前端传参到数据库查询的分页问题,不需要自己实现分页框架了。
Tuple2是公司内部实现的数据结构,用来返回两个不同类型的数据。
SelectConditionStepdsl是jOOQ提供的类和对象,其中dsl的类是DSLContext,用于执行SQL,改写生成器后可以使用Spring注入。

1
2
3
4
5
6
7
8
9
public Tuple2<List<表实体类>, Integer> pageVoById(Pageable pageable) {
SelectConditionStep<表Record类> step = dsl.selectFrom(表的枚举)
.where(表的枚举.IS_DELETED.eq(false));
List<表实体类> list = step.orderBy(表的枚举.ID.desc())
.limit((int) pageable.getOffset(), pageable.getPageSize())
.fetchInto(表实体类.class);
int count = dsl.fetchCount(step);
return new Tuple2<>(list, count);
}

这段代码出现在Service层,用惯之后很符合人类直觉,发挥了SQL人类可读性好的优势。

jOOQ封装了sql,将所有的字段和SQL语句化为Java代码,当然也可以传入完整或者部分的sql执行,自由度很高,同时有Java强类型的优势加持,屏蔽掉了MyBatis的ResultMapper环节,每一步SQL执行虽然要使用一些复杂的类和对象(例如SelectConditionStep),对习惯使用强类型语言的java码狗来说很有“安全感”。

相比jOOQ,基于XML的MyBatis代码编写繁琐,手写SQL经常漏掉order by id desc(页面上刚刚新增的记录在最前面)和is_deleted = 0(其实这是低级失误),查找SQL需要跳转两次(这还是装了IDE插件的情况),在多表查询时还要编写复杂的ResultMap,体验确实差很多。

记一次IDEA项目报红

今天上午,IDEA突然提示Maven的依赖有更新,点下Enable Auto-Import之后,项目中所有引入的依赖全部报红。
尝试点击Re-Import无效,重装IDEA,删除.idea文件夹,重新Clone项目均无效。

后来同事说远程Maven仓库没法访问,同时在调节设置时发现,在设置中指定了Maven路径(下载公司内部依赖库需要定制的Maven,其实就是改过配置)后,本地Repo路径会自动变成一个不存在的路径,改为~/.m2/repository之后,Re-Import后开始下载jar包,最后恢复正常。

记录一次调通七牛云存储接口的经历

换了新东家,单位配了Mac,由于hexo只会推送生成好的HTML,没有存Markdown源文件,没法写博客(强行给偷懒找借口)。

最近需要同时开多个项目,低压U承受不住,平时操作卡顿,因此换回台式。

新项目中有用到七牛云存储,需求是给存储的图片、视频加上水印,视频需要有缩略图。

于是开始研究七牛云的文档,历时两天终于调通接口,特此记一笔。

调图像水印的接口时遇到的问题是我的低级失误,某个Config类输出七牛云格式的参数时 忘记输出StringBuilder.toString()了,而是用了默认的null。

调视频水印的时候,七牛云有一个坑,调用预处理持久化接口生成带水印的视频,并且使用saveas命令覆盖原视频文件后,无法再用vthumb命令一次性同时创建水印图片,必须请求两次。。

遇到的问题则是由于我忽视了视频转码需要时间的问题,上传成功后在文件管理里没有看到带水印的视频,就以为参数错误,直接把文件删了…

直到我用了七牛云存储实验室,在查询结果时看到了正在转码的信息,才意识到确实需要创建一个私有的多媒体处理队列。

虽说浪费了时间,但是熟悉了七牛云转码和水印相关接口,而且是SpringBoot环境下的应用,算是一段有价值的经历吧。

图解白菜用到的循环队列

实现了用循环队列存QQ消息后,就七八个月没有管(能用的代码才是好代码),以至于写上简历被问到之后支支吾吾说不出……

这个队列比较特殊,只有入队和遍历,没有写出队(业务用不到)。
代码如下(CqMsg为反序列化出来的实体类,记载着QQ号、消息体、发送时间等信息):

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
public class MsgQueue {

private int start = 0;
private int end = 0;
private int len = 0;
private int N=100;

private CqMsg[] msgs = new CqMsg[N];
public MsgQueue(){}
public MsgQueue(int N) {
this.N=N;
msgs = new CqMsg[N];
}
public void addMsg(CqMsg msg) {
len++;
if (len >= N) {
len = N;
start++;
}
if (end == N) {
end = 0;
}
if (start == N) {
start = 0;
}
msgs[end] = msg;
this.msg = msg;
end++;
}


public ArrayList<CqMsg> getMsgsByQQ(Long QQ) {
ArrayList<CqMsg> result = new ArrayList<>();
if (start < end) {
for (int i = 0; i < end; i++) {
if (QQ.equals(msgs[i].getQQ())) {
result.add(msgs[i]);
}
}
} else {
for (int i = end; i < msgs.length; i++) {
if (QQ.equals(msgs[i].getQQ())) {
result.add(msgs[i]);
}
}
for (int i = 0; i < start - 1; i++) {
if (QQ.equals(msgs[i].getQQ())) {
result.add(msgs[i]);
}
}
}
return result;

}
}

一开始我创建了一个数组(容量由构造器指定),同时定义两个整数作为坐标变量,再加一个表示队列当前长度的变量,大概是这样的:

1
length = 0
null null null null null null
Start
End

之后每当收到新消息,都将消息插入到end坐标上,之后将end++,同时length++,直到数组装满。

在数组装满后,队列是这样的:

1
length = 5
消息1 消息2 消息3 消息4 消息5 消息6
Start
End

当有下一条消息进入队列时,尝试将队列长度增加:

1
length++

随即满足下方if语句块的判定,将length重置为数组长度,并且将起始点右移:

1
2
3
4
if (len >= N) {
len = N;
start++;
}
1
length = 5

进入下一个if块:

1
2
3
if (end == N) {
end = 0;
}

最后把结果插入到end上,之后end++:

消息7 消息2 消息3 消息4 消息5 消息6
Start
End

不断的插入消息后,Start也会达到数组最右端,此时的队列如图:

消息7 消息8 消息9 消息10 消息11 消息6
Start
End

此时插入消息的逻辑如下:

首先依旧将length重置为当前数组长度;

但是不会进第二个if块,直接进第三个:

1
2
3
if (start == N) {
start = 0;
}

这之后执行end++,会回到数组第一次装满时候队列的样子:

消息7 消息8 消息9 消息10 消息11 消息12
Start
End

每次遍历时,只需要从end→length,再从0→start就行,是一次O(n)的操作。

而插入时,则只需要O(1)(判断几个数字的大小、给数组某个位置赋值)。

如改用纯数组实现,在数组满后,插入消息需要O(n)的时间。

若改用链表,则可以使用迭代器达到与循环队列相同的复杂度:

有新消息时,丢弃头部消息,在尾部追加消息,也是O(1)的操作。

遍历时, 并不使用传统的for+get方法(每次获取链表的某个位置的值代价都是O(n)),而是使用迭代器,可以达到O(n)的时间复杂度。

面试题-List和Set的区别

上周末面试被问到List和Set的区别,支支吾吾答不上(之前没有用过Set,学过却忘了),写个文章补一下。

本来想直接写一篇文章复习整个Collection Framework的,后来发现要写完可以变成一本书……(其实是水平不够)

Set:
一个不包含重复元素的 collection。
更确切地讲,set 不包含满足 e1.equals(e2) 的元素对 e1 和 e2,并且最多包含一个 null 元素。

事实上最常用的Set接口的实现类HashSet,底层是使用HashMap实现的,元素是HashMap的Key,而Value是一个私有的静态的Object对象。

HashSet有一个addAll方法,可以用来给ArrayList去重(阿里代码规约也有提到):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void main(String[] args) {  
Set set=new HashSet();
set.add("字符串1");
set.add("字符串1");
set.add("字符串1");
set.add("字符串2");
set.add("字符串3");
System.out.println("size="+ set.size()); //3

List list = new ArrayList();
list.add("字符串1");
list.add("字符串4");
list.add("字符串5");
set.addAll(list);
System.out.println("size="+ set.size() ); //5

for(String s:set) {
System.out.println(s);
}
}

关于方法重载和覆盖的笔记

最近做笔试题总是遇到覆盖重写,总是记不住,干脆写个笔记辅助记忆。
(不知是我太菜了还是黑皮书的翻译太拗口,本来想着Java语言规范这本书即使看不下去,拿来做工具书查也是好的,结果当工具书也看不懂x)

@Override
方法覆盖存在于子类和父类(接口)之间,遵循以下原则:
方法名字:必须相同
参数列表:必须相同

抛出异常:子类必须<=父类(子类抛出的异常类,必须与父类相同,或者是父类抛出的异常类的子类)
返回类型:子类必须<=父类(子类的返回类型,必须与父类相同,必须是父类的返回类型的子类)

访问权限:子类必须>=父类(例如public不能覆盖为protected)

俗称两同两小一大原则

Overload
方法重写:在一个类中定义多个重名的方法,遵循以下原则:
方法名字:必须相同
参数列表:必须不同,如果参数个数相同,必须保证参数的类型或者顺序不同。
返回类型、访问权限、没有要求。

走进Java并发编程02

第二节:多线程编程的基本需求。

JUC满足了多线程编程的各种需求,但是丰富的需求也是从简单需求开始的。


1.复习:创建线程

我还记得,当年培训班的SE部分结课作业是实现一个Socket客户端/服务端。
其实从这个角度上来说,某鸟的排课水平并不差;这个作业同时要求掌握Socket库的基本用法,还要求理解和实现BIO模型,也就是“服务端监听客户端连接,每个连接创建一个新线程”。

大致的代码如下(节省篇幅,我省略了继承/实现接口的部分,直接使用lambda表达式):
1
2
3
4
5
6
7
8
9
10
11
//略去主类、主方法
//监听10086端口
ServerSocket sc = new ServerSocket(10086);
while(true){
//循环监听
Socket socket = serverSocket.accept();
//为每个连接创建新线程
new Thread(() -> {
//具体的操作,相当于重写run()方法
}).start();
}

这样的代码可能大家都很熟悉(如果觉得陌生的话,也可以改写成一个MyThread类,实现Runnable接口并重写Run方法),而且肯定会有人让我用线程池;还请暂且忍耐一下,看完这些“原始”的代码。

基本需求1:同步与线程安全(synchronized)

问题来了,假设我们要在业务逻辑里对某个东西进行操作,例如……购买商品?

我们设计一个商店类,剩余库存为2,当库存为0时显示已售空,同时让线程睡眠1秒以模拟数据库读写等操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Shop{
private int count = 2;
public void sell(String name){
System.out.println(name+"开始购买商品");
if(count<=0){
System.out.println(name+"发现商品已售空");
}else{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
count--;
System.out.println(name+"购买商品完成,剩余库存:"+count);
}
}
}

看起来是不是很合理?

对主方法进行修改,专注于线程而不是Socket:

1
2
3
4
5
6
Shop s = new Shop();
for (int i = 0; i < 5; i++) {
//lambda表达式要求表达式中只有静态变量
int finalI = i;
new Thread(() -> s.sell("顾客" + finalI)).start();
}

我们同时建立5个连接,观察一下控制台,结果是什么?

1
2
3
4
5
6
7
8
9
10
线程0开始购买商品
线程3开始购买商品
线程1开始购买商品
线程2开始购买商品
线程4开始购买商品
线程0购买商品完成,剩余库存:0
线程3购买商品完成,剩余库存:0
线程2购买商品完成,剩余库存:-3
线程1购买商品完成,剩余库存:-3
线程4购买商品完成,剩余库存:-3

这……这是一场灾难!5个买家全都购买成功,而库存变成了-3!

(题外话:在高并发秒杀环境中, count 不再是简单的成员变量,而是缓存/数据库的某个值。万一并发处理错误,导致 count 瞬间变成了负数,这时候如果以 count==0 作为判断条件,会导致秒杀无法停止,所以一定要将判断条件改为小于区间。)

这就是一个典型的线程不安全的类。

定义:线程安全:在单个/多个线程环境下都能得到预期运行结果。

究其原因,是由于线程受到操作系统的调度,我们无法直接控制线程何时运行,即使是调节优先级,得到的也只是影响,而不是保证(可以试试把五个线程的优先级排一下看看结果)!

幸运的是,Java语言提供了同步关键字 synchronized ,它是Java对多种锁的封装,根据使用情况不同有不同的表现。

我们把它加到 sell 方法上……

1
2
3
4
5
6
7
8
9
10
线程0开始购买商品
线程0购买商品完成,剩余库存:1
线程3开始购买商品
线程3购买商品完成,剩余库存:0
线程4开始购买商品
线程4发现商品已售空
线程2开始购买商品
线程2发现商品已售空
线程1开始购买商品
线程1发现商品已售空

结论:在将 synchronize 关键字加到某个方法上后,我们可以确保在一个线程进入 这个对象 的这个方法之后,就不会有另一个线程也进入,避免了超售的情况。

这背后实际是线程获取了 这个对象 的锁,在执行完方法后,自动释放了 这个对象 的锁,是不是很智能?

与此相同的用法还有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 public void sell(String name){
synchronized (this){
System.out.println(name+"开始购买商品");
if(count<=0){
System.out.println(name+"发现商品已售空");
}else{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
count--;
System.out.println(name+"购买商品完成,剩余库存:"+count);
}
}
}

在这段代码里, this 关键字指向了当前的对象,与在方法上加 synchronize 关键字作用是一样的。

所以明确一个概念: synchronize 代码块锁住的不是代码块,而是 synchronize 后面圆括号中的对象!

定义:对象锁: 让同一个对象的某个方法无法被多个线程并发执行的机制。


题外话:

为什么我要强调同一对象呢?

让我修改一下代码,每次创建线程都创建一个 Shop 对象……

1
2
3
4
5
6
//主方法、主类
for (int i = 0; i < 5; i++) {
//lambda表达式要求表达式中只有静态变量
int finalI = i;
new Thread(() -> new Shop().sell("顾客" + finalI)).start();
}

然后把Shop对象的库存属性改为静态变量,以使所有Shop对象可以共享它。

1
2
3
4
class Shop{
private static int count = 2;
...
}

点击运行,灾难又出现了!

现在的编码需求,变成了让 所有Shop类的对象sell() 方法都无法被同时执行。

继续修改 sell() 方法,既然 count 已经是静态变量,那么我们为什么不把 sell() 方法也改成静态方法呢?

这时候IDE报了一个错,原来在静态方法中不能用 this 关键字(对象都不一定有, this 指定谁去?),那么把它改为 Shop.Class ,类对象就不是对象了?

现在的 sell() 方法:

1
2
3
4
5
6
public static void sell(String name){
synchronized (Shop.class){
....
}
}
}

当然,我们也可以直接把 synchronize 关键字丢回到静态方法上:

1
2
3
public synchronized static void sell(String name){
...
}

定义:类锁: 让同一个类 多个实例对象 的某个方法,都 无法被多个线程并发执行的机制。


基本需求2:线程间通信(Object.wait()/Object.notify())

刚刚我们实现了一家有序出售物品的商店,但是一家商店不能只能出售物品,卖光了怎么办呢?进货。

大致的过程是,当某个顾客线程发现 count<=0 时,挂起所有“顾客”线程,并且通知一个线程去进货,等待进货完成后给所有挂起的“顾客”线程发送通知。

首先我们要在主方法中,单独开启一个进货线程(这个写法是lambda表达式中的方法引用,由于该线程的run方法只执行这一个无参方法,被IDE检测到了提示替换):

1
new Thread(s::purchase).start();

仅仅 synchronized 关键字已经不够用,我们给 Shop 类增加一个字段:

1
private final Object myLock = new Object();

这个字段没有其他意义,仅仅作为一把被别人持有的对象锁而存在。

和上一章不同, 这把对象锁的目的,不再是让这个对象的方法无法被并发执行,而是让其他线程持有它,以便唤醒或挂起这些线程。

注意:

虽然我这里用了 “唤醒”和“挂起”,但我指的并不是 Thread.suspend()Thread.resume()

这一对被废弃了十几年的方法,是属于 Thread 类的,调用 suspend() 在挂起时并不释放这个线程持有的锁,因此极其容易引发死锁;

Object.wait() 会让所有持有这个对象的对象锁的线程阻塞,同时也停止持有这个对象的对象锁。

当调用 Object.notify() 时,会随机取出一个因为 wait() 方法阻塞的线程,让它继续运行的同时重新持有对象锁;

而调用 Object.notifyAll() 时,会让所有之前因为 wait() 方法阻塞的线程解除阻塞,但是注意:只有那个重新持有对象锁的线程才能继续运行。

明白了 wait()notify() 这一对方法后,我们来着手改写 sell() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...
while (count <= 0) {
try {
System.out.println(LocalTime.now() + name + "要求了进货");
synchronized (myLock) {
myLock.notify();
}
System.out.println(LocalTime.now() + name + "进货等待中");
this.wait();

} catch (InterruptedException e) {
e.printStackTrace();
}
}
...

在现在的 sell() 方法中,首先把库存不足的判断由 if 改为 while ,这样每个顾客在收到进货完成的通知后,都会重复检查一次库存。

接下来改写条件块的内容:

  • 尝试获取myLock的对象锁;

  • 在其他获取了myLock对象锁,并且被阻塞的线程中,选一个恢复运行(在主方法中实际我们只创建了一个这样的线程,因此这里 notify()notifyAll() 没有什么区别 );

  • 将所有持有当前对象的对象锁的线程阻塞。

接下来是进货方法:

  • 当然进货方法要写死循环,一旦被 sell() 方法恢复运行后,能再次阻塞,等待下一次需要进货的时候;

  • 获取myLock的对象锁,并且开始阻塞;

  • 在被 sell() 方法恢复运行后,将库存+5,然后将所有获取了当前对象的对象锁,并且被阻塞的线程恢复运行(招呼其他顾客继续购物)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 public void purchase() {
while (true) {
synchronized (myLock) {
try {
System.out.println(LocalTime.now()+"店家等待进货通知");
myLock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(LocalTime.now()+"店家开始进货");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
count += 5;
synchronized (this) {
this.notifyAll();
}
System.out.println(LocalTime.now()+"店家进货完成");
}
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
17:12:32.563顾客0开始购买商品
17:12:32.563店家等待进货通知
17:12:33.564顾客0购买商品完成,剩余库存:1
17:12:33.564顾客4开始购买商品
17:12:34.564顾客4购买商品完成,剩余库存:0
17:12:34.564顾客3开始购买商品
17:12:34.564顾客3要求了进货
17:12:34.564顾客3进货等待中
17:12:34.564店家开始进货
17:12:34.565顾客2开始购买商品
17:12:34.565顾客2要求了进货
17:12:34.565顾客2进货等待中
17:12:34.565顾客1开始购买商品
17:12:34.565顾客1要求了进货
17:12:34.565顾客1进货等待中
17:12:36.565店家进货完成
17:12:36.565店家等待进货通知
17:12:37.566顾客1购买商品完成,剩余库存:4
17:12:38.566顾客2购买商品完成,剩余库存:3
17:12:39.567顾客3购买商品完成,剩余库存:2

我们圆满的完成了需求,尽管这个程序会有一个一直等待是否去进货的店家,所以不会直接结束。

基本需求3:线程间通信2(Thread.join())

还是刚才的问题,我们让店家去进货,但是我们不希望多加一个对象,然后折腾当前对象/myLock这两个对象的锁。

我们把在主方法中创建进货线程、并且循环阻塞等待通知,改成在 sell() 方法中创建进货线程、并调用 join() 方法。

顾名思义, join() 方法表示立即阻塞当前线程,并且让被调用 join() 方法的线程“参与”到程序执行中,在被调用 join() 方法的线程执行完后,才恢复之前阻塞的当前进程的运行。

注意, join() 方法必须在 start() 方法调用后调用;如果 join() 方法和 start() 方法中有其他代码, join() 方法会优先执行。

同时我们去掉 purchase() 方法中关于对象锁的语句:

sell() 方法:

1
2
3
4
5
6
7
8
9
10
11
while (count <= 0) {
try {
System.out.println(LocalTime.now() + name + "要求了进货");
Thread a = new Thread(this::purchase);
a.start();
a.join();
System.out.println(LocalTime.now() + name + "进货等待中");
} catch (InterruptedException e) {
e.printStackTrace();
}
}

purchase() 方法现在只剩下了操作 count 以及一些提示:

1
2
3
4
5
6
7
8
9
10
public void purchase() {
System.out.println(LocalTime.now() + "店家开始进货");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
count += 5;
System.out.println(LocalTime.now() + "店家进货完成");
}

运行,这次程序执行完自动结束了,因为不再有一个后台持续阻塞的线程了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
17:54:09.025顾客1开始购买商品
17:54:10.027顾客1购买商品完成,剩余库存:1
17:54:10.027顾客4开始购买商品
17:54:11.027顾客4购买商品完成,剩余库存:0
17:54:11.027顾客3开始购买商品
17:54:11.028顾客3要求了进货
17:54:11.031店家开始进货
17:54:13.032店家进货完成
17:54:13.032顾客3进货等待中
17:54:14.033顾客3购买商品完成,剩余库存:4
17:54:14.033顾客2开始购买商品
17:54:15.033顾客2购买商品完成,剩余库存:3
17:54:15.034顾客0开始购买商品
17:54:16.034顾客0购买商品完成,剩余库存:2

对Java库源码有过分析的可能会知道, join() 方法内部其实是由 Object.wait()/Object.notifyAll() 实现的!

附:本章完整代码:

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
package top.mothership;

import java.time.LocalTime;

public class Main {
public static void main(String[] args) {
Shop s = new Shop();
for (int i = 0; i < 5; i++) {
int finalI = i;
new Thread(() -> s.sell("顾客" + finalI)).start();
}
}

}

class Shop {
private int count = 2;


public synchronized void sell(String name) {
System.out.println(LocalTime.now() + name + "开始购买商品");
if (name.contains("2") || name.contains("0")) {
Thread.yield();
}
while (count <= 0) {
try {
System.out.println(LocalTime.now() + name + "要求了进货");
Thread a = new Thread(this::purchase);
a.start();
a.join();
System.out.println(LocalTime.now() + name + "进货等待中");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
count--;
System.out.println(LocalTime.now() + name + "购买商品完成,剩余库存:" + count);
}

public void purchase() {
System.out.println(LocalTime.now() + "店家开始进货");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
count += 5;

System.out.println(LocalTime.now() + "店家进货完成");
}
}