讲义整理-并发安全 冷不防 2023-01-14 05:57 162阅读 0赞 ### 讲义整理-并发安全 ### * 并发安全 * * 什么是线程安全性 * 线程封闭 * 无状态的类 * 让类不可变 * volatile * 加锁和 CAS * 安全的发布 * TheadLocal * Servlet 辨析 * 死锁 * * 概念 * 现象、危害和解决 * * 现象 * 危害 * 解决 * 其他安全问题 * * 活锁 * 线程饥饿 * 并发下的性能 * * 线程引入的开销 * * 上下文切换 * 内存同步 * 阻塞 * 如何减少锁的竞争 * * 减少锁的粒度 * 缩小锁的范围 * 避免多余的锁 * 锁分段 * 替换独占锁 * 线程安全的单例模式 * * 双重检查锁定 * 解决之道 * * 懒汉式 * 饿汉式 -------------------- # 并发安全 # ## 什么是线程安全性 ## 在《Java 并发编程实战》中,定义如下: 当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在调用代码中不需要任何额外的同步或者协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。 ## 线程封闭 ## 实现好的并发是一件困难的事情,所以很多时候我们都想躲避并发。避免并发最简单的方法就是线程封闭。什么是线程封闭呢? 就是把对象封装到一个线程里,只有这一个线程能看到此对象。那么这个对象就算不是线程安全的也不会出现任何安全问题。实现线程封闭有哪些方法呢? ad-hoc 线程封闭 这是完全靠实现者控制的线程封闭,他的线程封闭完全靠实现者实现。ad-hoc 线程封闭非常脆弱,应该尽量避免使用。 栈封闭 栈封闭是我们编程当中遇到的最多的线程封闭。什么是栈封闭呢?简单的说就是局部变量。多个线程访问一个方法,此方法中的局部变量都会被拷贝一份到线程栈中。所以局部变量是不被多个线程所共享的,也就不会出现并发问题。所以能用局部变量就别用全局的变量,全局变量容易引起并发问题。 ## 无状态的类 ## 没有任何成员变量的类,就叫无状态的类,这种类一定是线程安全的。 参见代码:cn.enjoyedu.ch7.safeclass. StatelessClass。 如果这个类的方法参数中使用了对象,也是线程安全的吗?比如: ![在这里插入图片描述][watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2tlZW5zdHlsZQ_size_16_color_FFFFFF_t_70] 当然也是,为何?因为多线程下的使用,固然 user 这个对象的实例会不正常,但是对于 StatelessClass 这个类的对象实例来说,它并不持有 UserVo 的对象实例,它自己并不会有问题,有问题的是 UserVo 这个类,而非 StatelessClass 本身。 ## 让类不可变 ## 让状态不可变,两种方式: 1. 加 final 关键字,对于一个类,所有的成员变量应该是私有的,同样的只要有可能,所有的成员变量应该加上 final 关键字,但是加上 final,要注意如果成员变量又是一个对象时,这个对象所对应的类也要是不可变,才能保证整个类是不可变的。 参见代码 cn.enjoyedu.ch7.safeclass.ImmutableClass 2. 根本就不提供任何可供修改成员变量的地方,同时成员变量也不作为方法的返回值。参见代码 cn.enjoyedu.ch7.safeclass. ImmutableClassToo但是要注意,一旦类的成员变量中有对象,上述的 final 关键字保证不可变并不能保证类的安全性,为何?因为在多线程下,虽然对象的引用不可变,但是对象在堆上的实例是有可能被多个线程同时修改的,没有正确处理的情况下,对象实例在堆中的数据是不可预知的。这就牵涉到了如何安全的发布对象这个问题。 但是要注意,一旦类的成员变量中有对象,上述的 final 关键字保证不可变并不能保证类的安全性,为何?因为在多线程下,虽然对象的引用不可变,但是对象在堆上的实例是有可能被多个线程同时修改的,没有正确处理的情况下,对象实例在堆中的数据是不可预知的。这就牵涉到了如何安全的发布对象这个问题。 ![在这里插入图片描述][watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2tlZW5zdHlsZQ_size_16_color_FFFFFF_t_70 1] ## volatile ## 并不能保证类的线程安全性,只能保证类的可见性,最适合一个线程写,多个线程读的情景。 ## 加锁和 CAS ## 我们最常使用的保证线程安全的手段,使用 synchronized 关键字,使用显式锁,使用各种原子变量,修改数据时使用 CAS 机制等等。 ## 安全的发布 ## 类中持有的成员变量,如果是基本类型,发布出去,并没有关系,因为发布出去的其实是这个变量的一个副本, 参见代码 cn.enjoyedu.ch7.safeclass.SafePublish。 但是如果类中持有的成员变量是对象的引用,如果这个成员对象不是线程安全的,通过 get 等方法发布出去,会造成这个成员对象本身持有的数据在多线程下不正确的修改,从而造成整个类线程不安全的问题。 参见代码cn.enjoyedu.ch7.safeclass. UnSafePublish,可以看见, ![在这里插入图片描述][watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2tlZW5zdHlsZQ_size_16_color_FFFFFF_t_70 2] 这个 list 发布出去后,是可以被外部线程之间修改,那么在多个线程同时修改的情况下不安全问题是肯定存在的,怎么修正这个问题呢?我们在发布这对象出去的时候,就应该用线程安全的方式包装这个对象。 参见代码cn.enjoyedu.ch7.safeclass. SafePublishToo。 我们将 list 用Collections.synchronizedList 进行包装以后,无论多少线程使用这个 list,就都是线程安全的了。 ![在这里插入图片描述][20210417162403959.png] 对于我们自己使用或者声明的类,JDK 自然没有提供这种包装类的办法,但是我们可以仿造这种模式或者委托给线程安全的类,当然,对这种通过 get 等方法发布出去的对象,最根本的解决办法还是应该在实现上就考虑到线程安全问题, 参见代码包 cn.enjoyedu.ch7.safeclass.safepublish 下的代码 ## TheadLocal ## ThreadLocal 是实现线程封闭的最好方法。ThreadLocal 内部维护了一个 Map,Map 的 key 是每个线程的名称,而 Map 的值就是我们要封闭的对象。每个线程中的对象都对应着 Map 中一个值,也就是 ThreadLocal 利用 Map 实现了对象的线程封闭。 ## Servlet 辨析 ## 不是线程安全的类,为什么我们平时没感觉到: 1. 在需求上,很少有共享的需求, 2. 接收到了请求,返回应答的时候,一般都是由一个线程来负责的。 但是只要 Servlet 中有成员变量,一旦有多线程下的写,就很容易产生线程安全问题。 ## 死锁 ## ### 概念 ### 是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。 举个例子:A 和 B 去按摩洗脚,都想在洗脚的时候,同时顺便做个头部按摩,13 技师擅长足底按摩,14 擅长头部按摩。 这个时候 A 先抢到 14,B 先抢到 13,两个人都想同时洗脚和头部按摩,于是就互不相让,扬言我死也不让你,这样的话,A 抢到 14,想要 13,B 抢到 13,想要 14,在这个想同时洗脚和头部按摩的事情上 A 和 B 就产生了死锁。 **怎么解决这个问题呢?** 第一种,假如这个时候,来了个 15,刚好也是擅长头部按摩的,A 又没有两个脑袋,自然就归了 B,于是 B 就美滋滋的洗脚和做头部按摩,剩下 A 在旁边气鼓鼓的,这个时候死锁这种情况就被打破了,不存在了。 第二种,C 出场了,用武力强迫 A 和 B,必须先做洗脚,再头部按摩,这种情况下,A 和 B 谁先抢到 13,谁就可以进行下去,另外一个没抢到的,就等着,这种情况下,也不会产生死锁。 **总结一下:** 死锁是必然发生在多操作者(M>=2 个)情况下,争夺多个资源(N>=2 个,且 N<=M)才会发生这种情况。很明显,单线程自然不会有死锁,只有 B 一个去,不要 2 个,打十个都没问题;单资源呢?只有 13,A 和 B 也只会产生激烈竞争,打得不可开交,谁抢到就是谁的,但不会产生死锁。同时,死锁还有一个重要的要求,争夺资源的顺序不对,如果争夺资源的顺序是一样的,也不会产生死锁。 **学术化的定义** 死锁的发生必须具备以下四个必要条件。 1. 互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放。 2. 请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。 3. 不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。 4. 环路等待条件:指在发生死锁时,必然存在一个进程——资源的环形链,即进程集合\{P0,P1,P2,···,Pn\}中的 P0 正在等待一个 P1 占用的资源;P1正在等待 P2 占用的资源,……,Pn 正在等待已被 P0 占用的资源。 理解了死锁的原因,尤其是产生死锁的四个必要条件,就可以最大可能地避免、预防和解除死锁。只要打破四个必要条件之一就能有效预防死锁的发生:打破互斥条件:改造独占性资源为虚拟资源,大部分资源已无法改造。打破不可抢占条件:当一进程占有一独占性资源后又申请一独占性资源而无法满足,则退出原占有的资源。打破占有且申请条件:采用资源预先分配策略,即进程运行前申请全部资源,满足则运行,不然就等待,这样就不会占有且申请。打破循环等待条件:实现资源有序分配策略,对所有设备实现分类编号,所有进程只能采用按序号递增的形式申请资源。 避免死锁常见的算法有有序资源分配法、银行家算法。 ## 现象、危害和解决 ## 在我们 IT 世界有没有存在死锁的情况,有:数据库里多事务而且要同时操作多个表的情况下。所以数据库设计的时候就考虑到了检测死锁和从死锁中恢复的机制。比如 oracle 提供了检测和处理死锁的语句,而 mysql 也提供了“循环依赖检测的机制” ![在这里插入图片描述][watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2tlZW5zdHlsZQ_size_16_color_FFFFFF_t_70 3] ![在这里插入图片描述][watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2tlZW5zdHlsZQ_size_16_color_FFFFFF_t_70 4] 在 Java 世界里存在着多线程争夺多个资源,不可避免的存在着死锁。 **那么我们在编写代码的时候什么情况下会发生呢?** ### 现象 ### **简单顺序死锁** 参见代码 cn.enjoyedu.ch7. NormalDeadLock **动态顺序死锁** 顾名思义也是和获取锁的顺序有关,但是比较隐蔽,不像简单顺序死锁,往往从代码一眼就看出获取锁的顺序不对。 参见代码 cn.enjoyedu.ch7.tranfer.service. UserAccou ### 危害 ### 1. 线程不工作了,但是整个程序还是活着的 2. 没有任何的异常信息可以供我们检查。 3. 一旦程序发生了发生了死锁,是没有任何的办法恢复的,只能重启程序,对生产平台的程序来说,这是个很严重的问题。 **实际工作中的死锁** 时间不定,不是每次必现;一旦出现没有任何异常信息,只知道这个应用的所有业务越来越慢,最后停止服务,无法确定是哪个具体业务导致的问题;测试部门也无法复现,并发量不够。 ### 解决 ### **定位**:要解决死锁,当然要先找到死锁,怎么找? 通过 jps 查询应用的 id,再通过 jstack id 查看应用的锁的 ![在这里插入图片描述][watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2tlZW5zdHlsZQ_size_16_color_FFFFFF_t_70 5] ![在这里插入图片描述][watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2tlZW5zdHlsZQ_size_16_color_FFFFFF_t_70 6] ![在这里插入图片描述][watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2tlZW5zdHlsZQ_size_16_color_FFFFFF_t_70 7] **修正** 关键是保证拿锁的顺序一致 两种解决方式 1. 内部通过顺序比较,确定拿锁的顺序; 2. 采用尝试拿锁的机制。 参见代码 cn.enjoyedu.ch7.tranfer.service. SafeOperate 和SafeOperateToo ## 其他安全问题 ## ### 活锁 ### 两个线程在尝试拿锁的机制中,发生多个线程之间互相谦让,不断发生同一个线程总是拿到同一把锁,在尝试拿另一把锁时因为拿不到,而将本来已经持有的锁释放的过程。 解决办法:每个线程休眠随机数,错开拿锁的时间。 ### 线程饥饿 ### 低优先级的线程,总是拿不到执行时间 ## 并发下的性能 ## 使用并发的目标是为了提高性能,引入多线程后,其实会引入额外的开销,如线程之间的协调、增加的上下文切换,线程的创建和销毁,线程的调度等等。过度的使用和不恰当的使用,会导致多线程程序甚至比单线程还要低。 衡量应用的程序的性能:服务时间,延迟时间,吞吐量,可伸缩性等等,其中服务时间,延迟时间(多快),吞吐量(处理能力的指标,完成工作的多少)。多快和多少,完全独立,甚至是相互矛盾的。 对服务器应用来说:多少(可伸缩性,吞吐量)这个方面比多快更受重视。 我们做应用的时候: 1. **先保证程序正确,确实达不到要求的时候,再提高速度。(黄金原则)** 2. **一定要以测试为基准。** ### 线程引入的开销 ### #### 上下文切换 #### 如果主线程是唯一的线程,那么它基本上不会被调度出去。另一方面,如果可运行的线程数大于 CPU 的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用 CPU。这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。上下文切换有点像我们同时阅读几本书,在来回切换书本的同时我们需要记住每本书当前读到的页码。 切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和JVM 共享的数据结构。应用程序、操作系统以及 JVM 都使用一组相同的 CPU。在 JVM 和操作系统的代码中消耗越多的 CPU 时钟周期,应用程序的可用 CPU 时钟周期就越少。但上下文切换的开销并不只是包含 JVM 和操作系统的开销。当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。 当线程由于等待某个发生竞争的锁而被阻塞时,JVM 通常会将这个线程挂起, 并允许它被交换出去。如果线程频繁地发生阻塞,那么它们将无法使用完整的调度时间片。在程序中发生越多的阻塞(包括阻塞 IO,等待获取发生竞争的锁,或者在条件变量上等待),与 CPU 密集型的程序就会发生越多的上下文切换,从而增加调度开销,并因此而降低吞吐量。 上下文切换是计算密集型操作。也就是说,它需要相当可观的处理器时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。上下文切换的实际开销会随着平台的不同而变化, 然而按照经验来看:在大多数通用的处理器中,上下文切换的开销相当于50~10000 个时钟周期,也就是几微秒。 UNIX系统的 vmstat命令能报告上下文切换次数以及在内核中执行时间所占比例等信息。如果内核占用率较高(超过 10%),那么通常表示调度活动发生得很频繁,这很可能是由 IO 或竞争锁导致的阻塞引起的。 #### 内存同步 #### 同步操作的性能开销包括多个方面。在 synchronized 和 volatile 提供的可见性保证中可能会使用一些特殊指令,即内存栅栏( Memory Barrier)。 内存栅栏可以刷新缓存,使缓存无效刷新硬件的写缓冲,以及停止执行管道。 内存栅栏可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。在内存栅栏中,大多数操作都是不能被重排序的。 #### 阻塞 #### 引起阻塞的原因:包括阻塞 IO,等待获取发生竞争的锁,或者在条件变量上等待等等。 阻塞会导致线程挂起【挂起:挂起进程在操作系统中可以定义为暂时被淘汰出内存的进程,机器的资源是有限的,在资源不足的情况下,操作系统对在内存中的程序进行合理的安排,其中有的进程被暂时调离出内存,当条件允许的时候,会被操作系统再次调回内存,重新进入等待被执行的状态即就绪态,系统在超过一定的时间没有任何动作】。 很明显这个操作至少包括两次额外的上下文切换,还有相关的操作系统级的操作等等。 ### 如何减少锁的竞争 ### #### 减少锁的粒度 #### 使用锁的时候,锁所保护的对象是多个,当这些多个对象其实是独立变化的时候,不如用多个锁来一一保护这些对象。但是如果有同时要持有多个锁的业务方法,要注意避免发生死锁 #### 缩小锁的范围 #### 对锁的持有实现快进快出,尽量缩短持由锁的的时间。将一些与锁无关的代码移出锁的范围,特别是一些耗时,可能阻塞的操作 #### 避免多余的锁 #### 两次加锁之间的语句非常简单,导致加锁的时间比执行这些语句还长,这个时候应该进行锁粗化—扩大锁的范围。 #### 锁分段 #### ConcurrrentHashMap 就是典型的锁分段。 #### 替换独占锁 #### 在业务允许的情况下: 1. 使用读写锁, 2. 用自旋 CAS 3. 使用系统的并发容器 ## 线程安全的单例模式 ## 参见代码包 cn.enjoyedu.ch7.dcl 下 ### 双重检查锁定 ### ![在这里插入图片描述][watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2tlZW5zdHlsZQ_size_16_color_FFFFFF_t_70 8] 解决办法,加 volatile 关键字 ### 解决之道 ### #### 懒汉式 #### 类初始化模式,也叫延迟占位模式。在单例类的内部由一个私有静态内部类来持有这个单例类的实例。 延迟占位模式还可以用在多线程下实例域的延迟赋值。 #### 饿汉式 #### 在声明的时候就 new 这个类的实例,因为在 JVM 中,对类的加载和类初始化,由虚拟机保证线程安全。 或者使用枚举 [watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2tlZW5zdHlsZQ_size_16_color_FFFFFF_t_70]: /images/20221022/2a3052628a784658854bd7de841cebed.png [watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2tlZW5zdHlsZQ_size_16_color_FFFFFF_t_70 1]: /images/20221022/3abe88e616d2475b90f6576310016aaf.png [watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2tlZW5zdHlsZQ_size_16_color_FFFFFF_t_70 2]: /images/20221022/1334fa57e75345898c4f3fae78a65a1a.png [20210417162403959.png]: /images/20221022/4d8c8c6bb095458fb4873bbf39e6e56d.png [watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2tlZW5zdHlsZQ_size_16_color_FFFFFF_t_70 3]: /images/20221022/6c352dad4fcd46d98b2151918ed2e6c8.png [watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2tlZW5zdHlsZQ_size_16_color_FFFFFF_t_70 4]: /images/20221022/3c735417766c450282d5c4e629c72b5b.png [watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2tlZW5zdHlsZQ_size_16_color_FFFFFF_t_70 5]: /images/20221022/5574d02ac267406cae64ce1a3ac19a27.png [watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2tlZW5zdHlsZQ_size_16_color_FFFFFF_t_70 6]: /images/20221022/665fa6c7d6744b9e8dd7246878ea550e.png [watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2tlZW5zdHlsZQ_size_16_color_FFFFFF_t_70 7]: /images/20221022/9e55f83a38ce4ce6a55f6aa293ca1163.png [watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2tlZW5zdHlsZQ_size_16_color_FFFFFF_t_70 8]: /images/20221022/aaecbf1fe42d45a48a1108b8c0c70864.png
相关 讲义整理-常见并发面试题 讲义整理-总复习和常见并发面试题 总复习和常见并发面试题 谈面试 常见面试题 在 java 中守护线程和用户线程的区别 拼搏现实的明天。/ 2023年01月14日 06:55/ 0 赞/ 169 阅读
相关 讲义整理-Java8新增的并发 讲义整理-Java8新增的并发 Java8新增的并发 原子操作 CAS LongAdder 其他新增 た 入场券/ 2023年01月14日 05:59/ 0 赞/ 177 阅读
相关 讲义整理-JMM 和底层实现原理 讲义整理-JMM 和底层实现原理 JMM 和底层实现原理 JMM 基础-计算机原理 物理内存模型带来的问题 伪共享 落日映苍穹つ/ 2023年01月14日 05:58/ 0 赞/ 172 阅读
相关 讲义整理-并发任务执行框架 讲义整理-并发任务执行框架 实战--并发任务执行框架 架构师是什么? 主要职责 架构师的方方面面 比眉伴天荒/ 2023年01月14日 05:58/ 0 赞/ 166 阅读
相关 讲义整理-线程池 讲义整理-线程池 线程池 为什么要用线程池? ThreadPoolExecutor 的类关系 线程池的创建各个参数含义 冷不防/ 2023年01月14日 05:56/ 0 赞/ 133 阅读
相关 讲义整理-原子操作 CAS 讲义整理-并发编程 原子操作 CAS 什么是原子操作?如何实现原子操作? CAS 是怎么实现线程的安全呢? CAS 实 向右看齐/ 2023年01月14日 05:53/ 0 赞/ 216 阅读
相关 讲义整理-线程的并发工具类 讲义整理-并发编程 线程的并发工具类 Fork-Join 分而治之 归并排序 归并排序(降序)示 桃扇骨/ 2023年01月14日 05:52/ 0 赞/ 142 阅读
相关 讲义整理-并发编程 本文是按照自己学习时老师讲义内容整理而来,很多内容过于复杂没有整理完全。 -------------------- [线程基础、线程之间的共享和协作][Link 1] 怼烎@/ 2022年11月19日 01:13/ 0 赞/ 154 阅读
还没有评论,来说两句吧...