大家好,我是二哥呀。定時任務的應用場景其實蠻常見的,比如說:
- 資料備份
- 訂單未支付則自動取消
- 定時爬取資料
- 定時推送資訊
- 定時釋出文章
- 等等(想不出來了,只能等等來湊,?,反正只要等的都需要定時,怎麼樣,這波圓場可以吧)
程式設計喵?實戰專案裡需要做一個定時釋出文章的功能,一開始我想用 Spring Task,於是研究了一番,發現 Spring Task 用起來確實簡單,但對於複雜業務卻也無能為力。
於是我就把注意力放到了 Quartz 上面,這是一款老而彌堅的開源任務排程框架。
記得我在 14 年開發大宗期貨交易平臺的時候就用到了它,每天凌晨定時需要統計一波交易資料,生成日報報表,當時配合 Cron 表示式用的。
可惜後來平臺穩定了,新的政策出來了,直接把大宗期貨交易滅了。於是我發財的機會也隨著破滅了。想想都覺得可惜,哈哈哈。
時光荏苒,Quartz 發展到現在,已經可以和 Spring Boot 專案無縫銜接了,今天我們就來實戰一把。
Timer
JDK 1.3 就開始支援的一種定時任務的實現方式。內部通過 TaskQueue 的類來存放定時任務,用起來比較簡單,但缺陷比較多,比如說一個 Timer 就會起一個執行緒,任務多了效能就非常差,再比如說如果執行任務期間某個 TimerTask 耗時比較久,就會影響其他任務的排程。
@Slf4j
public class TimerDemo {
public static void main(String[] args) {
TimerTask task = new TimerTask() {
@Override
public void run() {
log.debug("當前時間{}執行緒名稱{}", DateTime.now(),
Thread.currentThread().getName());
}
};
log.debug("當前時間{}執行緒名稱{}", DateTime.now(),
Thread.currentThread().getName());
Timer timer = new Timer("TimerDemo");
timer.schedule(task,1000L);
}
}
程式碼跑起來後的日誌如下所示:
13:11:45.268 [main] DEBUG top.springtask.TimerDemo - 當前時間2022-04-27 13:11:45執行緒名稱main
13:11:46.280 [TimerDemo] DEBUG top.springtask.TimerDemo - 當前時間2022-04-27 13:11:46執行緒名稱TimerDemo
ScheduledThreadPoolExecutor
JDK 1.5 開始提供的的定時任務,它繼承了 ThreadPoolExecutor,實現了 ScheduledExecutorService 介面,所以支援併發場景下的任務執行。同時,優化了 Timer 的缺陷。不過,由於使用了佇列來實現定時器,就有出入佇列、調整堆等操作,所以定時不是非常非常準確(吹毛求疵)。
@Slf4j
public class ScheduledThreadPoolExecutorDemo {
public static void main(String[] args) throws InterruptedException {
TimerTask task = new TimerTask() {
@Override
public void run() {
log.debug("當前時間{}執行緒名稱{}", DateTime.now(),
Thread.currentThread().getName());
}
};
log.debug("當前時間{}執行緒名稱{}", DateTime.now(),
Thread.currentThread().getName());
ScheduledExecutorService executorService = Executors.newScheduledThreadPool(3);
executorService.scheduleAtFixedRate(task, 1000L,1000L, TimeUnit.MILLISECONDS);
Thread.sleep(1000+1000*4);
executorService.shutdown();
}
}
輸出結果如下所示:
14:43:41.740 [main] DEBUG top.springtask.ScheduledThreadPoolExecutorDemo - 當前時間2022-04-27 14:43:41執行緒名稱main
14:43:42.752 [pool-1-thread-1] DEBUG top.springtask.ScheduledThreadPoolExecutorDemo - 當前時間2022-04-27 14:43:42執行緒名稱pool-1-thread-1
14:43:43.748 [pool-1-thread-1] DEBUG top.springtask.ScheduledThreadPoolExecutorDemo - 當前時間2022-04-27 14:43:43執行緒名稱pool-1-thread-1
14:43:44.749 [pool-1-thread-2] DEBUG top.springtask.ScheduledThreadPoolExecutorDemo - 當前時間2022-04-27 14:43:44執行緒名稱pool-1-thread-2
14:43:45.749 [pool-1-thread-2] DEBUG top.springtask.ScheduledThreadPoolExecutorDemo - 當前時間2022-04-27 14:43:45執行緒名稱pool-1-thread-2
14:43:46.749 [pool-1-thread-2] DEBUG top.springtask.ScheduledThreadPoolExecutorDemo - 當前時間2022-04-27 14:43:46執行緒名稱pool-1-thread-2
Spring Task
Spring Task 是 Spring 提供的輕量級定時任務工具,也就意味著不需要再新增第三方依賴了,相比其他第三方類庫更加方便易用。
好像關於 Spring Task,沒有其他廢話可說了,我們來直接上手。
第一步,新建配置類 SpringTaskConfig,並新增 @EnableScheduling註解開啟 Spring Task。
@Configuration
@EnableScheduling
public class SpringTaskConfig {
}
當然了,也可以不新建這個配置類,直接在主類上新增 @EnableScheduling 註解。
@SpringBootApplication
@EnableScheduling
public class CodingmoreSpringtaskApplication {
public static void main(String[] args) {
SpringApplication.run(CodingmoreSpringtaskApplication.class, args);
}
}
第二步,新建定時任務類 CronTask,使用 @Scheduled 註解註冊 Cron 表示式執行定時任務。
@Slf4j
@Component
public class CronTask {
@Scheduled(cron = "0/1 * * ? * ?")
public void cron() {
log.info("定時執行,時間{}", DateUtil.now());
}
}
啟動伺服器端,發現每隔一秒鐘會列印一次日誌,證明 Spring Task 的 cron 表示式形式已經起效了。
預設情況下,@Scheduled 建立的執行緒池大小為 1,如果想增加執行緒池大小的話,可以讓 SpringTaskConfig 類實現 SchedulingConfigurer 介面,通過 setPoolSize 增加執行緒池大小。
@Configuration
@EnableScheduling
public class SpringTaskConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler();
threadPoolTaskScheduler.setPoolSize(10);
threadPoolTaskScheduler.setThreadNamePrefix("my-scheduled-task-pool-");
threadPoolTaskScheduler.initialize();
taskRegistrar.setTaskScheduler(threadPoolTaskScheduler);
}
}
服務熱部署完成後,會在控制檯看到這樣的資訊:
可以確認自定義執行緒池大小已經生效了,有的任務用的是執行緒led-task-pool-3,有的是執行緒led-task-pool-7,跑時間長了,可以發現 led-task-pool-1 到 led-task-pool-10 的都有。
Spring Task 除了支援 Cron 表示式,還有 fixedRate(固定速率執行)、fixedDelay(固定延遲執行)、initialDelay(初始延遲)三種用法。
/**
* fixedRate:固定速率執行。每5秒執行一次。
*/
@Scheduled(fixedRate = 5000)
public void reportCurrentTimeWithFixedRate() {
log.info("Current Thread : {}", Thread.currentThread().getName());
log.info("Fixed Rate Task : The time is now {}", DateUtil.now());
}
/**
* fixedDelay:固定延遲執行。距離上一次呼叫成功後2秒才執。
*/
@Scheduled(fixedDelay = 2000)
public void reportCurrentTimeWithFixedDelay() {
try {
TimeUnit.SECONDS.sleep(3);
log.info("Fixed Delay Task : The time is now {}",DateUtil.now());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/**
* initialDelay:初始延遲。任務的第一次執行將延遲5秒,然後將以5秒的固定間隔執行。
*/
@Scheduled(initialDelay = 5000, fixedRate = 5000)
public void reportCurrentTimeWithInitialDelay() {
log.info("Fixed Rate Task with Initial Delay : The time is now {}", DateUtil.now());
}
不過,fixedRate 有個坑,假如某個方法的定時器設定的固定速率是每5秒執行一次,這個方法現在要執行下面四個任務,四個任務的耗時是:6s、6s、 2s、 3s,任務會如何執行呢(單執行緒環境下)?
2022-04-27 15:25:52.400 INFO 4343 --- [led-task-pool-1] c.codingmore.component.PublishPostTask : Fixed Rate Task : The time is now 2022-04-27 15:25:52
2022-04-27 15:25:58.401 INFO 4343 --- [led-task-pool-1] c.codingmore.component.PublishPostTask : Fixed Rate Task : The time is now 2022-04-27 15:25:58
2022-04-27 15:26:00.407 INFO 4343 --- [led-task-pool-1] c.codingmore.component.PublishPostTask : Fixed Rate Task : The time is now 2022-04-27 15:26:00
2022-04-27 15:26:04.318 INFO 4343 --- [led-task-pool-1] c.codingmore.component.PublishPostTask : Fixed Rate Task : The time is now 2022-04-27 15:26:04
第一個任務開始的相對時間是第 0 秒,但由於執行了 6 秒,所以原來應該是第 5 秒執行的任務,延遲到第 6 秒才開始執行,第三個任務延遲了 12 秒,原本應該是第 10 秒執行,第三個任務沒有延遲,正常 15 秒後執行。
假如我們使用 @EnableAsync 註解開啟多執行緒環境的話,結果會怎麼樣呢?
2022-04-27 15:33:01.385 INFO 4421 --- [led-task-pool-1] c.codingmore.component.PublishPostTask : Fixed Rate Task : The time is now 2022-04-27 15:33:01
2022-04-27 15:33:07.390 INFO 4421 --- [led-task-pool-1] c.codingmore.component.PublishPostTask : Fixed Rate Task : The time is now 2022-04-27 15:33:07
2022-04-27 15:33:09.391 INFO 4421 --- [led-task-pool-1] c.codingmore.component.PublishPostTask : Fixed Rate Task : The time is now 2022-04-27 15:33:09
2022-04-27 15:33:13.295 INFO 4421 --- [led-task-pool-1] c.codingmore.component.PublishPostTask : Fixed Rate Task : The time is now 2022-04-27 15:33:13
關於 Cron 表示式
這裡順帶普及一下 Cron 表示式,在定時任務中會經常會遇到。Cron 這個詞來源於希臘語 chronos,原意也就是時間。
Cron 表示式是一個含有時間意義的字串,以 5 個空格隔開,分成 6 個時間元素。舉幾個例子就一目瞭然了。
示例 | 說明 |
---|---|
0 15 10 ? * * |
每天上午10:15執行任務 |
0 0 10,14,16 * * ? |
每天10 點、14 點、16 點執行任務 |
0 0 12 ? * 3 |
每個星期三中午 12 點執行任務 |
0 15 10 15 * ? |
每月 15 日上午 10 點 15 執行任務 |
Cron 的語法格式可以總結為:
Seconds Minutes Hours DayofMonth Month DayofWeek
每個時間元素的取值範圍,以及可出現的特殊字元如下所示。
時間元素 | 取值範圍 | 可出現的特殊字元 |
---|---|---|
秒 | [0,59] |
*,-/ |
分鐘 | [0,59] |
*,-/ |
小時 | [0,59] |
*,-/ |
日期 | [0,31] |
*,-/?LW |
月份 | [1,12] |
*,-/ |
星期 | [1,7] |
*,-/?L# |
特殊字元的含義和示例如下所示。
特殊字元 | 含義 | 示例 |
---|---|---|
* |
所有可能的值 | 很好理解,月域中為每個月,星期域中每個星期幾 |
, |
列舉的值 | 很好理解,小時域中 10,14,16 ,就表示這幾個小時可選 |
- |
範圍 | 很好理解,分鐘域中 10-19 ,就表示 10-19 分鐘每隔一分鐘執行一次 |
/ |
指定數值的增量 | 很好理解,分鐘域中 0/15 ,就表示每隔 15 分鐘執行一次 |
? |
不指定值 | 很好理解,日期域指定了星期域就不能指定值,反之亦然,因為日期域和星期域屬於衝突關係 |
L |
單詞 Last 的首字母 | 很好理解,日期域和星期域支援,表示月的最後一天或者星期的最後一天 |
W |
除週末以外的工作日 | 很好理解,僅日期域支援 |
# |
每個月的第幾個星期幾 | 很好理解,僅星期域支援,4#2 表示某月的第二個星期四 |
關於 Quartz
Quartz 是一款功能強大的開源的任務排程框架,在 GitHub 上已經累計有 5k+ 的 star 了。小到單機應用,大到分散式,都可以整合 Quartz。
在使用 Quartz 之前,讓我們先來搞清楚 4 個核心概念:
- Job:任務,要執行的具體內容。
- JobDetail:任務詳情,Job 是它要執行的內容,同時包含了這個任務排程的策略和方案。
- Trigger:觸發器,可以通過 Cron 表示式來指定任務執行的時間。
- Scheduler:排程器,可以註冊多個 JobDetail 和 Trigger,用來排程、暫停和刪除任務。
整合 Quartz
Quartz 儲存任務的方式有兩種,一種是使用記憶體,另外一種是使用資料庫。記憶體在程式重啟後就丟失了,所以我們這次使用資料庫的方式來進行任務的持久化。
第一步,在 pom.xml 檔案中新增 Quartz 的 starter。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
<version>2.6.7</version>
</dependency>
第二步,在 application.yml 新增 Quartz 相關配置,配置說明直接看註釋。
spring:
quartz:
job-store-type: jdbc # 預設為記憶體 memory 的方式,這裡我們使用資料庫的形式
wait-for-jobs-to-complete-on-shutdown: true # 關閉時等待任務完成
overwrite-existing-jobs: true # 可以覆蓋已有的任務
jdbc:
initialize-schema: never # 是否自動使用 SQL 初始化 Quartz 表結構
properties: # quartz原生配置
org:
quartz:
scheduler:
instanceName: scheduler # 排程器例項名稱
instanceId: AUTO # 排程器例項ID自動生成
# JobStore 相關配置
jobStore:
class: org.quartz.impl.jdbcjobstore.JobStoreTX # JobStore 實現類
driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate # 使用完全相容JDBC的驅動
tablePrefix: QRTZ_ # Quartz 表字首
useProperties: false # 是否將JobDataMap中的屬性轉為字串儲存
# 執行緒池相關配置
threadPool:
threadCount: 25 # 執行緒池大小。預設為 10 。
threadPriority: 5 # 執行緒優先順序
class: org.quartz.simpl.SimpleThreadPool # 指定執行緒池實現類,對排程器提供固定大小的執行緒池
Quartz 預設使用的是記憶體的方式來儲存任務,為了持久化,我們這裡改為 JDBC 的形式,並且指定 spring.quartz.jdbc.initialize-schema=never
,這樣我們可以手動建立資料表。因為該值的另外兩個選項ALWAYS和EMBEDDED都不太符合我們的要求:
- ALWAYS:每次都初始化
- EMBEDDED:只初始化嵌入式資料庫,比如說 H2、HSQL
那手動建立資料表的 SQL 語句去哪裡找呢?
為了方便小夥伴們下載,我把它放在了本教程的原始碼裡面了:
如果使用 Intellij IDEA 旗艦版的話,首次開啟 SQL 檔案的時候會提示你指定資料來源。在上圖中,我配置了本地的 MySQL 資料庫,匯入成功後可以在資料庫中檢視到以下資料表:
Quartz資料庫核心表如下:
Table Name | Description |
---|---|
QRTZ_CALENDARS | 儲存Quartz的Calendar資訊 |
QRTZ_CRON_TRIGGERS | 儲存CronTrigger,包括Cron表示式和時區資訊 |
QRTZ_FIRED_TRIGGERS | 儲存與已觸發的Trigger相關的狀態資訊,以及相聯Job的執行資訊 |
QRTZ_PAUSED_TRIGGER_GRPS | 儲存已暫停的Trigger組的資訊 |
QRTZ_SCHEDULER_STATE | 儲存少量的有關Scheduler的狀態資訊,和別的Scheduler例項 |
QRTZ_LOCKS | 儲存程式的悲觀鎖的資訊 |
QRTZ_JOB_DETAILS | 儲存每一個已配置的Job的詳細資訊 |
QRTZ_JOB_LISTENERS | 儲存有關已配置的JobListener的資訊 |
QRTZ_SIMPLE_TRIGGERS | 儲存簡單的Trigger,包括重複次數、間隔、以及已觸的次數 |
QRTZ_BLOG_TRIGGERS | Trigger作為Blob型別儲存 |
QRTZ_TRIGGER_LISTENERS | 儲存已配置的TriggerListener的資訊 |
QRTZ_TRIGGERS | 儲存已配置的Trigger的資訊 |
剩下的就是對 Quartz 的 scheduler、jobStore 和 threadPool 配置。
第三步,建立任務排程的介面 IScheduleService,定義三個方法,分別是通過 Cron 表示式來排程任務、指定時間來排程任務,以及取消任務。
public interface IScheduleService {
/**
* 通過 Cron 表示式來排程任務
*/
String scheduleJob(Class<? extends Job> jobBeanClass, String cron, String data);
/**
* 指定時間來排程任務
*/
String scheduleFixTimeJob(Class<? extends Job> jobBeanClass, Date startTime, String data);
/**
* 取消定時任務
*/
Boolean cancelScheduleJob(String jobName);
}
第四步,建立任務排程業務實現類 ScheduleServiceImpl,通過Scheduler、CronTrigger、JobDetail的API來實現對應的方法。
@Slf4j
@Service
public class ScheduleServiceImpl implements IScheduleService {
private String defaultGroup = "default_group";
@Autowired
private Scheduler scheduler;
@Override
public String scheduleJob(Class<? extends Job> jobBeanClass, String cron, String data) {
String jobName = UUID.fastUUID().toString();
JobDetail jobDetail = JobBuilder.newJob(jobBeanClass)
.withIdentity(jobName, defaultGroup)
.usingJobData("data", data)
.build();
//建立觸發器,指定任務執行時間
CronTrigger cronTrigger = TriggerBuilder.newTrigger()
.withIdentity(jobName, defaultGroup)
.withSchedule(CronScheduleBuilder.cronSchedule(cron))
.build();
// 排程器進行任務排程
try {
scheduler.scheduleJob(jobDetail, cronTrigger);
} catch (SchedulerException e) {
log.error("任務排程執行失敗{}", e.getMessage());
}
return jobName;
}
@Override
public String scheduleFixTimeJob(Class<? extends Job> jobBeanClass, Date startTime, String data) {
//日期轉CRON表示式
String startCron = String.format("%d %d %d %d %d ? %d",
DateUtil.second(startTime),
DateUtil.minute(startTime),
DateUtil.hour(startTime, true),
DateUtil.dayOfMonth(startTime),
DateUtil.month(startTime) + 1,
DateUtil.year(startTime));
return scheduleJob(jobBeanClass, startCron, data);
}
@Override
public Boolean cancelScheduleJob(String jobName) {
boolean success = false;
try {
// 暫停觸發器
scheduler.pauseTrigger(new TriggerKey(jobName, defaultGroup));
// 移除觸發器中的任務
scheduler.unscheduleJob(new TriggerKey(jobName, defaultGroup));
// 刪除任務
scheduler.deleteJob(new JobKey(jobName, defaultGroup));
success = true;
} catch (SchedulerException e) {
log.error("任務取消失敗{}", e.getMessage());
}
return success;
}
}
第五步,定義好要執行的任務,繼承 QuartzJobBean 類,實現
executeInternal 方法,這裡只定義一個定時釋出文章的任務。
@Slf4j
@Component
public class PublishPostJob extends QuartzJobBean {
@Autowired
private IScheduleService scheduleService;
@Autowired
private IPostsService postsService;
@Override
protected void executeInternal(JobExecutionContext jobExecutionContext) throws JobExecutionException {
Trigger trigger = jobExecutionContext.getTrigger();
JobDetail jobDetail = jobExecutionContext.getJobDetail();
JobDataMap jobDataMap = jobDetail.getJobDataMap();
Long data = jobDataMap.getLong("data");
log.debug("定時釋出文章操作:{}",data);
// 獲取文章的 ID後獲取文章,更新文章為釋出的狀態,還有釋出的時間
boolean success = postsService.updatePostByScheduler(data);
//完成後刪除觸發器和任務
if (success) {
log.debug("定時任務執行成功,開始清除定時任務");
scheduleService.cancelScheduleJob(trigger.getKey().getName());
}
}
}
第六步,釋出文章的介面裡 PostsServiceImpl 新增定時釋出的任務排程方法。
@Service
public class PostsServiceImpl extends ServiceImpl<PostsMapper, Posts> implements IPostsService {
private void handleScheduledAfter(Posts posts) {
// 文章已經儲存為草稿了,並且拿到了文章 ID
// 呼叫定時任務
String jobName = scheduleService.scheduleFixTimeJob(PublishPostJob.class, posts.getPostDate(), posts.getPostsId().toString());
LOGGER.debug("定時任務{}開始執行", jobName);
}
}
好,我們現在啟動服務,通過Swagger 來測試一下,注意設定文章的定時釋出時間。
檢視 Quartz 的資料表 qrtz_cron_triggers,發現任務已經新增進來了。
qrtz_job_details 表裡也可以檢視具體的任務詳情。
文章定時釋出的時間到了之後,在日誌裡也可以看到 Quartz 的執行日誌。
再次檢視 Quartz 資料表 qrtz_cron_triggers 和 qrtz_job_details 的時候,也會發現定時任務已經清除了。
整體上來說,Spring Boot 整合 Quartz還是非常絲滑的,配置少,步驟清晰,比 Spring Task 更強大,既能針對記憶體也能持久化,所以大家在遇到定時任務的時候完全可以嘗試一把。
完整的功能在程式設計喵實戰專案中已經實現了,可以把程式設計喵匯入到本地嘗試一下。
業務梳理
簡單來梳理一下程式設計喵定時釋出文章的業務。
1)使用者在釋出文章的時候可以選擇定時釋出,如果選擇定時釋出,那麼就要設定定時釋出的時間,暫時規定至少十分鐘以後可以定時。
2)當管理端使用者選擇了定時釋出,那麼在儲存文章的時候,文章狀態要先設定為草稿狀態,對前端使用者是不可見的狀態。
3)儲存文章的時候通知 Quartz,我有一個任務,你需要在某個規定的時間去執行。
scheduleService.scheduleFixTimeJob(PublishPostJob.class, posts.getPostDate(), posts.getPostsId().toString());
4)Quartz 收到這個通知後,就會在資料庫中寫入任務,具體的任務是到指定時間把文章從草稿的狀態轉為釋出狀態,這時候,前端使用者就可以看得見文章了。
// 獲取文章的 ID後獲取文章,更新文章為釋出的狀態,還有釋出的時間
boolean success = postsService.updatePostByScheduler(data);
同時,將任務清除。
// 暫停觸發器
scheduler.pauseTrigger(new TriggerKey(jobName, defaultGroup));
// 移除觸發器中的任務
scheduler.unscheduleJob(new TriggerKey(jobName, defaultGroup));
// 刪除任務
scheduler.deleteJob(new JobKey(jobName, defaultGroup));
整個過程就完成了。Quartz 是如何實現定時釋出文章的呢?其實也是通過 Cron 表示式。
CronTrigger cronTrigger = TriggerBuilder.newTrigger()
.withIdentity(jobName, defaultGroup)
.withSchedule(CronScheduleBuilder.cronSchedule(cron))
.build();
也就是當我們傳入一個指定時間後,通過計算,計算出 Cron 表示式。
String startCron = String.format("%d %d %d %d %d ? %d",
DateUtil.second(startTime),
DateUtil.minute(startTime),
DateUtil.hour(startTime, true),
DateUtil.dayOfMonth(startTime),
DateUtil.month(startTime) + 1,
DateUtil.year(startTime));
在 Quartz 中,有兩類執行緒:Scheduler排程執行緒和任務執行執行緒。
- 任務執行執行緒:Quartz不會在主執行緒(QuartzSchedulerThread)中處理使用者的Job。Quartz把執行緒管理的職責委託給ThreadPool,一般的設定使用SimpleThreadPool。SimpleThreadPool建立了一定數量的WorkerThread例項來使得Job能夠線上程中進行處理。WorkerThread是定義在SimpleThreadPool類中的內部類,它實質上就是一個執行緒。
- QuartzSchedulerThread排程主執行緒:QuartzScheduler被建立時建立一個QuartzSchedulerThread例項。
原始碼路徑
- 程式設計喵:https://github.com/itwanger/coding-more
- codingmore-springtask:https://github.com/itwanger/codingmore-learning
- codingmore-quartz:https://github.com/itwanger/codingmore-learning
本文已收錄到 GitHub 上星標 2.4k+ 的開源專欄《Java 程式設計師進階之路》,據說每一個優秀的 Java 程式設計師都喜歡她,風趣幽默、通俗易懂。內容包括 Java 基礎、Java 併發程式設計、Java 虛擬機器、Java 企業級開發、Java 面試等核心知識點。學 Java,就認準 Java 程式設計師進階之路?。
https://github.com/itwanger/toBeBetterJavaer
star 了這個倉庫就等於你擁有了成為了一名優秀 Java 工程師的潛力。該開源倉庫最近又上 GitHub trending 榜單了,看來是大家都非常認可呀!
沒有什麼使我停留——除了目的,縱然岸旁有玫瑰、有綠蔭、有寧靜的港灣,我是不繫之舟。