文章目录
前面学习了多线程的基础知识,下面进入多线程进阶。
文章目录
一、常见锁策略
此处谈到的锁策略,并不仅限于java、C++、Python、数据库、操作系统…但凡涉及到锁,都是可以用到下列的锁策略的。
1.1乐观锁和悲观锁
这不是两把具体的锁,应该叫做两类锁。
乐观锁:预测锁竞争不是很激烈。(这里做的工作相对比较少)
悲观锁:预测锁竞争会很激烈。(做的工作比较多)
背后做的工作是截然不同的。
这里都不绝对,主要看锁竞争激烈程度。
1.2 轻量级锁和重量级锁
轻量级锁加锁解锁开销比较小,效率更高。
重量级锁加锁解锁开销比较大,效率更低。
多数情况下,乐观锁也是一个轻量级锁。【不能完全保证】
多数情况下,悲观锁也是一个重量级锁。【不能完全保证】
1.3 自旋锁和挂起等待锁
自旋锁是一种一点典型的轻量级锁。
挂起等待锁是一种典型的重量级锁。
在这里,举个例子来更好理解一下自旋锁和挂起等待锁。
假设有一个情况,我要向我喜欢的女神表白了。此时相当于我要对女神尝试加锁。但是接下来,女神告诉我,她有喜欢的人了,给我喜提好人卡。此时相当于女神已经被别人加锁了。
但是我死活不放弃,那么,我有两种方式等待机会。(锁被释放了,女神分手)
1.自旋锁:我每天给女神发早安、午安、晚安,一旦女神分手,我第一时间知道。一旦锁被释放了,我第一时间感知到,从而有机会获取到锁。很明显,自旋锁,占用了很大的系统资源。
2.挂起等待锁:我愿意等女神,但我知道女神过的很好,我躲起来不打扰,如果有一天想起来了我,告诉我。如果女神分手了,想起来了我,求安慰,但实际情况下,女生分手更大概率把你也忘了,指不定啥时候能想起来,当真的被唤醒的时候,中间恐怕是沧海桑田了。很明显,挂起等待锁,把 CPU 省下来,可以干其他好多事。
1.4 互斥锁和读写锁
互斥锁:就好比之前学过的 synchronized 加锁一样,如果一个线程对一个对象进行加锁了,那么另外一个线程想要再对这个对象进行加锁,就得一直阻塞等待了。
读写锁:读写锁分成了三种操作步骤:1.给读操作加锁 2.给写加锁 3.解锁
基于一个事实:多个线程对于同一个变量进行读操作,此时不会有线程安全问题,也不需要加锁控制。
读写锁就是多线程针对同一个变量操作这种情况,进行的特殊处理!!
读锁和读锁之间,不存在互斥。
读锁和写锁之间,存在互斥。
写锁和写锁之间,存在互斥。
1.5 公平锁和非公平锁
此处把公平理解成为 “先来后到”。
我们先来体会一下下面的小场景:有一堆滑稽老铁再追女神,他们有先来后到的顺序。
此时,公平锁的情况就是,当女神分手之后,谁最早追女神的滑稽,先上位。
我们再来看一下另一个场景:此时多个滑稽老铁一拥而上,部分先来后到顺序。
非公平锁就是,当女神分手之后,不按照谁先来,谁后到,谁抢到就是谁的。
另外,我们这里要注意一下:
在操作系统和 Java 标准库中的 synchronized 其实是 “非公平锁”。
操作系统针对加锁的控制,本身就依赖于线程的随机调度,这个顺序是随机的,并不会考虑线程阻塞等待多久了。
如果要想实现公平锁,就得额外在这个基础上,引入一个 阻塞队列,让这些想要加锁的线程在队列中进行排队。
1.6 可重入锁和不可重入锁
不可重入锁:一个线程针对一把锁,连续加锁两次,出现死锁。
可重入锁:一个线程针对同一把锁,连续加锁N次,不会死锁。
1.7 synchronized 的归类
通过以上的这些锁的认识,我们针对 synchronized 进行对号入座。
1.synchronized 既是一个乐观锁,又是一个悲观锁。
synchronized 默认是一个乐观锁,但是如果发现,当前锁竞争比较激烈,就会变成悲观锁。
2.synchronized 既是一个轻量级锁,又是一个重量级锁。
synchronized 默认是一个轻量级锁,但是当前锁竞争比较激烈,就会变成重量级锁。
3.synchronized 的轻量级锁,是针对于自旋锁来实现的。synchronized 的重量级锁是针对挂起等待锁来实现的。
4.synchronized 不是读写锁。
5.synchronized 是非公平锁。
6.synchronized 是可重入锁。
二、CAS
2.1 CAS 的基本概念
CAS全称 Compare and Swap,字面意思是比较交换。
我们假设内存中的原始数据为 V,寄存器中的旧数据为 A,和要求改的数据为 B。我们定义了一下操作:
1.比较 A 和 V 是否相等。(比较)
2.如果比较相等,将 B 写入 V。(交换)
3.返回操作是否成功。
我们这里假设 V=20,A=10,B=10,那么根据上述的操作,有以下伪代码:
if(A == V) {
V = B;
}
上述这里 A 和 V的值不相同,则无事发生。
在上述过程中,大多数不关心 B 的值后续是多少,更关心的是 V 这个变量的情况!!
这里说是交换,其实是赋值!!
另外,上述 CAS 的操作并不是一段代码来实现的,而是通过一条指令 CPU 来实现的。所以 CAS 操作是原子的,那么咱们后续在解决线程安全问题除了加锁之外,又多了一个方法。
CAS 的伪代码:
小结:CAS 可以理解成是 CPU 给咱们提供了一条特殊的指令,通过这个指令,可以一定程度上解决线程安全问题。
2.2 CAS 的应用场景
2.2.1 实现原子类
这个是 java 标准库实现的。
假如,我们想要多线程版本将一个变量的值自增 2w 次。
版本1:
public class CSA {
public static int count;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}
根据上述的结果,很明显,版本1两个线程同时修改同一个变量,此时会有线程安全问题。
版本2:
public static void main1(String[] args) throws InterruptedException {
// 这些原子类,就是基于 CAS 实现了 自增、自减等操作,此时进行这类操作不需要加锁,也是线程安全的。
AtomicInteger atomicInteger = new AtomicInteger();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count = atomicInteger.incrementAndGet();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count = atomicInteger.incrementAndGet();
}
});
t1.start();;
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
当我们使用 基于CAS 实现的原子类,就不会出现线程安全问题。
如下是基于 CAS 实现的原子类的伪代码:
public class AtomicInteger {
private int val;
public int getAndIncrement() {
int oldValue = val;
while (CAS(val,oldValue,oldValue+1) != true) {
val = oldValue;
}
return oldValue;
}
}
我们现在来分析一下多线程使用基于 CAS 实现的原子类,实现自增。
两个线程 t1 和 t2:
t1 和 t2 中的 CAS 操作执行的是,看 val 和 oldvalue 的值是否相等,如果相等,就把 oldValue + 1 的值写入 val 中。
原子类的实现,就保证每一次自增的时候,判断一下当前寄存器中的值是否和内存中一样,如果不一样,就重新读取内存中的值。
2.2.2 实现自旋锁
public class SpinLock {
private Thread owner = null;
public void lock() {
// 通过 CAS 查看当前的锁是被哪个线程持有
// 如果这个锁被其他线程持有了,那么就自旋等待
// 如果这个锁没有被其他线程持有,那么就将 owner 设置为当前尝试加锁的线程
while (CAS(owner,null,Thread.currentThread())) {
}
}
public void unlock() {
this.owner = null;
}
}
CAS 这里起到的效果是,检测当前的 owner 是否为空,如果为空,那么就给 owner 赋值当前线程的引用,如果赋值成功,循环就结束,尝试加锁成功。
如果当前的锁被其他线程占用了,那么 CAS 就不会产生任何赋值,继续循环,继续等待下次判定。
注意:在Java 中,并不是直接提供了一个方法,叫做 CAS,Java 原生态提供的 CAS 还是比较复杂,这里的 CAS 只是一个简单的表示形式。
2.3 CAS 的ABA问题
前面说到 CAS,其核心就是判断内存中的 val 和 oldValue 是否一致,如果一致,就视为内存中的 val 在中途没有被其他线程改变,所以进行下一步操作的时候,不会有线程安全问题。
但是,这里说的内存val 和 oldValue 一致,内存中的 val 可能是一直没改过,也可能是改过了,但是后来又还原回来了。
ABA 问题,一般情况下,不会对代码逻辑有啥太大影响。但是不排除一些特殊极端的情况,虽然这些情况,在实际开发中的概率很低,但还是要注意。
现在,我们脑补一个极端的例子:
滑稽老铁现在准备去银行取钱,原本他的账户上余额1000元,准备取500.当他按下取款这一刻,突然机器卡了,于是滑稽老铁多按了几次取款按钮。下面我们考虑 CAS 的方式来扣款。
第一次执行 CAS 的时候,账户余额为 500,第二次继续执行 CAS,由于账户余额500 != 1000,此时 CAS 不执行,扣款失败,这是正常的情况。
如果此时,在滑稽老铁第二次准备 CAS 取钱的时候,有个人给他账户上转账500,此时,账户余额又变成了1000,那么第二次扣款就会成功,那么总共扣款1000元,那么肯定有问题,出现bug了!!
当然了,上述场景,发生的概率还是非常低的,一方面,恰好,滑稽老铁多按了几次取钱,那边正好有人给他转一样的金额。
概率低,但并不代表不考虑,如果发生这样的情况,都是不容易解决的,所以,我们要防患于未然。
针对 ABA 问题,我们采取的解决方案是,加入一个版本号。想象成初始版本号为1,以后每修改一次版本号+1,然后进行 CAS 的时候,就不是以金额为基准,而是以版本号为基准。
三、synchronized 原理
前面我们介绍到了,针对六类锁的形容,synchronized 可以进行划分。
1.synchronized 默认是一个乐观锁,当锁竞争程度较激烈,就会转为悲观锁。
2.synchronized 默认是一个轻量级锁,当锁竞争较激烈,会转为重量级锁。
3.synchronized 实现轻量级锁,用到了自旋锁的实现。实现重量级锁,用到了挂起等待锁的实现。
4.synchronized 是一个非公平锁。
5.synchronized 是一个可重入锁。
6.synchronized 不是读写锁。
除此之外,synchronized 内部还有一些其他的优化机制,存在的目的就是让这个锁更加高效,更好用。
3.1锁升级
我们在在使用synchronized 加锁的时候,可能会涉及以下几个过程。
1.无锁
2.偏向锁
3.轻量锁
4.重量锁
synchronized(locker) {
}
当代码执行到synchronized 内部的时候,加锁过程可能涉及到上述几个过程。
偏向锁 -> 轻量级锁
进行加锁的时候,首先会进入偏向锁状态,偏向锁,并不是真正加锁,而是占了一个位置(这个操作是非常轻量的),如果整个使用锁过程中,都没有出现锁竞争,在 synchronized 之后,取消偏向锁即可。但是,如果使用过程中,另外一个线程也尝试加锁,那么就会在它加锁之前,迅速的把偏向锁升级成轻量级锁,另一个线程就只能阻塞等待了。
当synchronized 发生锁竞争的时候,就会从偏向锁转向轻量级锁,此时 synchronized 通过自旋锁的方式来进行加锁的。如果别人很快就释放锁了,那么很划算,但是如果迟迟拿不到锁,一直自旋并不划算,synchronized 自旋并不是无止境的自旋下去,而是达到了一定程度之后,就会再次升级为重量级锁。
重量级锁(挂起等待锁),则是基于系统API来进行加锁,如果线程进行了重量级加锁,并且发生了锁竞争,此时线程就会被放到阻塞队列中,暂时不参与 CPU 的调度,然后直到锁被释放,这个线程才有机会被调度到,才有机会获取到锁。
有一个问题:锁能升级,那么升级之后,锁还能降级吗?
没有!!! JVM 主流实现中,只有锁升级,没有锁降级。当前锁只能升级,只要是指定的锁对象已经升级了,就回不了头了。除非是搞另外一个锁对象,重复刚才的 无锁、偏向锁、轻量级锁、重量级锁…
3.2 锁消除
由于编译器的智能判定,会查看当前代码是否真的需要加锁,如果这个场景不需要加锁,程序猿也加了,就会自动把锁干掉。
3.3 锁粗化
要理解锁粗化,先要搞清楚 锁粒度。
锁粒度:synchronized 包含的代码越多,粒度就越粗,包含的代码越少,粒度就越细。
通常情况下,我们认为,锁粒度细一点比较好,加锁的代码是不能并发执行的,锁的粒度越细,说明能并发执行的代码就越多,效率就越高,反之就越低。
但是,有些情况下,锁粒度粗一些更好。
假如每一次解锁和下一次加锁之间的时间非常短,此时还不如直接搞一个大锁直接搞定。
举个例子来说。
假设在公司,你的领导给你安排了三个任务。下面有两种方式向你的领导汇报工作。
第1种:
打电话,汇报工作1,挂电话。
打电话,汇报工作2,挂电话。
打电话,汇报工作3,挂电话。
第2种:
打电话,汇报工作1、汇报工作2、汇报工作3,挂电话。
很显然,上述两种情况,第二种更好。此时就相当于把多个小锁直接使用一个大锁解决。
四、JUC(Java.util.concurrent)的常见类
JUC 这个类中,存放了并发编程相关的组件。并发编程是更广义的概念,多线程是实现并发编程中的一种具体方式,同时也是 Java 提供的默认的方式。除此之外,还有很多其他的并发编程模型。
4.1 Callable 接口
Callable 接口,类似于 Runnable 一样。
Runnable,描述一个线程要执行的任务,描述的任务没有返回值。
Callable,也是用来描述一个任务,描述的任务带有返回值。
如果需要使用某个任务来单独计算某个结果,此时使用 Callable 更合适。
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
System.out.println("hello");
return 1;
}
};
上述的 call 方法,就相当于 Thread 类中的 run 方法。
不能直接把 callable 引用直接放在Thread 类的构造方法中。
public static void main(String[] args) throws Exception {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
System.out.println("hello");
return 1;
}
};
Thread t = new Thread(callable); // error
}
而是先要指定一下,未来的类 FutereTask,将 callable 打包成未来的一个任务,再传入 Thread类中。
public static void main(String[] args) throws Exception {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
System.out.println("hello");
return 1;
}
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
}
可以通过 get 方法获取到任务的返回值,但是 get 会阻塞等待,直到 callable 执行完毕,get 才阻塞成功,才获取到结果。
Integer result = futureTask.get();
因此,我们创建线程的方式又多了一种。
4.2 ReentrantLock
ReentrantLock,可重入锁,是Java标准库给我们提供的另外一种锁,也是可重入的。
前面我们说到 synchronized 是根据代码块来进行加锁和解锁的。
而这里的 ReentrantLock 是使用 lock 和 unlock 来进行加锁解锁的。
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
// 加锁
reentrantLock.lock();
// 解锁
reentrantLock.unlock();
}
4.2.1 ReentrantLock 劣势
上述这样的写法,会有一个最大的问题,就是 unlock 可能会执行不到。
比如下面的伪代码:
可能程序在 unlock 之前就已经 return 了,那么此时就不会执行 unlock。
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
reentrantLock.lock();
if(cond1) {
reentrantLock.unlock();
return;
}
if(cond2) {
return;
}
reentrantLock.unlock();
}
针对上述可能执行不到 unlock ,可以使用 finally 解决。
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
reentrantLock.lock();
if(cond1) {
reentrantLock.unlock();
return;
}
if(cond2) {
return;
}
throw new Exception();
final {
reentrantLock.unlock();
}
}
4.2.2 ReentrantLock 优势
- ReentrantLock 提供了公平版本的实现。
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock(true);
}
- synchronized 提供的加锁操作,如果获取不到锁,就会死等,一直阻塞等待。而ReentrantLock 提供了更灵活的方式,tryLock。
无参数版本:能获取到锁就获取,获取不到就放弃。
reentrantLock.tryLock();
有参数版本:可以指定一个等待时间,超过了这个时间,还获取不到锁,就放弃。
- ReentrantLock 提供了一个更强大,更方便的等待通知机制。
synchronized 搭配 wait、notify,唤醒的是随机一个 wait 的线程,ReentrantLock 搭配 wait、notify 可以唤醒指定的 wait 线程。
虽然ReentrantLock 具有一定的优势,但是实际开发中,用的最多的还是 synchronized。
synchronized 是针对()里的对象,本质上是操作对象里的”对象头”里的数据结构,这个部分是 JVM 内部的 C++ 代码实现的。
ReentrantLock 的锁对象就是你定义的 ReentrantLock 实例,这是在Java 代码层面上进行的锁对象。
4.3 原子类
原子类内部使用的是 CAS实现,所以性能要比 i++ 高很多,原子类有以下几个:
以 AtomicInteger 为例,常见方法有:
我们可以看到,基于 CAS 确实能够更高效的解决线程安全问题,但是 CAS 不能代替锁,它的适用范围是有限的的,不像锁适用范围那么广。
4.4 信号量 Semaphore
操作系统中的信号量和这里的信号量是一个东西,只不过此处的信号量是Java 把操作系统中的信号量重新封装了一下。
很多停车场,在入口这里会有一个牌子,上面显示:当前空闲车位有xxx个。
每次有车,从入口进去,计数器就会 -1;
每次从出口出来,计数器就会+1;
如果当前停车场满了,此时计数器就是0,如果此时有车还想停,
1)等待
2)放弃这里,寻找下一个停车场
信号量,本质上就是一个计数器,描述了可用资源的个数。
P操作:申请一个可用资源,计数器-1;
V操作:释放一个可用资源,计数器+1;
P操作如果为0了,继续P操作,就会阻塞等待。
接下来,我们考虑一个初始值为1 的信号量。针对这个信号量,只有1和0这两种取值!!(信号量不能为负数)
进行一次P操作:1 -> 0
进行一次V操作:0 -> 1
如果已经进行了一次P操作,再接着P操作,就会阻塞等待。
之前我们谈到的 锁 这就类似于一个二元信号量。
实际开发中,虽然锁是最常见的,但是信号量也会偶尔使用到,主要还是看具体的需求场景。
4.5 CountDownLatch
想象一下,有一个跑步比赛
这场比赛中,开始的时间是明确的(裁判的发令枪),结束时间则是不明确的(所有运动员都过了终点线)。
为了等待这个比赛结束,我们就引入了 CountDownLatch。主要有两种方法:
(1)await:表示等待所有的选手过终点
(2)countDown:在构造的时候,指定一个计数(选手的个数)
例如:有四个选手进行比赛
初始情况下,调用 await,就会阻塞,每个选手都冲过终点,都会调用一次countDown。
前三次调用 countDown 没有任何影响,第四次调用 countDown, await 就会被
唤醒,返回解除机制,此时就认为整个比赛都已经结束了。
在实际开发中,countDowmLatch 也是有很多应用场景的。
比如,下载一个大文件,可以的做法是,把这个大文件切分多个小块,安排多线程去分别下载,然后通过 CountDownLatch 来区分整体是不是下载完了。
五、多线程环境使用 ArrayList
Java 标准库中的类,大部分情况下是线程不安全的。多个线程使用同一个类的时候,很可能会出问题。
Vector、Stack、HashTable这几个类是少有的线程安全的集合类,关键方法都带有 synchronized。
而 ArrayList 是不带有 synchronized 的集合类。
- 自己加锁,自己使用 synchronized 和 ReentrantLock【最常见】
- Collections.synchronizedList 这里会提供一些 ArrayList 相关的方法,同时是带锁的,使用这个方法,把集合类嵌套一层。
- CopyOnWriteArrayList,简称 “COW”,写时拷贝
如果针对ArrayList 进行读操作,不做额外任何工作。
如果进行写操作,则拷贝一份新的ArrayList,针对新的进行修改,在修改过程中,如果有读操作,就对旧的ArrayList读,当修改完了,使用新的替换旧的。
很显然,这种操作优点是不需要加锁,缺点是,这个ArrayList 不能太大,只是适用于数组较小的情况。
六、多线程环境使用哈希表【重点、难点】
HashMap 是不安全的,HashTable 是安全的,给关键方法加锁了。
因此,针对HashMap,更推荐使用的是 ConcurrentHashMap,更优化的线程安全哈希表。
下面有一个考点:
ConcurrentHashMap 进行了哪些优化?比 HashTable 好在哪里?和 HashTable 之间的区别是啥?
- ConcurrentHashMap 相对于 HashTable 大大缩小了锁冲突的概率,把一把大锁,变成了N把小锁。
HashTable 的做法是,直接在方法上加 synchronized,等于给 this 加锁,只要操作哈希表上的任意元素,都会产生加锁,也就都有可能发生锁冲突。
但是,实际上,基于哈希表的结构特点,有些元素进行并发操作的时候,是不会产生线程安全的,也就不需要锁控制。
假设,现在我将整个哈希表加锁了。
此时元素1 和 元素2 在一个链表(二叉树)上,如果线程A修改元素1,线程B修改元素2,是否会有线程安全问题?【这种情况是需要加锁的】
很显然,此时会有线程安全问题。如果两个元素相邻,此时并发执行插入/删除元素,就需要修改相邻节点的next指向。
另外,元素3和元素4是在不同的链表(二叉树)上,此时线程1去修改元素3,线程2去修改元素4,是否会有线程安全问题?【这个情况不需要加锁】
不会,因为这相当于多线程去修改不同的变量,此时不会有线程安全问题。
ConcurrentHashMap 的做法是,每个链表都有自己的锁(而不是大家共用同一把锁了)。具体来说,就是每个链表头结点来作为加锁对象,啊,两个线程针对同一个对象进行加锁,才会有锁竞争,才有阻塞等待,针对不同对象,没有锁竞争。
此时锁的粒度变细了。
针对元素1和元素2情况,是针对同一把锁进行加锁,会有锁竞争,会保证线程安全。
针对元素3和元素4,针对不同的所进行加锁,此时不会有锁竞争,没有阻塞等待,程序会执行更快。(再快,也不会有不加锁快)
上述情况,是针对JDK1.8及其之后的情况,在1.8之前,ConcurrentHashMap使用的是 “分段锁”。
分段锁,本质上也是缩小锁范围,从而降低锁冲突,但是这种做法不够彻底,一方面粒度不够细,另一方面代码实现起来比较繁琐。
-
ConcurrentHashMap 做了一个激进的操作。针对读操作不加锁,只针对写操作加锁。
读和读之间没有冲突。
写和写之间有冲突。
读和写之间没有冲突。(很多场景下,读和写之间不加锁,可能会读到了一个写了一半的数据,相当于脏读了) -
ConcurrentHashMap 内部也充分使用了 CAS 操作,通过这个也进一步减少了加锁操作的数目。
- 针对扩容,采取了 “化整为零”的方式。
HashMap、HashTable 的扩容:
创建一个更大的数组空间,把旧数组上的内容搬到新数组上(插入+删除),这个操作会在某次put的时候触发,如果旧数组上的内容多,这么搬运的话,非常耗时。就会出现,某个put比平时put会卡很多。
ConcurrentHashMap 的扩容:
每次搬运一小部分的内容。创建一个新的数组,旧的数组也会保留。每次put操作,都是往新数组上插入,并同时搬运一小部分元素。每次get的时候,旧数组和新数组都查询,每次remove,只是把元素删了就行了。
最终,所有的元素都搬运到新数组中,在释放掉旧数组即可。
七、总结
好了,以上就是多线程的进阶相关的知识了,整个多线程内容也就告一段落了。有喜欢的小伙伴,希望点点关注,后续小编会再接再厉,争取创作出更好的文章。