ChannelId的那些事儿

ChannelId组成

ChannelId默认由io.netty.channel.DefaultChannelId实现,最终结果存储在一个data的byte数组中,data数组内由5个字段组成,从低位到高位分别是:6或8字节的MACHINE_ID,4字节的PROCESS_ID,4字节的SEQUENCE,8字节的TIMESTAMP和4字节的RANDOM,下表以8字节的`MACHINE_ID举例:

字段 RANDOM TIMESTAMP SEQUENCE PROCESS_ID MACHINE_ID
字节 [27-24](4字节) [23-16](8字节) [15-12](4字节) [11-8](4字节) [7-0](6或8字节)
说明 随机 自定义 初值为0,每次递增1 优先从系统属性io.netty.processId获取,类型为int 优先从系统属性io.netty.machineId获取,类型为String,长度为17或23

在DefaultChannelId的实现中,**TIMESTAMP**字段并没有直接使用系统的时间戳,例如:System.nanoTime(),而是自定义了一个时间戳生成方法:Long.reverse(System.nanoTime()) ^ System.currentTimeMillis(),举例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
final long nanoTime = System.nanoTime();
final long timeMillis = System.currentTimeMillis();
final long xorValue = nanoTime ^ timeMillis;
final long reverse = Long.reverse(xor);

log.info("{}", String.format("%64s", Long.toBinaryString(nanoTime)));
log.info("{}", String.format("%64s", Long.toBinaryString(timeMillis)));
log.info("{}", String.format("%64s", Long.toBinaryString(xorValue)));
log.info("{}", String.format("%64s", Long.toBinaryString(reverse)));

/*
输出如下所示
: 1111111100000110011110111010001111000001000000 -nanoTime
: 10111100001101101100000001000101100110110 -timeMillis
: 1111101011100111110011011010000111101101110110 -xorValue
: 110111011011110000101101100111110011101011111000000000000000000 -reverse,也即将xorValue换成2进制后,头尾互掉后的值
*/

至于Netty为什么这么设计,只是在源码中有一句简单的注释:// timestamp (kind of),翻译过来就是一种(Netty式的)时间戳,可能就是想这么实现吧。

ChannelId的表示

ChannelId有两个函数:asShortText()asLongText()

  • asShortText()使用懒加载的方式,输出**RANDOM字段转换成16进制并存储到String shortValue,例如:60fb0e5f**

  • asLongText()也采用懒加载的方式,将data数组中的5个字段转换成16进制以后,从**MACHINE_ID开始,到RANDOM结束,用“-”连起来,最后存储到String longValue中,例如:00000000000000e0-00003738-00000000-3a5715eac38e517e-60fb0e5f**

bind那些事儿

1
2
3
ServerBootstrap->ReflectiveChannelFactory: newChannel()
ServerBootstrap->ServerBootstrap: 1. initChannel(Channel)
ServerBootstrap->EventLoopGroup: register(Channel)

在上述的1. initChannel(Channel)中,执行了了一个重要的操作:new了一个匿名的ChannelInitializer并且addLast到传入的ChannelChannelPipeline中,该ChannelInitializer主要执行了两件事:

  • ChannelInitializer.initChannel回调被调用时,将ServerBootstrapChannelHandler addList到Channel.pipeline(),而ServerBootstrapChannelHanlder可以在构造时通过handler(ChannelHandler)传入,例如传入一个new LoggingHandler(LogLevel.INFO),从而实现log输出,有两点需要注意:
    • ChannelInitializer.initChannel回调时,发生在ServerBootstrap.group中(也即大家常用的BossGroup),此时还没有注册到ServerBootstrap.childGroup中(也即大家常用的WorkerGroup)。
    • 该ChannelHandler全局只有一个,会被所有的EventLoop共享包括ServerBootstrap.groupServerBootstrap.childGroup在内的所有EventLoop
  • ChannelInitializer.initChannel回调被调用时,调用了ChannelEventLoop(该EventLoop隶属于父线程池)并执行了一个异步任务:向Channel.pipeline()中addLast了一个new ServerBootstrapAcceptorServerBootstrapAcceptor执行了两件重要的事情:
    • ServerBootstrap.childHandler addLast到Channel.pipeline()
    • 调用ServerBootstrap.childGroup.register(Channel)Channel注册到子线程池(所以AbstractChannel声明成员变量eventLoop时使用了volatile关键字修饰)

GitHubGitHub tag (latest by date)Maven CentralGitHub Workflow Status

gamedo.persistence

gamedo.persistence是gamedo游戏服务器框架的持久化模块。它底层依赖于spring-data-mongodb,致力于构建一个 高性能、简单易用、易于维护 的游戏服务器持久化模块。同时,gamedo.persistence吸收了ECS设计模式的思想,也即:“组合优于继承”(这也是开发中的gamedo.ecs模块的一大个特性)。通过对底层的设计,将游戏持久化对象数据约束为Entity-Components(也即一个实体由多个组件组合而成)的形式,从而统一团队成员对于游戏对象数据的规范化设计和使用。

开始使用

Maven配置

增加Maven依赖:

1
2
3
4
5
<dependency>
<groupId>org.gamedo</groupId>
<artifactId>persistence</artifactId>
<version>1.2.1</version>
</dependency>

使用说明

  1. 定义游戏对象数据使之继承自EntityDbData,并使用 @Document注解来指定该持久化对象数据要持久化到MongoDB的哪一个Document中,一般情况下,该类内不再增加新的成员变量,因为数据应该存储在ComponentDbData的子类中,例如定义个玩家类:
1
2
3
4
5
6
@Document("player")
public class EntityDbPlayer extends EntityDbData {
public EntityDbPlayer(String id, Map<String, ComponentDbData> componentDbDataMap) {
super(id, componentDbDataMap);
}
}
  1. 根据开发需求,定义不同的组件数据类,也即是 ComponentDbData的子类,并使用和EntityDbPlayer相同的 @Document注解,确保被持久化到同一个Document中,例如定义一个背包类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@EqualsAndHashCode(callSuper = true)
@AllArgsConstructor
@Data
@Document("player")
public class ComponentDbPlayerInfo extends ComponentDbData
{
private String name;
private int level;
}

@EqualsAndHashCode(callSuper = true)
@AllArgsConstructor
@Data
@Document("player")
public class ComponentDbBag extends ComponentDbData
{
final List<Integer> itemList = new ArrayList<>();
}
  1. 定义EntityDbPlayer的转换器(属于样板代码,不需要实现逻辑),由于gamedo.persistence在设计过程中,使用了使用了自定义转换器对EntityDbData类进行序列化和反序列化,因此其子类也需要继承 AbstractEntityDbDataReadingConverterAbstractEntityDbDataWritingConverter并加上**@Component**注解,目的可以正确加载到spring的IOC容器中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
@ReadingConverter
public class EntityDbPlayerReadingConverter extends AbstractEntityDbDataReadingConverter<EntityDbPlayer> {
public EntityDbPlayerReadingConverter(MongoConfiguration configuration) {
super(configuration);
}
}

@Component
@WritingConverter
public class EntityDbPlayerWriterConverter extends AbstractEntityDbDataWritingConverter<EntityDbPlayer> {
public EntityDbPlayerWriterConverter(MongoConfiguration configuration) {
super(configuration);
}
}
  1. 搞定!接下来就是使用gamedo.persistence了
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
@Slf4j
@SpringBootApplication(scanBasePackages = {"org.gamedo", "org.gamedo.persistence"})
public class Application {
public static void main(String[] args) {
final ConfigurableApplicationContext applicationContext = SpringApplication.run(Application.class, args);

//1、从容器中获取DbDataMongoTemplate(不能通过new操作符获取DbDataMongoTemplate,否则不具有异步持久化能力)
final DbDataMongoTemplate dataMongoTemplate = applicationContext.getBean(DbDataMongoTemplate.class);
//2、创建一个玩家持久化对象类:EntityDbPlayer
final EntityDbPlayer entityDbPlayer = new EntityDbPlayer(new ObjectId().toString(), new HashMap<>());

//3、增加一个组件数据:ComponentDbData
entityDbPlayer.addComponentDbData(new ComponentDbBag(new ArrayList<>()));
entityDbPlayer.addComponentDbData(new ComponentDbPlayerInfo("testName", 1));

//4、调用同步save函数,将完整的EntityDbPlayer持久化到MongoDB中
dataMongoTemplate.save(entityDbPlayer);
//4.1、同步方式实现异步调用save函数,该方法会把entityDbPlayer安全地发布到db线程后,就直接返回。
dataMongoTemplate.saveAsync(entityDbPlayer);

//接下来是gamedo.persistence提供的线程安全的异步增量更新的功能---------------------------------------------------

//应用层
//5、获取组件数据
final ComponentDbBag componentDbData = entityDbPlayer.getComponentDbData(ComponentDbBag.class);
//6、修改数据
componentDbData.getItemList().add(1);
//7、对修改的变量进行标脏
componentDbData.setDirty("itemList", componentDbData.getItemList());

//存储层
//8、进行异步更新,并通过CompletableFuture检查执行结果
if(componentDbData.isDirty())
{
final CompletableFuture<UpdateResult> future = dataMongoTemplate.updateFirstAsync(componentDbData);
//9、可以检查执行结果
future.whenCompleteAsync((result, t) -> {
if (t != null) {
log.error("exception caught.", t);
} else {
log.info("update async finish, result:{}", result);
}

applicationContext.close();
});

log.info("application run finish.");
}
}
}

当执行完第4步或第4.1步后,MongoDB中数据为:

1
2
3
4
5
6
7
8
9
10
11
12
13
{ 
"_id" : ObjectId("6058279e4cff6830d4ff1fd1"),
"_class" : "org.gamedo.db.EntityDbPlayer",
"ComponentDbBag" : {
"itemList" : [],
"_class" : "org.gamedo.db.ComponentDbBag"
},
"ComponentDbPlayerInfo" : {
"name" : "testName",
"level" : NumberInt(1),
"_class" : "org.gamedo.db.ComponentDbPlayerInfo"
}
}

第59步骤属于gamedo.persistence提供的异步局部增量更新的特性,在团队协作开发中,一般会把第89步封装到一个单独的模块中,进行统一的持久化操作,比如每隔5秒检查 **DbData.isDirty()**,并进行异步局部增量更新,而对于上层使用者只需要进行标脏操作即可。

当程序运行结束后,MongoDB中数据为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{ 
"_id" : ObjectId("6058279e4cff6830d4ff1fd1"),
"_class" : "org.gamedo.db.EntityDbPlayer",
"ComponentDbBag" : {
"itemList" : [
NumberInt(1)
],
"_class" : "org.gamedo.db.ComponentDbBag"
},
"ComponentDbPlayerInfo" : {
"name" : "testName",
"level" : NumberInt(1),
"_class" : "org.gamedo.db.ComponentDbPlayerInfo"
}
}

通过对比可知,文档中仅仅ComponentDbBag.itemList里增加了一个NumberInt(1),而控制台日志输出为:

1
2
2021-03-16 15:21:05.265  INFO 36044 --- [           main] org.gamedo.Application                   : application run finish.
2021-03-16 15:21:05.275 INFO 36044 --- [nPool-worker-19] org.gamedo.Application : updateFirstAsync finish, result:AcknowledgedUpdateResult{matchedCount=1, modifiedCount=1, upsertedId=null}

通过日志可知:

  1. 持久化发生在nPool-worker-19线程,而非main主线程
  2. 持久化日志在主线程日志之后打印,也即不会阻塞main主线程的业务逻辑

设计思想

ECS:组合优于继承

在gamedo.persistence中,游戏持久化对象数据被定义为:EntityDbData。它对应于ECS中的E(Entity),并且和ECS中的Entity相同的是:EntityDbData仅仅只是一个数据容器(当然,如果非得在EntityDbData的子类内增加成员变量也是可以的),真正要存储的数据都放在ComponentDbData中,对应于ECS中的C(Component),同时,EntityDbData和ComponentDbData都实现了DbData接口,通过观察DbData接口的定义,可以知道:

  • 它包含一个String类型的Id,映射到MongoDB的Document的_id字段
  • 它包含一个更新器,代表着这个DbData内的属性都可以通过$set操作符进标脏

在gamedo.persistence的实现中,EntityDbData中含有一个Map<String, ComponentDbData>的成员变量,其中Key为ComponentDbData所代表的java.lang.Class的getSimpleName()返回值,Value为ComponentDbData的子类实现。DbData、EntityDbData、ComponentDbData的继承关系如图所示:

gamedo.persistence.DbData-UML.png

高性能:化整为零

在游戏服务器框架的持久化业务场景中,一般情况都是游戏逻辑线程负责对游戏持久化对象数据进行修改,而在另外一个线程(以下简称为db线程)对该持久化数据进行读操作,并将之持久化到db中。这样读写分离的操作是为了带来更好的性能,假设对于持久化数据的修改和持久化操作都在业务线程中,这将会极大地影响业务线程。而读写操作不在同一个线程也带来了java编程中最常见的多线程问题:内存可见性和并发竞争。总而言之,就是要解决如何将游戏持久化对象数据安全地发布到db线程中,一种经典的做法是先将持久化对象数据序列化成中间状态,发布到db线程后再反序列化为原来状态后执行持久化操作。而这种操作带来了一个缺点,就是:带来了无谓的性能损耗。当持久化对象数据非常大时,这种性能损耗将会更加明显,即使仅修改一个简单的成员变量,也要执行全局序列化/反序列化。

由于gamedo.persistence模块的底层数据库是MongoDB,而后者提供了 “$set”操作符,这允许用户可以对MongoDB中文档(甚至是内嵌的文档)的字段进行局部更新,gamedo.persistence正是利用了这个特性,设计了一个线程安全的更新器(Updater),并为每个Entity和Component配备了一个独立的Updater, 并通过一系列的封装和设计,使之具有简单易用的特性。此外Updater内部使用了spring-data-mongodb的Update,当安全地发布到db线程后,可以直接执行持久化操作,而不会带来额外的性能反序列化性能开销。这种化整为零的拆分思想保证了只有需要更新的字段才会进行持久化操作,而无需进行无谓的全局序列化/反序列化。

  1. 一句话概括:把托管在github上的代码发布到sonatype nexus仓库,后者通过自动机制同步到maven中央仓库,可以参考官方的帮助文档:OSSRH Guide

  2. 创建sonatype jira账号,创建一个jira issue,如果是groupId是自有域名需要证明,例如增加TXT记录

  3. 由于执行maven的deploy phase时,需要将代码推送到sonatype的仓库,因此需要配置用户名和密码,方法就是在.m2/setting.xml里配置server字段,配置内容为:

    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
    <settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
    https://maven.apache.org/xsd/settings-1.0.0.xsd">
    <localRepository/>
    <interactiveMode/>
    <offline/>
    <pluginGroups/>
    <servers>
    <server>
    <id>ossrh</id>
    <username>sonatype jira's user name</username>
    <password>sonatype jira's password</password>
    </server>
    </servers>
    <mirrors/>
    <proxies/>
    <profiles>
    <profile>
    <id>nexus-release</id>
    <properties>
    <gpg.passphrase>gpg's passphrase</gpg.passphrase>
    </properties>
    </profile>
    </profiles>
    <activeProfiles>
    <activeProfile>nexus-release</activeProfile>
    </activeProfiles>
    </settings>

    如上所示,需要配置servers.server节点,至于profiles.profile节点,后面会用到

  4. 当执行到maven的verify phase时,需要调用maven-gpg-plugin插件的sign这个goal,目的是对推送到仓库的所有文件进行签名,而这个goal又会默认调用gpg这个系统命令,因此需要提前安装gpg,如果是在windows,需要下载windows下的gpg4win,安装后生成gpg key:gpg --generate-key,并将公钥分发到远程服务器:gpg --keyserver hkp://pool.sks-keyservers.net --send-keys B87AC4F5C8D3C23B,也可以使用gpg4win附带的可视化工具Kleopatra对gpg paire进行备份、生成吊销证书、分发到远程服务器。

  5. 创建开源项目,并托管到github,保证pom.xml内必备字段都完备,比如:GAV、licenses、developers、scm、issueManagement,如果是非pom打包,还要求有doc的jar包和源码的jar包,因此需要配置:maven-source-pluginmaven-javadoc-plugin插件,详情可以参考sonatype官方文档说明,详情参考的gamedo项目的配置,配置过程中需要注意:

    1. snapshotRepository.id需要保持和setting.xml中的server.id一致,maven通过pom.xml和setting.xml实现配置分离和继承,公共数据配置在pom.xml中,私密数据配置在setting.xml中
    2. maven-gpg-plugin插件中的configuration.gpgArguments内的两个参数的目的是让gpg插件自动输入密码,该配置参考自该链接
  6. 进入发布循环流程(参考文档):

    1. 开发,开发,开发

    2. 发布snapshots版本:mvn clean deploy -P nexus-release(需要保证版本号以 -SNAPSHOT 结尾)

    3. 版本稳定,准备发布release版本,设置新版本:export newVersion=1.0.0 && mvn versions:set -DnewVersion=${newVersion}(该操作是去掉 -SNAPSHOT 后缀)

    4. 发布新版本到nexus:mvn clean deploy -P nexus-release,由于nexus-staging-maven-plugin插件中的autoReleaseAfterClose设置为false,发布后会驻留在staging库,不会自动同步到release库

    5. 如果发现版本有问题,可以从staging库删除:mvn nexus-staging:drop,当确认无误后,可以发布到release库:mvn nexus-staging:release,nexus会在十分钟后自动同步到maven中央库,并且会在jira里自动评论:OSSRH-65516

    6. 提交新的tag到git中

      1
      2
      3
      git commit -m "Release ${newVersion}"
      git tag ${newVersion}
      git push --tags
    7. 开启下一次版本迭代,修改新的版本号export newVersion=1.1.0-SNAPSHOT && mvn versions:set -DnewVersion=${newVersion} && git commit -m "new feature version"

  1. spring-data-mongodb向mongodb-driver执行find指令时,后者解析后返回一个BsonDocument,BsonDocument继承自BsonValue,并且implements了Map<String, Bsonvalue>接口(内部通过LinkedHashMap实现),其成员如下,可以参考mongodb API

    1
    2
    3
    4
    5
    6
    waitedMS		--> BsonInt64
    cursor --> BsonDocument
    firstBatch --> BsonArrayWrapper //第一批读取到的document列表(ArrayList<Document>),当decode后被wrapper到BsonArrayWrapper
    id --> BsonInt64 //游标的Id,
    ns --> BsonString //NameSpace的缩写,组成为:数据库名.文档名,例如:test.Bag
    ok --> bsonDouble //标明命令是否执行成功:1:成功;0:失败
  2. spring-data-mongodb的基础事件:org.springframework.data.mongodb.core.mapping.event.MongoMappingEvent,可以继承AbstractMongoEventListener,当执行document在进行删除前、删除后、存储前、存储后、转换前、转换后、加载后时,可以执行自定义操作。

  3. MongoDb生成唯一Id最佳实践(来源):

    • 定义生成唯一Id的java类:

      1
      2
      3
      4
      5
      6
      7
      @Document(collection = "database_sequences")
      @Data
      public class DatabaseSequence {
      @Id
      private String id;
      private long seq;
      }
    • 定义生成唯一Id的函数:

      1
      2
      3
      4
      5
      6
      public long generateSequence(String seqName) {
      DatabaseSequence counter = mongoOperations.findAndModify(query(where("_id").is(seqName)),
      new Update().inc("seq",1), options().returnNew(true).upsert(true),
      DatabaseSequence.class);
      return !Objects.isNull(counter) ? counter.getSeq() : 1;
      }
    • 可以使用AbstractMongoEventListener实现自动化设置,例如:

      1
      2
      3
      User user = new User();
      user.setId(sequenceGenerator.generateSequence(User.SEQUENCE_NAME)); //User.SEQUENCE_NAME为static string类型
      user.setEmail("john.doe@example.com");
  4. spring-data-mongodb实例化entity的算法如下所示(原文地址):

    • 如果包含无参构造参数,则使用该构造函数,其他有参构造函数被忽视
    • 如果只有一个含有参数的构造函数,将该构造函数将会被使用
    • 如果有多个有参构造函数,那么含有@PersistenceConstructor注解的构造函数将会被使用。
  5. 在加载数据并创建领域对象时,Spring Data会在运行时生成对象工厂(继承自接口:ObjectInstantiator),并且调用领域对象的构造函数,而并没有使用反射,这提升了10%性能,但是如果要是用该特性必须满足以下条件(原文地址):

    • 不能是私有类
    • 不是是非静态的内部类
    • 不能是CGLib代理类
    • 被Spring Data选中的构造函数不能是私有的
  6. Spring Data给予的一般性建议((原文地址)[https://docs.spring.io/spring-data/mongodb/docs/3.1.6/reference/html/#mapping.general-recommendations]):

    • 尽量使用不可变实体对象(immutable objects
    • 如果无法将实体设计为不可变对象,那么提供一个全参构造函数,如此一来Spring Data可以将对象属性一次性赋值成功,从而跳过属性设置,最终获得最佳性能。
    • 全参构造函数可以提供最佳的性能,所以要尽量避免由于出现多个重载构造函数而使用*@PersistenceConstructor* 注解,而要防止多个重载的构造函数,那就尽量使用静态的工厂方法
    • 确保Spring Data的对象工厂(instantiator )和属性访问器(property accessor classes)可以正常被使用,也即第5条的4个条件都要被满足
    • 对于标识符字段(也即将会被序列化为_id字段的属性),声明为final,并且优先使用全参构造函数,其次使用with…方法
    • 使用Lombok 来避免样板代码,并且使用*@AllArgsConstructor*来生成全参构造函数
  7. Spring Data在映射层处理_id字段的规则

    • 如果类的成员变量被增加了@Idorg.springframework.data.annotation.Id)注解,那么将会被映射为MongoDB的_id字段
    • 如果类的成员变量没有@Id注解,但是被命名为id,那么也会被解析映射为_id字段
    • 作为标识符字段的默认类成员变量名_id,并且可以被@Field字段注解

  1. Doug Lea在他的论文中写道:

    • JUC同步器框架的主要性能目标是实现可伸缩性,也即:当发生锁竞争时,可预测地保持效率。在理想的情况下,无论多少线程尝试通过同步点,所需的开销都是常量。(更确切的讲)其中一个主要目标是:某一线程被允许通过同步点但还没有通过的情况下,使其耗费的总时间最少。
    • 实现同步器的上述目标包含了两种不同的使用类型。大部分应用程序是最大化其总的吞吐量,容错性,并且最好保证尽量减少饥饿的情况(非公平锁)。然而,对于那些控制资源分配的程序来说,更重要是去维持多线程读取的公平性,可以接受较差的总吞吐量(公平锁)。没有任何框架可以代表用户去决定应该选择哪一个方式,因此,应该提供不同的公平策略。
  2. AQS队列的head node很特殊,它属于一个占位node,其成员变量pre、next、thread都为null,也就是说队列中第2个node才是真正排队的队首。

  3. 对于AbstractQueuedSyncchronizer的入队函数:enq,当第一次调用(也即初始化队列)时,内部的for循环至少循环2次,第一次设置head、第二次设置tail。

  4. AQS的公平锁实现FairSync中,获取锁的流程为(假定请求锁的线程名为t2):

    • 首先尝试获取锁(FairSync::tryAcquire):
      • 如果发现state == 0(没有人拿到锁),并且有资格拿锁(也即hasQueuedPredecessors返回false,从字面意思讲,这个函数代表是否有线程在当前线程前面排队中,返回true的条件需要同时满足两个:1、队列已经初始化,2、队列中只有一个占位node(前面有一个node在enq一半时会出现这种情况)或者首个排队node的线程不是本线程),则尝试CAS设置state为0,如果设置成功,则获取锁成功并返回。
      • 如果发现state != 0,并且当前线程获取了锁,则增加锁的数量,并且获取锁成功并返回。
    • 如果没有获取到锁:先CAS入队(AbstractQueuedSynchronizer::addWaiter,重点1),其流程为:
      • 检查队列是否已经初始化(tail != null),如果已经初始化则CAS设置当前node为队尾
      • 如果队列已经初始化或者上一步CAS设置队尾失败,则自旋进行入队操作(AbstractQueuedSynchronizer::enq)。
    • 入队后,开始自旋(AbstractQueuedSynchronizer::acquireQueued),其流程为:
      • 先再一次检测能否获取锁(设计思路是:t2入队后,可能前面的线程正好释放了锁,此时t2虽然在队中,但是可能就是队首(也即是队列的第2个node),那就不需要排队)
      • 如果还是拿不到;检测是否需要park,检测条件为:pre.waitState == 0(并且前前一个node的waitState是由当前节点修改为0的,为什么呢?因为前一个node已经睡眠中,无法修改自己,有意思!),如果需要(第一次自旋时必然不成立,乐观锁的天性:尽量不park),则调用LockSupport.park阻塞当前线程。
      • 如果拿到锁直接返回
  5. 一个线程尝试获取ReentrantLock公平锁时,最多会尝试3次获取锁,发生的场景是锁已经被其他线程占有,但是该请求线程是队列的队首,该请求首先会尝试获取,获取失败后,在入队park前会自旋2次尝试获取。最少会1次尝试锁,发生的场景是锁已经被其他线程占有,且该请求线程入队后也不是队首。

  1. (2.2.1 竞态条件)静态条件的两种场景类型:“先检查后执行(Check-Then-Act)”和“读取-修改-写入”,例如对于线程安全的Vector,当执行如下代码时:

    1
    2
    if(!vector.contains(element))
    vector.add(element);

    就存在“先检查后执行”的竞态条件。

  2. (2.4 用锁来保护状态)一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。换一个方式解读就是:如果存在的可变状态多于一个,并且要保证这些可变状态的修改是原子的,那么约定做法就是封装在一个类中,并且对该类进行操作时,都要先获取该类对象的内置锁。

  3. (2.5 活跃性与性能)如果只有一个变量需要原子操作,那么使用Atomic是很有用的,但是如果多个变量需要原子性操作,那么就比较适合使用同步代码块,并且取消Atomic的使用。

  4. (3.1.3 加锁与可见性)这一节有一个重要的结论:

    加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。

    换句话说,加锁有两层用途:互斥排他和内存可见性。而后者是大家很容易忽视的。那内存可见性的一个应用场景就是:某一个线程写,其他线程读,这种情况下不存在并发竞争,而值存在内存可见性的问题。而这种情况下,大部分都是使用volatile,这也是为什么加锁的内存可见性的用途被忽视的另外一个原因。

  5. (3.2 发布与逸出)构造函数中this逸出的三种场景:

    • 在构造函数中new了内部匿名类,那么匿名类中就包含this指针
    • 在构造函数中启用了新的线程,并且构造后直接启动
    • 在构造函数中调用一个可改写的实例方法时(既不是私有方法,也不是终结方法)
  6. (3.3.1 Ad-hoc线程封闭)只要你能确保只有单个线程对共享的volatile变量执行写入操作,那么就可以安全地在这些共享的volatile变量上执行“读取-修改-写入”的操作。在这种情况下,相当于将修改操作封闭在单个线程中以防止发生竞态条件,并且volatile变量的可见性保证还确保了其他线程能看到最新的值。

  7. (3.5.1 不正确的发布:正确的对象被破坏)本节中的AssertionError异常的例子的简单解释:1、JMM规范中并没有要求对象字段的初始化要happens before与另一个线程对该对象的可见性;2、if(n != n)并非一个原子性操作,而需要3步操作,因此会出现第一次读取的n值和第二次读取的n值不相同的情况。而如果将对象的字段改成volatile后就能避免AssertionError,这是因为JMM有规范要求:volatile sore要可见于其他线程。关于本节例子的两个很好的补充解释:知乎StackOverflow

    if(n != n)这行代码可以联想到一种并发编程易出错场景:某个函数里引用一个会被并发修改的字段,那么可能会出现在函数逻辑过程中发生变化的情况。这就引入了4.1.2 依赖状态的操作的内容,已经并发编程的一个基本思想:但在并发程序中,先验条件可能会由于其他线程执行的操作而变成真。在并发程序中要一直等到先验条件为真,然后再执行该操作(死循环加CAS?)。

  8. (4.3.3 当委托失效时)虽然AtomicInteger是线程安全的,但经过组合得到的类却不是。也就是说,如果一个类中的字段即使都是线程安全的,但是这个类也不一定是线程安全的。当这些字段之间存在某些不变性条件时,就会导致“先检查后执行”操作的出现。而这是前面所讲的竞态条件的一种常见场景。

  9. (4.3.5 示例:发布状态的车辆追踪器)注意本节的批注[1],很重要但是很容易忽视的一个并发编程错误场景!

  10. (5.1.3 隐藏迭代器)Synchronized*容器的toStringhashCodeequals函数会调用迭代器,因此在调用这些方法时,记得加锁,例如记录日志时或者将这些容器作为另一个容器的key或value时。这都是很容易忽视的!否则在迭代的过程中如果其他线程执行了并发增删操作,很有可能抛出ConcurrentModificationException。另外Synchronized*容器没有putIfAbsent之类的函数,如果要实现类似的检查并执行的操作,需要自己加锁!或者使用专用的并发容器,例如:ConcurrentMap

  11. (5.4 阻塞方法与中断方法)当捕获到InterruptedException时,不要私自吞掉该异常,要么继续向上传递该异常或者干脆不不捕获该异常,要么重新恢复中断:Thread.currentThread().interrupt();

  12. (5.5 同步工具类)闭锁(CountDownLatch):属于消耗性的只减不增的递减计数器,计时器为零时解锁;FutureTask:属于特殊的二元闭锁,运行中时阻塞,运行结束时解锁;信号量(Semaphore):属于池化的计数器,可赠可减,当池中计数器为0时阻塞,可以用任何容器加Semphore组合为有界容器。栅栏(CyclicBarrier):可以复用的递增计数器,初始化时,计数器为0,计数器未满时阻塞,计时器满时解锁,并且重置计数器。Exchanger:特殊的两方栅栏,任何一方都先发起交换请求,等待另一方响应后,交换达成,适用于读写线程进行无GC的缓冲交换(示例:baeldung)。

  13. (7.1.1 中断)对中断的正确理解是:它并不会真正地中断一个正在运行的线程,而是发出中断请求,然后由线程(自己决定)在下一个合适的时刻中断自己。通常,中断是实现取消的最合理方式。这句话的意思是,程序不需要自己顶一个类似volatile的cancelled的状态,并轮询检测该状态,已决定是否取消。如果采用中断的方式,那么可以调用:inerrupt(),如果线程处于阻塞中,会收到InterruptedException,如果处于非阻塞中,其interrupted状态会被标记。因此完整的程序框架类似于:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class MyThread extends Thread {
    public void run() {
    try {
    while(!Thread.currentThread().isInterrupted()) {
    //执行业务逻辑
    }
    }
    catch(InterruptedException e) {
    //线程退出
    }
    }

    public void cancel() {interrupt();}
    }
  14. (7.1.2 中断策略)这一节有时间再继续消化!

  15. (7.1.5 通过Future来实现取消)Future.get(long, TimeUnit)会返回4种异常,需要对这4种异常进行进行处理,特别是InterruptedException,不要私自吞掉!当一个Future已经完成后,再次调用cancel也不会有什么影响。

  16. (7.1.7 通过newTaskFor来封装非标准的取消)这一节主要讲了一件事:如果想在执行Future.cancel时执行额外的操作,那么:1、集成Callable接口,实现自己的Callable;2、继承ThreadPoolExecutor,重写newTaskFor函数,如果发现不是自己实现的Callable,那么调用super,否则返回一个自己实现的FutureTask,这个FutureTask的cancel函数先执行自己的一些操作,然后再调用super。

  17. (7.2 停止基于线程的服务)一个重要的概念:只有线程的所有者有权关闭线程,而线程池是其工作线程的所有者。

  18. (8.1.1 线程饥饿死锁)在单线程的Executor中,如果任务一将任务二提交到同一个Executor,那么会导致饥饿死锁!而在多线程的Executor中,如果这种现象比较多,也可能发生死锁。因此有一条规则:不要在同一个Executor中递归提交任务。当Executor A依赖Executor B时,A的有效线程数量(有可能)实际上隐式地依赖于B的线程数量。例如某逻辑线程池使用了包含10个连接的JDBC线程池。

  19. (8.2 设置线程池的大小)对于计算密集型任务,线程池的大小为CPU数量 + 1时,能实现最优利用率。而对于IO密集型线程池大小的评估公式为:CPU数量 * CPU利用率(大于等于0且小于等于1)* ( 1 + IO等待时间 / 计算时间)

  20. (12.3.5 无用代码的消除)在性能测试过程中,有一些技巧可以避免编译器对某些无用的代码进行消除,大致思路就是保证计算结果被使用,但是不要引入IO操作,而导致了性能偏差。下面的例子是一个很好的技巧:

    1
    2
    if(foo.x.hashCode() == System.nanoTime())
    System.out.print(" ");

    这段代码绝大数情况下不会成功,即使成功,也只是输出一个空字符。

  21. (14.2.2 过早唤醒)Object的内置对象锁存在一个条件队列,而唤醒这个条件队列的条件谓词可能不止一个,也就是说导致唤醒一个Object线程的条件不止是一个,因此当Object在wait后被唤醒时,仍然需要继续检测当时导致wait的同一个条件谓词,这也从另外一个角度说明了为什么要循环检测条件谓词(另外一个条件是:唤醒现象的发生

  22. (14.2.4 通知)每当在等待一个条件时,一定要确保在条件谓词变为真时通过某种方式发出通知。换句话说:wait和notify(notifyAll)一般都是在同一个函数内成对出现?另外由于同一个条件队列可以对应于多个条件谓词,因此尽量使用notifyAll而非notify(如果非得使用notify,需要满足两个条件:1、唯一条件谓词:条件谓词只有一个;2、单进单出:最多只能唤醒一个线程来执行,换句话说就是唤醒后,只有一个线程可以继续工作,剩下的线程会竞争失败而继续wait),否则会面临信号丢失的风险。

  23. (14.2.5 示例:阀门类)这一节的例子中,generation字段可以理解为批次,用一个形象的比喻就是这个Gate前可以等待一批又一批的线程,在await函数里,当线程被notifyAll后,当且仅当门是关闭状态并且当前的批次还是自己等待时的批次,才会再次wait(如果门已经开启,或者已经换了批次,那就证明gate肯定是开过,那我必须过去!)。

  24. (14.3 显示的Condition对象)与内置锁和条件队列一样,当使用显示的Lock和Condition时,也必须满足锁、条件谓词和条件变量之间的三元关系。在条件谓词中包含的变量必须有Lock来保护,并且在检查条件谓词以及调用await和signal时,必须持有Lock对象。

  25. (15.3 原子变量类)原子标量类(AtomicInteg、AtomicLong、AtomicBoolean、AtomicReference)没有重新定义hashCode和equals方法,因此每一个实例都是不同的,因此也不适用于做散列容器的键值。

  1. (2.2 运行时数据区域)jvm内存划分为5个区域,简单说就是2个堆(堆和非堆(方法区的别称))、2个栈(虚拟机栈和本地方法栈)、1个程序计数器

  2. (2.2.5 方法区)方法区和永久代的区别在于(JDK 8以前),前者是java虚拟机规范的定义,而永久代是HotSpot虚拟机下特有的实现,且属于方法区的一部分,这么设计的目的就是为了垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的 工作。

  3. (2.3.1 对象的创建)本节讲到:由字节码流中new指令后面是否跟随invokespecial指令所决定,Java编译器会在遇到new关键字的地方同时生成 这两条字节码指令,验证测试代码如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    package com.example.demo.test;

    public class Test2 {

    public static void main(String[] args) {
    final Test2 test2 = new Test2();
    System.out.println(test2);
    }
    }

    执行:javac Test2.java,然后执行:javap -v Test2.class查看字节码文件,输出内容为:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    Classfile /mnt/f/Develop/Project/demo/src/main/java/com/example/demo/test/Test2.class
    Last modified Dec 14, 2020; size 436 bytes
    MD5 checksum b6b5fa11a474d65891c6991ba0d7143a
    Compiled from "Test2.java"
    public class com.example.demo.test.Test2
    minor version: 0
    major version: 52
    flags: ACC_PUBLIC, ACC_SUPER
    Constant pool:
    #1 = Methodref #6.#15 // java/lang/Object."<init>":()V
    #2 = Class #16 // com/example/demo/test/Test2
    #3 = Methodref #2.#15 // com/example/demo/test/Test2."<init>":()V
    #4 = Fieldref #17.#18 // java/lang/System.out:Ljava/io/PrintStream;
    #5 = Methodref #19.#20 // java/io/PrintStream.println:(Ljava/lang/Object;)V
    #6 = Class #21 // java/lang/Object
    #7 = Utf8 <init>
    #8 = Utf8 ()V
    #9 = Utf8 Code
    #10 = Utf8 LineNumberTable
    #11 = Utf8 main
    #12 = Utf8 ([Ljava/lang/String;)V
    #13 = Utf8 SourceFile
    #14 = Utf8 Test2.java
    #15 = NameAndType #7:#8 // "<init>":()V
    #16 = Utf8 com/example/demo/test/Test2
    #17 = Class #22 // java/lang/System
    #18 = NameAndType #23:#24 // out:Ljava/io/PrintStream;
    #19 = Class #25 // java/io/PrintStream
    #20 = NameAndType #26:#27 // println:(Ljava/lang/Object;)V
    #21 = Utf8 java/lang/Object
    #22 = Utf8 java/lang/System
    #23 = Utf8 out
    #24 = Utf8 Ljava/io/PrintStream;
    #25 = Utf8 java/io/PrintStream
    #26 = Utf8 println
    #27 = Utf8 (Ljava/lang/Object;)V
    {
    public com.example.demo.test.Test2();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
    stack=1, locals=1, args_size=1
    0: aload_0
    1: invokespecial #1 // Method java/lang/Object."<init>":()V
    4: return
    LineNumberTable:
    line 3: 0

    public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
    stack=2, locals=2, args_size=1
    0: new #2 // class com/example/demo/test/Test2
    3: dup
    4: invokespecial #3 // Method "<init>":()V
    7: astore_1
    8: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
    11: aload_1
    12: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
    15: return
    LineNumberTable:
    line 6: 0
    line 7: 8
    line 8: 15
    }
    SourceFile: "Test2.java"

    上述输出中,第54行为源码第6行的final Test2 test2 = new Test2();,第56行就是文中说的invokespecial。

  4. (2.3.2 对象的内存布局)源码地址:jdk8

    Mark Word(64 bits) 锁状态
    unused:25 | hash:31 | unused:1 | age:4 | biased_lock:0 | lock:01 正常
    thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:01 偏向锁
    ptr_to_lock_record:62 | lock:00 轻量级锁
    ptr_to_heavyweight_monitor:62 | lock:10 重量级索
    | lock:11 GC标记

    当开启指针压缩(UseCompressdOops)时,正常状态和偏向锁状态下的两个unused字段会变成:cms_free,具体参考:stackoverflow

  5. (2.3.3 对象的访问定位)句柄访问和直接指针访问的本质区别在于:前者是间接引用(相当于中间又增加了一层,从而带来了更好的灵活性),后者是直接引用。

  6. (2.4.2 虚拟机栈和本地方法栈溢出)经测试64位Linux下Xss最小值为:228k,默认值为:1024K(64位Windows 10下,两个值分别为:108K和0)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    java -Xss128K com.example.demo.test.Test2                                                                             
    The stack size specified is too small, Specify at least 228k
    Error: Could not create the Java Virtual Machine.
    Error: A fatal exception has occurred. Program will exit.

    java -XX:+PrintFlagsFinal -version | grep ThreadStackSize
    intx CompilerThreadStackSize = 0 {pd product}
    intx ThreadStackSize = 1024 {pd product}
    intx VMThreadStackSize = 1024 {pd product}
    openjdk version "1.8.0_275"
    OpenJDK Runtime Environment (build 1.8.0_275-8u275-b01-0ubuntu1~18.04-b01)
    OpenJDK 64-Bit Server VM (build 25.275-b01, mixed mode)

    TIPS:

    • 如果运行程序出现了OutOfMemoryError异常,发生的区域很小的几率发生在虚拟机栈(主要发生区域是堆),理由是:HotSpot虚拟机不支持栈的动态扩展,所以线程运行期间不会申请内存扩展,从而不会导致OOM,那么只会出现在创建线程申请内存时出现OOM,有3种措施会可以促进这种OOM:1、虚拟机栈空间尽量小;2、每个线程的栈内存尽量大(Xss);3、创建的线程足够多。更详细地说,如果是在32位Windows系统下,由于单个进程最大内存限制是2G,那么排除掉Xmx的堆最大容量、直接内存、jvm本身消耗内存以及程序计数器(可以忽略不计)内存,剩下的内存才是虚拟机栈和本地方法栈的可用内存,如果虚拟机栈内存足够小,而且Xss配置得又偏大,那么,如果创建非常多线程的情况下,是有可能出现创建线程时导致虚拟机栈OOM的。
    • 如果允许程序出现了StackOverflowError异常,比较大的可能性是递归调用导致了栈溢出,比较小的可能性是因为调用堆栈过深导致栈溢出,因为对于后者来说,默认Xss的大小为1024k,是很难导致溢出的。
  7. (2.4.3 方法区和运行时常量池溢出)自JDK 7起,原本存放在永久代的字符串常量池被移至Java堆之中

  8. (2.4.4 本机直接内存溢出)OOM后,dump文件比较小,而且又使用的Direct Memory,那么就要考虑是否是直接内存OOM

  9. (3.2.3 再谈引用)强引用:打死也不能回收;软引用(SoftReference):OOM前回收;弱引用(WeakReference):下次GC时回收;虚引用(PhantomReference):根本不算引用,因为无法获得引用对象,应用场景:为了能在这个对象被收集器回收时收到一个系统通知。

  10. (3.3.1 分代收集理论)

    • 垃圾分代收集的本质在于对象的年龄。朝生夕灭的小鲜肉放在一起管理(称之为年轻代),相亲无数(也就是躲过了多次垃圾回收)的大龄青年(称之为老年代)放在一起管理,这可真够现实的。
    • 除了CMS收集器,其他都不存在只针对老年代的收集
  11. (3.3.2 标记-清除算法(Mark-Sweep))原理:标记处房间内的垃圾,然后直接清理掉,缺点:房间还是很乱(内存碎片),因为只清理了垃圾,而没有整理房间

  12. (3.3.3 标记-复制算法(Mark-Copy))多用于年轻代垃圾收集器,原理:

    • 基础版:房间分成两部分,每次只用其中一部分,每次回收时,清理垃圾,并将物品搬到另一个房间,缺点:浪费空间。
    • 进阶版:房间分成三部分,一大两小(8:1:1),每次只用一大和一小,每次回收时,清理垃圾,并将物品搬到另一个房间
  13. (3.3.4 标记-整理算法(Mark-Compact))多用于老年代垃圾收集器,原理:在标记-清除算法的基础上,额外增加整理(更形象的说,是“压缩整理”,从标记-整理算法的英文名:Mark-Compact就能看出来)的功能。

    TIPS:

    • 对于CMS收集器,平时多数时间都采用(并发)标记-清除算法,这也应对CMS的全称:Concurrent Mark Sweep,当空间碎片过多时采用标记-整理算法。这也对应CMS算法的缺点:1、容易产生内存碎片;2、一旦由于碎片过多触发了标记整理算法,就会导致长时间的STW
  14. (3.4.2 安全点)安全点停顿:几乎所有虚拟机不再采用抢先式中断(Preemptive Suspension)而是采用主动式中断(Voluntary Suspension),也即虚拟机负责设置标记位,各个线程负责轮询检查标记位,然后运行到安全点上(有点类似于游戏服务器的某个全局模块设置标记位,在线玩家心跳逻辑里轮询检测这个标记位)。

  15. (3.4.4 记忆集与卡表)通俗地讲,从垃圾收集区域来看,Remembered Set指的是谁指向了我的集合(如果有人指向了我,那么这个“我”就不能当做垃圾被回收)!这是一个抽象的逻辑概念模型。从具体实现的角度来看,最原始的Remembered Set就是存储对方所有跨带引用对象的集合,但是这是非常低效的,因此可以把这个“我”的区域(也即垃圾收集区域)划分成若干子区域(每个区域称之为卡页:Card Page),然后用一个数组标记该区域是否含有跨代引用指针(这个数组就是卡表:Card Table),在GC时将Card Table中存在跨代引用的Card Page进行扫描,从而减少GC Roots的扫描的范围(这种分组的思想是不是很熟悉,想一下武汉市在医疗资源紧缺时采取的新冠核酸筛的混检模式,就是讲每10人混合成一个样本,筛查效率理论上可以提升到原来的10倍)。

  16. (3.4.5 写屏障)这一节讲了两件事:

    • 卡表的状态维护机制:写屏障,通俗讲就是虚拟机层面的AOP

    • 当多线程写同一块内存时,如果这块内存被同一个缓存行的卡表对应,那么意味着这个位于同一个缓存行的卡表会被多个线程修改,导致性能降低,优化机制为:不要无条件修改卡表,而是加一个判断(额,这个优化真的非常非常初级啊)

  17. (3.4.6 并发的可达性分析)

    • 三色标记:白色:还未过安检;黑色:已经过安检,安全;灰色:安检中
    • 两种条件同时满足时,会出现白色误判为垃圾:1、扫描期间某黑色新指向了白色对象;2、同时某灰色到该白色的直接或间接引用也恰巧删除。这就导致了该白色对象仅被黑色对象引用,而黑色对象是安全的,不会再次扫描,这就导致错过扫描而被误判为垃圾。举一个形象的例子:三个包过机场的安检仪:黑包–灰包–白包这3个包用绳子相继连着,此时黑包已过安检,灰包安检中,白包等待安检,突然风雨突变,灰包和白包的绳子莫名断掉,同时有一股神秘力量将黑包和小白连起来(来自用户线程),但是这个连线却没穿过安检仪,那这种情况下白包就会错过安检(这个例子真是绝,我真是大聪明!)
    • 消除白色误判的两种策略:1、破除上述条件一,也即增量更新(Incremental Update):如果黑色在扫描期间指向了白色,那就让它变灰;2、破除上述条件二,也即原始快照(Snapshot At The Beginning, SATB):先拍个照,当进行扫描时,不管灰色是否删除了白色的指向,都按照这个快照扫描。CMS是基于增量更新,G1、Shenandoah则是用原始快照。
  18. (3.5.6 CMS收集器)

    • 收集过程分成4个阶段:

      1. 初始标记(CMS initial mark):标记GC Roots能直接关联到的对象,会引发短暂的STW
      2. 并发标记(CMS concurrent mark):也即三色标记阶段,会导致某些白色被误判垃圾,也会导致漏过某些白色而产生浮动垃圾
      3. 重新标记(CMS remark):采用增量更新策略,重新标记,会引发STW
      4. 并发清除(CMS concurrent sweep):这也是CMS名称的由来,并发清除(concurrent sweep)
    • 缺点:

      1. 并发标记对CPU敏感(默认GC线程数=处理器核心数量 +3)/ 4):CPU核心数大于等于4个时,并发标记线程数不超过25%,当并发线程数小于4个时,有一半运算能力执行GC,因此CPU敏感。
    1. 由于GC过程中浮动垃圾无法收集(要留到下一次),因此需要为老年代预留出一部分空闲空间(-XX:CMSInitiatingOccupancyFraction,JDK 6以后该默认值从JDK 5的68%提升至92%),防止并发失败(Concurrent Mode Failure)导致临时启用Serial Old。
    2. CMS正如它的名字所示,属于标记清除算法,因此在GC结束后会导致大量内存碎片。当申请大对象内存时,可能会出现内存碎片过多无法分配,导致提前触发Full GC(有两个参数:-XX:+UseCMSCompactAtFullCollection和-XX:CMSFullGCsBeforeCompaction可以相对改善,具体细节可以使用时查看相关资料)。

    TIPS:

    • 所有收集器中只有CMS有针对老年代的Old GC
  19. (3.5.7 Garbage First收集器)

    • JDK 7 Update 4时,G1的”Experimental“的标识被移除,到JDK 8 Update 40,提供类卸载的支持,变成了Oracle官方称为“全功能的垃圾收集 器”(Fully-Featured Garbage Collector)

    • G1的Remembered Set结构:由于存在多个Region,而每个Region都要维护的Remembered Set都需要存储谁引用了我,因此Remembered本质上是哈希表,key为其他Region的起始地址,value为一个集合,存储的是该Region中卡表的索引号。这存在两个问题:1、Remembered Set是一个双向索引,这维护起来就麻烦了;2、Remembered Set比较多,因此要消耗额外的堆内存,大约在10%~20%之间。

    • Region中有一部分内存用于GC并发回收中的对象分配,这个区域都位于TAMS(Top at Mark Start)指针之上。G1默认TAMS地址以上的对象都是存活的,不进行GC。

    • G 1停顿预测模型一句话总结:每个Region进行回收统计,包括:回收耗时、Remember Set中脏卡数量等,G1根据每个Region的统计状态的新旧评价回收价值,并预测哪些Region集合可以在-XX:MaxGCPauseMillis内回收时获得最高收益。

    • 收集过程同样(相对于CMS)分成4个阶段:

      1. 初始标记(Initial Marking):标记GC Roots能直接关联到的对象,还要额外(相对于CMS)修改TAMS指针的值,会引发短暂的STW
      2. 并发标记(Concurrent Marking):也即三色标记阶段,还要额外重新处理SATB记录上并发标记期间引用改动的对象。
      3. 最终标记(Final Marking):短暂的STW,处理上一步中遗留的少量的SATB记录。
      4. 筛选回收(Live Data Counting and Evacuation):1、更新Region的统计数据,并根据统计价值和成本进行排序;2、筛选Region回收集合;3、把回收Region集合中的存活对象移动到空的Region中,然后清空所有回收集合Region全部空间,由于需要移动存活对象,因此会引发STW,但是会多线程并行完成。
    • 从G1开始,垃圾收集器的设计导向发生变化:追求全堆清理转向追赶内存分配速率(Allocation Rate)。我个人把这个新原则称之为遇熊逃命原则,也即不追求跑得最快,只要求不是最慢即可,换到GC角度上,那就是只要应付得了内存分配的速率即可。只要我打扫垃圾的速度比你制造垃圾的速度足够快,足以。这个理念非常牛B啊,所以才说G1是收集器的一个里程碑。

    TIPS:

    • 按理来说,G1是全堆回收的,那为什么仍然存在年轻代和老年代?个人推测是因为仍然沿用了HotSopt垃圾分代框架,而书中也多次用了“扮演新生代”或“扮演老年代”的说法,这也可以证明这个推测。

    • 书中没讲到的G1的思想:G1实际上采取的是分治算法的思想,将大问题分解成小问题,逐个击破。从整体上讲,它属于标记-整理算法(根据前文内容,整理算法是让所有存活对象移动到内存一端,如果按照这个标准,那G1整体上并非严格意义上的标记-整理算法),而从局部上讲,又属于标记复制算法(Region A移动到Region B)。

  20. (3.6 低延迟垃圾收集器)

    • Shenandoah和G1的3个不同之处:
      • 支持并发整理算法,G1在第4个阶段进行筛选回收时,是多线程并行处理,但是不会并发。
      • 默认不使用分代收集,不存在新生代和老年代的Region
      • 将G1的Remembered Set改为连接矩阵(Connection Matrix):降低维护成本,也降低伪共享的发生概率。
    • Shenandoah收集过程:
      • 初始标记(Initial Marking),标记GC Roots能直接关联到的对象,会引发短暂的与堆大小无关的STW
      • 并发标记(Concurrent Marking):与G1一样,并发且并行
      • 最终标记(Final Marking):与G1一样,处理剩余的SATB扫描,引发短暂的STW
      • 并发清理(Concurrent Cleanup):清理连一个存活对象都没有找到的Region
      • 并发回收(Concurrent Evacuation):这是Shenandoah的核心改进,通过读屏障和Brooks Pointers转发指针实现并发回收。
      • 初始引用更新(Initial Update Reference):确保上一个并发回收阶段的线程都已经完成对象移动任务,会引发STW
      • 最终引用更新(Final Update Reference):修正存在于GC Roots 中的引用,最后一次引发STW,停顿时间只与GC Roots数量有关。
      • 并发清理(Concurrent Cleanup):清理Immediate Garbage Regions。

    TIPS:

    • 本节开头提了:内存占用(Footprint)、吞吐量(Throughput)、延迟(Latency)组成了三元悖论,意指三者只能得其二,类似的三元悖论还有很多,例如分布式系统中著名的CAP定理:一致性(Consistency)、可用性(Availability)、分区容忍性(Partition Tolerance)。详情可以参考:周大的另外一本开源书籍IBM Cloud Learn Hub
    • Shenandoah将Remembered Set改为连接矩阵,从数据管理的视角上来看,实际上是把数据分散处理调整为全局集中处理,这也符合数据驱动开发的思想。例如在游戏开发领域,双向游戏好友是比较常见的社交功能,相比于把好友数据存储到每个玩家身上,维护一系列全局的好友关系数据将是更好的选择。因为如果使用前一个方案,那么在分布式环境下,单就数据一致性的这个问题,就会要求架构底层需要很好的支持,功能逻辑的复杂性也会大大增加。
    • Brooks Pointer通过CAS操作保证并发时对象访问的正确性,CAS真是并发编程一大神器啊!
  21. (3.6.2 ZGC收集器):

    • Region分成3种:Small Region(2MB,存储<256KB对象)、Medium Region(32MB,存储>=256KB&&<4MB 对象)、Large Region(2MB的整数倍,最小为4MB,存储>=4MB对象)
    • Shenandoah收集过程:
      • 并发标记(Concurrent Mark):操作与G1、Shenandoah类似,也会引发STW,区别在于标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked 0、Marked 1标志 位。
      • 并发预备重分配(Concurrent Prepare for Relocate):1、根据特定的查询条件统计得出 本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set);2、类卸载和弱引用处理(从JDK 12开始支持)。
      • 并发重分配(Concurrent Relocate):复制Relocation Set中的存活对象到新Region,每个Region维护转发表(Forward Table),记录转发关系。并发期间,如果对象访问,由于染色指针的存在,ZGC知道该对象是否处于Reloaction Set,因此会被内存屏障截获,转发到新Region上,并同时更新引用(指针自愈)。
      • 并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所 有引用。实际上即使没有这一步,这些对象也可以在之后的访问时,通过指针自愈实现重映射。ZGC将此工作合并到下一次垃圾收集循环的并发标记阶段中,从而更加节省了一次对象图的遍历。

    TIPS:

    • 在上述第3个“并发重分配”过程,当复制Relocation Set中的存活对象到新Region时,周大没有详述实现细节,推测应该有类似的CAS操作来更新对象指针的染色指针,这是一个小遗憾。
    • 根据上述第4个“并发重映射”过程的描述,其过程被合并到下一次GC中,那ZGC岂不是只有3个大的过程?这是一个不太大的疑惑点。
  22. (3.8.5 空间分配担保)在JDK 6 Update 24以前,当老年代最大连续可用空间小于新生代所有对象总和时,两种情况下会触发Full GC:

    • -XX:+HandlePromotionFailure允许担保失败,但是老年代最大可用的连续空间是否小于历次晋升到老年代对象容量的平均大小
    • -XX:-HandlePromotionFailure不允许担保失败

    TIPS:

    在JDK 6 Update 24之后,HandlePromotionFailure参数已经被去掉,测试代码:

    1
    2
    3
    4
    5
    ☺  java -XX:+PrintFlagsFinal -XX:+HandlePromotionFailure  -version
    Unrecognized VM option 'HandlePromotionFailure'
    Did you mean '(+/-)PromotionFailureALot'?
    Error: Could not create the Java Virtual Machine.
    Error: A fatal exception has occurred. Program will exit.

    上述规则调整为:只要老年代的连续空间大于等于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行Full GC,换言之只有这两个条件同时满足,蔡楚发Full GC。

  23. (4.2.2 jstat:虚拟机统计信息监视工具):远程虚拟机进程的VMID为:[protocol:][//]lvmid[@hostname[:port]/servername]

  24. (7.3.5 初始化)关于代码清单7-6中,字段B的值将会是2而不是1:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    static class Parent {
    public static int A = 1;
    static {
    A = 2;
    }
    }

    static class Sub extends Parent {
    public static int B = A;
    }

    public static void main(String[] args) {
    System.out.println(Sub.B);
    }

    书中的解释并不是很彻底,可以使用javac编译,然后借助javap -v 命令得到更清新的答案,其Parent的javap命令解析如下:

    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
    javap -v StaticMethod\$Parent                                                                                                                                                   master ✗
    Warning: Binary file StaticMethod$Parent contains com.example.demo.test.StaticMethod$Parent
    Classfile /mnt/f/Develop/Project/demo/src/main/java/com/example/demo/test/StaticMethod$Parent.class
    Last modified Dec 24, 2020; size 399 bytes
    MD5 checksum 0f4fdfb9857a60ee769dbb6b4a2f3a0d
    Compiled from "StaticMethod.java"
    class com.example.demo.test.StaticMethod$Parent
    minor version: 0
    major version: 52
    flags: ACC_SUPER
    Constant pool:
    #1 = Methodref #4.#14 // java/lang/Object."<init>":()V
    #2 = Fieldref #3.#15 // com/example/demo/test/StaticMethod$Parent.A:I
    #3 = Class #17 // com/example/demo/test/StaticMethod$Parent
    省略……
    {
    省略……

    static {};
    descriptor: ()V
    flags: ACC_STATIC
    Code:
    stack=1, locals=0, args_size=0
    0: iconst_1
    1: putstatic #2 // Field A:I
    4: iconst_2
    5: putstatic #2 // Field A:I
    8: return
    LineNumberTable:
    line 6: 0
    line 8: 4
    line 9: 8
    }
    SourceFile: "StaticMethod.java"
    InnerClasses:
    static #18= #3 of #16; //Parent=class com/example/demo/test/StaticMethod$Parent of class com/example/demo/test/StaticMethod

    通过上述代码可以发现,Line 54-55应对与源码中的类变量的定义:public static int A = 1;,而Line 56-57应对与有静态语句块中的赋值:A = 2;,也就是说:编译后的static静态语句块除了包含程序员的逻辑代码,还额外插入了编译器自动生成的对静态变量初始化的代码,且这些插入代码的位置符合如下原则:如果静态变量定义在static静态语句块上面,那么在程序员逻辑代码上面按照声明顺序插入,否则在程序员的逻辑代码下面按照声明顺序插入。当Parent类完成初始化后,成员变量A的值为2,当Sub类进行初始化时,要调用自己的<clinit>(),按照文中所述:Java虚拟机会保证在子类的()方法执行前,父类的()方法已经执行 完毕。Sub进行初始化时A的值已经是2,因此赋值给B后就是2了。

  25. (7.4.2 双亲委派模型)

    “双亲委派”这个术语实在是翻译得败笔,根据知乎上一个比较形象的回答,翻译为“啃老模式”更为形象。也就是说遇到类加载请求时,先让父加载器去加载,父加载器加载不了了自己才去加载。

  26. (8.3.1 解析)静态方法、私有方法、实例构造器、父类以及final方法(由于历史设计的原因,final方法是使用invokevirtual指令来调用)在类加载的时候就可以把符号引 用解析为该方法的直接引用。这些方法统称为“非虚方法”(Non-Virtual Method)

  27. (8.3.2 分派)

    • 静态分派对应的是方法的重载(Overload),动态分配对应的是类的重写(Override)。
    • 当子类声明了与父类同名的字段时,虽然在子类的内存中两 个字段都会存在,但是子类的字段会遮蔽父类的同名字段
  28. (8.4.3 java.lang.invoke包)一句话解释Reflection和MethodHandle的区别:Reflection是重量级,而MethodHandle 是轻量级。

  29. 主内存与工作内存之间具体的交互协议:
    6QaSat.png

  30. 对于普通变量(相对于volatile变量):要求read/load、write/store成对顺序出现,但是允许不连续出现(也即中间可以插入其他指令)。

  31. 对于volatile变量:

    • 只有当前一个指令是load时,才允许执行use;且只有后一个动作是use时,才能执行load动作。换句话说:额外要求load和use是连续成对出现的,结合上面第2条,那就是use/load/read一条龙。这条规则要求在工作内存中,每次使用volatile变量前都必须先从主内存刷新最新的值,保证能看见其他线程对该变量的修改。
    • 只有当前一个指令是assign时,才允许执行store;且只有后一个动作是store时才能执行assign动作。换句话说:额外要求assign和store是连续成对出现,结合上面第2条,那就是assign/store/write一条龙。这条规则要求在工作内存中,每次修改volatile变量后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对该变量所做的修改。
    • 对于任意两条龙A和B,如果A的龙头(assign或use)发生在B的龙头之前,那么A的龙尾要在B的龙尾之前(我这个解释真是太牛了!)
  32. 关于互斥量(mutex)和信号量(semaphore)的区别:

    • 正如他们的名字一样,前者的使用场景是保护资源的独占性,注重的是竞争关系;而后者的场景是等待“信号”的发生,更倾向于生产者/消费者模型中的消费者,注重的是供需关系。[知乎回答](semaphore和mutex的区别? - 人马座的回答 - 知乎 https://www.zhihu.com/question/47704079/answer/528324049)
    • 对于mutex,只有上锁的线程才有资格解锁;而对于semaphore,它可以被任意的线程获取和释放。
  33. 简述java的synchronized关键字:

    • 它是java中最基本的互斥同步的手段,属于一种块结构的同步语法。
    • 被javac编译后,会在同步块的前后分别形成moniterenter和moniterexit两个字节码指令
    • 这两个字节码都需要一个reference类型参数指明要锁定的和解锁的对象。如果没有指明,那么就根据synchronized修饰的方法类型决定是锁对象实例还是Class对象。
    • 在执行monitorenter指令时,首先要去尝试获取对象的锁,并且锁计数器加一,执行 monitorexit指令时会将锁计数器的值减一。计数器清零时,锁被释放。并且可以推论出:对于同一个线程来说,synchronized是可重入的。
  34. 简述java的并发机制(或者同步机制)

    • 同步的定义:同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者是一些, 当使用信号量的时候)线程使用。
    • 基于同步的定义或者目的,有两种实现思路:1、互斥同步,其基本思想是对于共享资源的获取是悲观的(也即悲观锁),因此在访问资源时,都进行加锁操作,例如java语言层面提供的synchronized关键字。2、非阻塞同步,其基本思想是对于共享资源的获取时保持乐观的。不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数 据的确被争用,产生了冲突,那再进行其他的补偿措施。
  35. 关于JDK 6引入的自适应自旋锁的机制

    • 自旋时间不再固定,而是由同一把锁前一次的自旋时间以及锁拥有者的状态决定。
    • 假如刚刚自旋等待获取过同一把锁,并且持有锁的线程正在运行中,虚拟机会认为很可能再次成功,就会增加自旋次数
    • 假如很少成功获得过锁,那么可能直接省略掉自旋,避免浪费CPU资源。

  1. sed命令执行时包含了若干个循环(cycle),而每个循环又包含若干个命令(command),完整的执行流程图为(图片来源):

    The Sed execution model

  2. tips: 如果sed语法的第一个字符是“/”,那么表示这是一个正则表达式的地址定位方式。(细品)

  3. sed使用场景示例:

    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
    echo 'line 1\nline 2\nline 3' | sed 's/line/"&"/g' #将所有的line加上双引号
    echo 'line\t1\nline\t2\nline\t3' | sed -n 'l' #显示文本中的控制字符
    echo 'line 1\nline 2\nline 3' | sed -n '/1/=' #显示包含1的行的行号,输出:1
    echo 'line 1\nline 1\nline 2\nline 3' | sed -n '$=' #显示总行数,输出:3

    #删除某几行文字(可以延伸到保留某几行,或删除或保留匹配的行)
    echo 'line 1\nline 2\nline 3' | sed '1d' #删除第1行
    echo 'line 1\nline 2\nline 3' | sed -n '1!p' #功能同上

    #sed默认会将pattern space中的数据输出到屏幕,如果使用FLAG p,还会每次额外把current pattern space输出到屏幕,举例
    echo 'line 1\nline 2\nline 3' | sed '/.*/p' #匹配所有的字符,并且会把pattern space和current pattern space中的内容都输出到屏幕上,因此每一行文字都会重复输出两次
    echo 'line 1\nline 2\nline 3' | sed -n '/1/p' #匹配包含“1”的行,但是-n选项会禁止pattern space输出到屏幕,而FLAG p又会把current pattern space中的内容都输出到屏幕上,因此屏幕上显示的是:line 1,也即实现了grep的功能。记住:option -n和command p是一家。

    #在日常工作中,经常需要使用sed进行替换(例如部署服务器时,进行模板变量的替换),而需求是屏幕上只输出被修改的行,可以使用FLAG w将修改内容输出的stdout,如下所示:
    cat 1.txt
    123
    456
    789
    zhengtongshan@DESKTOP-R7DT0R0 /mnt/e/Tetris/src/1_Branches/rtm_tetris_zhugan
    ☺ sed -i 's/1/s/gw /dev/stdout' 1.txt
    s23
    zhengtongshan@DESKTOP-R7DT0R0 /mnt/e/Tetris/src/1_Branches/rtm_tetris_zhugan
    cat 1.txt
    s23
    456
    789

    #在日常工作中,会遇到将制定的两行合并为一行的情况,情景为:如果发现某行包含某个字符,则将下面一行和本行合并,如下面的例子,如果行的第一个字符为a,则将后面的一行合并过来
    echo 'a\nb\nc\na\nb' |sed -e '/^a/N' -e 's/\n//'
    ab
    c
    ab

    #下面两个语句是等效的:(细品)
    man cp | sed 's/copy/{&}/w cp.txt' #将man page中的〝copy〞字串加上大括号〝{}〞,并将current pattern space写入“cp.txt”
    man cp | sed -n 's/copy/{&}/p' > cp.txt

    #多个FLAG可以叠加使用,但是和其他Linux指令一样,与文件有关的FLAG要放在最后
    man cp | sed 's/copy/{&}/Igpw cp.txt' #不区分大小写(FLAG I)将每行 (FLAG g)的“copy”加上大括号(Command s),将pattern space输出到屏幕(FLAG p),并将current pattern space写入文件“cp.txt”(FLAG w)
    echo 'line 1\n\nline 2\nline 3' | sed '/^$/d' #删除空白行
    echo 'line 1\nline 2\nline 3\nline 4' | sed -e 'N' -e 'N' -e 's/\n/+/g' #用两次N连续读取两个下一行(此时pattern space里有三行),然后把换行符都换成+,执行完毕后,输出为:
    line 1+line 2+line 3
    line 4
  4. 使用t label可以实现事务性的修改,比如两个替换要么同时进行,要么就都不做。

  5. command n命令的流程是:1、如果没有配置地址或匹配则输出pattern space(如果不满足则跳过下一步);2、然后读取下一行到pattern space。详情参考:Sed 命令完全指南中的次行命令一节,例如:

    1
    echo 'line 1\nline 2\nline 3\nline 4\nline 5\nline 6' | sed -e 'n' -e 'n' -ne 'p' #当第1个n执行后,pattern space变成:line 2,当第2个n执行后,pattern space变成:line 3,然后执行p后,打印current pattern space:line 3,然后进入下一个循环,打印出:line 6,上述sed可以简写成:sed -ne 'n;n;p',此外我们发现p命令的n选项是一个全局选项,它对整个sed语句生效而不仅限于p命令自己
  6. command N、D可以实现联合多行条件判断(例如:满足某一行有ID字符串,且下面两行分别包含NAME和ADDRESS字符串,则执行删除ID行的操作),实现更复杂的操作,如果不使用command N、D,那么sed的操作仅限于行内。

  7. command D为删除current pattern space的第一个字符到换行,而command P类似,只是将删除变为打印。

  8. command c和s的区别在于,前者是整行替换,后者还需要指定要替换的pattern。

问题提出:

驻厂深圳后,办公地点的网络不再是专线,因此IP不再是固定的,而是由运营商动态分配,而线上某些业务都开启了IP白名单,如果仍然通过添加白名单的方式,那么将会非常麻烦。总结一下受限的业务场景,包括:

  1. ssh连接腾讯云服务器
  2. http访问蓝鲸运维平台
  3. Jenkins Slave(Windows 7)服务器拷贝服务器版本到腾讯云

方案思路:

考虑到ssh提供了socks代理,因此可以在北京公司部署一台固定IP且已经加入IP白名单的服务器(假设为HostA,IP为:10.12.28.114,OS为Windows 10),然后开启socks代理,受限业务的流量都通过socks代理。

方案实施:

  1. 安装OpenSSH(windows 10 version 1803及以后的版本已经集成),在HostA上:设置–>应用–>应用和功能–>可选功能–>添加功能–>OpenSSH服务器
  2. 开启sshd服务:在HostA上,控制面板–>系统和安全->管理工具–>服务–>OpenSSH SSH Server
  3. 开启socks代理:ssh -f -N -D 0.0.0.0:1080 win10@127.0.0.1,此命令会在1080端口上开启sockets代理(关于如何使用ssh-key登录,可以参考这篇文章:Configuring SSH Key-Based Authentication on Windows 10/ Server 2019,并着重关注: How to Login Windows Using SSH Key Under Local Admin?

问题解决:

  1. 受限场景1:SSH连接工具上设置socks代理,如图所示:
    6QUzVI.png
  2. 受限场景2:到控制面板–>Internet选项–>连接–>局域网设置–>代理服务器–>高级 里设置socks代理,然后使用IE访问蓝鲸运维平台
    6QUvqA.png
  3. 受限场景3:由于jenkins slave机器是windows 7系统,本身没有集成OpenSSH Client组件,因此考虑在slave上安装ssh客户端,最终考虑使用git附带的Unix tools中的ssh功能(安装时需要把Unix tools加入环境变量),在%USERPROFILE%/.ssh/config内增加ProxyCommand命令行进行代理,命令如下:ProxyCommand connect -S 10.12.28.114:1080 %h %p 如果要是用http代理,需要把 -S 改为 -H ,回到jenkins配置界面,配置scp命令拷贝版本即可。

杂项

  • Conditional("some condition") 可以优雅地替代宏定义

  • 泛型类中的static字段和非泛型类的字段有所区别,后者中该static字段属于该类,而前者中该static字段仅属于该泛型类,例如Generic<A>.staticFieldGeneric<B>.staticField是不同的。对于这个特性,可以有比较进阶的用法,例如:

    1
    2
    3
    4
    5
    6
    7
    public class Foo {
    public static int lastId;
    }

    public class Foo<T> : Foo {
    public static int id = ++lastId;
    }

    那么对于每一个Foo<T>的子类,启id都是唯一的,从而实现id自我管理(而这里的泛型T只是被借壳而已,没有任何用处)。

  • 使用Type.MakeGenericType,可以在运行时生成泛型类,这是一个java不具备的特性,举例:

    1
    2
    3
    var type = typeof(Dictionary<,>);
    Type[] typeArgs = {typeof(string), typeof(int)};
    var genericType = type.MakeGenericType(typeArgs)
  • default关键字:在泛型类和泛型方法中产生的一个问题,给定参数化类型 T 的一个变量 t,只有当 T 为引用类型时,语句 t = null 才有效;只有当 T 为数值类型而不是结构时,语句 t = 0 才能正常使用。 解决方案是使用default 关键字,此关键字对于引用类型会返回null,对于数值类型会返回零。

  • 对于ManualResetEvent

    1. 可以通过构造函数初始化为有信号或无信号状态。
    2. 当且仅当ManualResetEvent处于无信号状态时(也即Set没有被调用或者初始化为无信号状态),其他线程调用了ManualResetEvent.WaitOne才会被阻塞。
    3. 调用ManualResetEvent.Reset可以阻塞所有其他调用了ManualResetEvent.WaitOne的线程
    4. 应用场景:工作A和B需要并发执行,但是需要A执行完毕后再执行B。最佳实践:a、A线程(称之为ManualResetEvent控制线程)调用ManualResetEvent.Reset使之进入非信号状态;b、B线程(称之为被控制线程或等待线程?阻塞线程?)在适当位置调用ManualResetEvent.WaitOne等待信号;c、A线程调用ManualResetEvent.Set通知B线程继续执行。
  • 可以通过实现一个public的无参的返回IEnumeratorGetEnumerator()
    为一个类扩展foreach语法,例如(详情):

    1
    2
    3
    4
    5
    6
    7
    public IEnumerator<Tile> GetEnumerator() {
    for(int x = 0; x < this.size.x; x++) {
    for(int y = 0; y < this.size.y; y++) {
    yield return this[x, y];
    }
    }
    }

GetAwaiter()

  • 在C#中,任意类只要实现了:GetAwaiter() 函数,就可以被await关键字异步等待
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public struct Delay
    {
    public TimeSpan TimeSpan {get;}

    private Delay(TimeSpan timeSpan)
    {
    TimeSpan = timeSpan;
    }

    public static Delay Second(int seconds)
    {
    return new Delay(TimeSpan.FromSeconds(seconds));
    }

    public TaskAwaiter GetAwaiter()
    {
    return Task.Delay(TimeSpan).GetAwaiter();
    }
    }
    由于 Delay 实现了 GetAwaiter 方法,现在就可以使用: await Delay.Seconds(2); 而在实际应用中,可以使用C#的扩展功能对Delay 进行扩展,而不必侵入修改,例如:
    1
    2
    3
    4
    5
    6
    7
    public static class Extensions
    {
    public static TaskAwaiter GetAwaiter(this Delay delay)
    {
    return Task.Delay(delay.TimeSpan).GetAwaiter();
    }
    }
    这意味着:可以使用扩展的方式可以实现对任意类的 await 异步等待,基于该理念,Delay 这个结构体已经没有存在的必要,因为可以对TimeSpan进行扩展,例如:
    1
    2
    3
    4
    5
    6
    7
    public static class Extensions
    {
    public static TaskAwaiter GetAwaiter(this TimeSpan timeSpan)
    {
    return Task.Delay(timeSpan).GetAwaiter();
    }
    }
    而现在可以使用:await TimeSpan.FromSeconds(2); 更加简洁的一个操作是继续对int进行扩展,例如:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public static class Extensions
    {
    public static TaskAwaiter GetAwaiter(this TimeSpan timeSpan)
    {
    return Task.Delay(timeSpan).GetAwaiter();
    }

    public static TimeSpan Seconds(this int integer)
    {
    return TimeSpan.FromSeconds(integer);
    }
    }
    现在就可以使用:await 3.Seconds();

required关键字

required关键字子在C# 11中被引入,表示字段必须由构造函数或对象初始化器进行初始化:

1
public required string Title {get; init;}

尽量避免使用Async void方法

在下面代码中,如果ProcessDataAsync()方法中抛出异常,HandleButtonClick无法正常捕获!而如果把ProcessDataAsync的返回值改成async Task则可以正常捕获:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public async void ProcessDataAsync()
{
// Some asynchronous operations
}

public async void HandleButtonClick(object sender, EventArgs e)
{
try
{
ProcessDataAsync();
}
catch (Exception ex)
{
// This will never catch the async exceptions!
}
}

接口中的静态方法

接口中的静态方法

C# 8.0引入了接口的静态方法,该静态方法只能通过接口访问(不能通过实现类访问)。接口的静态方法具有很大的局限性,一般的应用场景是在泛型接口上使用静态方法,举例如下:

1
2
3
4
5
6
7
8
9
10
11
//约束T必须实现IDeserializable
interface IDeserializable<T> where T : IDeserializable<T>
{
static T? Deserialize(string json) => JsonSerializer.Deserialize<T>(json);
}

class Student : IDeserializable<Student>
{
public int Id { get; set; }
public string Name { get; set; }
}

在上述代码中,虽然接口的静态方法的使用场景具有局限性,但是我们同时也对泛型接口的类型T进行了约束。通过该约束,是的泛型接口的静态方法具备了实现基础,此时我们可以对Student类进行反序列化,如下所示:

1
var student = IDeserializable<Student>.Deserialize("{ \"Id\" : 42, \"Name\" : \"Tom\" }");

接口中的抽象静态方法

C# 10.0 进一步引入了抽象静态方法,在工厂设计模式中可以使用抽象静态方法,例如:

1
2
3
4
interface IFactory<T>
{
static abstract T Create();
}

这要求所有实现了IFactory<T>泛型接口的类都要实现 Student 抽象静态方法。另外一种应用场景是单例模式,例如:

1
2
3
4
5
6
7
8
9
interface ISingleton<T> where T : ISingleton<T>
{
static abstract T Instance { get; }
}

class SingletonClass : ISingleton<SingletonClass>
{
public static SingletonClass Instance => new();
}
0%