《java并发编程实战》 第十一章 如何提升性能与可伸缩性

第十一章 如何提升性能与可伸缩性

  并发程序中提升性能意味着会增加复杂性、活跃性与安全性失败的风险。

对性能的思考

  概念:资源密集型操作,例如CPU密集型、数据库密集型操作。指某个特定操作缺少CPU、数据库请求资源。
   一个并发设计很糟糕的应用程序, 其性能甚至比实现相同功能的串行程序的性能还要差。原因在于,与单线程的方法相比, 使用多个线程总会引人一些额外的性能开销。造成这些开销的操作包括:线程之间的协调(例如加锁、触发信号以及内存同步等), 增加的上下文切换, 线程的创建和销毁, 以及线程的调度等。如果过度地使用线程, 那么这些开销甚至会超过由千提高吞吐量、响应性或者计算能力所带来的性能提升。
  如何提升程序的性能,核心就是通过将 应用程序分解到多个线程上执行, 使得每个处理器都执行一些工作, 从而使所有 CPU都保持忙碌状态。(当然, 这并不意味着将CPU时钟周期浪费在一些无用的计算上, 而 是执行一些有用的工作。)
  性能可以用“多少”和“多快”来衡量,多快用服务时间、等待时间指标,多少用生产量、吞吐量来衡量计算资源一定情况下能完成多少任务。有时我们会牺牲多快来换取多少,我们通常会接受每个工作单元执行更长的时间或消耗更多的计算资源,以换取应用程序在增加更多资源的情况下处理更高的负载。
  可伸缩性指的是:当增加计算资源时(例如CPU、内存、存储容量或I/O带宽),程序的吞吐量或者处理能力能相应的增加。
   可伸缩性和性能也是相互矛盾的,提升可伸缩性往往也会造成性能损失。例如,如果把表现层、业务逻辑层和持久化层都融合到单个应用程序中,那么在处理第一个工作单元时, 其性能肯定要高于将应用程序分为多层并将不同层次分布到多个系统时的性能。 这种单一 的应用程序避免了在不同层次之间传递任务时存在的网络延迟,同时也不需要将计算过程分解到不同的抽象层次, 因此能减少许多开销
  对性能的提升可能是并发错误的最大来源。有人认为同步机制“ 太慢”, 因而采用一些看似聪明实则危险的方法来减少同步的使用, 这也通常作为不遵守同步规则的一个常见借口。然而, 由于并发错误是最难追踪和消除的错误, 因此对于任何可能会引入这类错误的措施, 都需要谨慎实施。
  对于服务器应用程序来说, “ 多少 ” 这个方面——可伸缩性、 吞吐量和生产量, 往往比 “多快 ” 这个方面更受重视。不要用安全性来换取性能。(在交互式应用程序中,延迟或许更加重要, 这样用户就不用等待进度条的指定, 并奇怪程序究竟在执行哪些操作。)本章将重点介绍可伸缩性而不是单线程程序的性能。
   免费的perfbar应用程序可以给出CPU的忙碌程度信息, 而我们通常的目标就是使CPU保持忙碌状态, 因此这个功能可以有效地评估是否需要进行性能调优或者已实现的调优效果如何。

使用Amdahl定律分析可伸缩性

大多数并发程序都与农业耕作有着许多相似之处, 它们都是由一系列的并行工作和串行工作组成的。Amdahl定律描述的是:在增加计算资源的情况下, 程序在理论上能够实现最高加速比, 这个值取决于程序中可并行组件与串行组件所占的比重。假定F是必须被串行执行的部 分, 那么根据Amdahl定律, 在包含N个处理器的机器中, 最高的加速比为:
        
                     在这里插入图片描述
  当N趋近无穷大时, 最大的加速比趋近于1/F。 因此, 如果程序有50%的计算需要串行执行, 那么最高的加速比只能是 2 (而不管有多少个线程可用);如果在程序中有10%的计算需要串行执行, 那么最高的加速比将接近10。Amdahl定律还量化了串行化的效率开销。在拥有10个处理器的系统中, 如果程序中有10%的部分需要串行执行, 那么最高的加速比为5.3 (53% 的使用率), 在拥有 100 个处理器的系统中, 加速比可以达到 9.2 (9% 的使用率)。即使拥有无限多的CPU, 加速比也不可能为10。
  如图给出了处理器利用率在不同串行比例以及处理器数量情况下的变化曲线。(利用率的定义为:加速比除以处理器的数量。)随着处理器数量的增加, 可以很明显地看到, 即使串行 部分所占的百分比很小, 也会极大地限制当增加计算资源时能够提升的吞吐率。
          在这里插入图片描述
  第6章介绍了如何识别任务的逻辑边界并将应用程序分解为多个子任务。 然而, 要预测应用程序在某个多处理器系统中将实现多大的加速比, 还需要找出任务中的串行部分。
  假设应用程序中N个线程正在执行程序消单11-1中的doWork, 这些线程从一个共享的工作队列中取出任务进行处理,而且这里的任务都不依赖于其他任务的执行结果或影响。暂时先不考虑任务是如何进入这个队列的, 如果增加处理器, 那么应用程序的性能是否会相应地发生变化?初看上去, 这个程序似乎能完全并行化:各个任务之间不会相互等待, 因此处理器越多,能够并发处理的任务也就越多。然而,在这个过程中包含了一个串行部分——从队列中获取任务。所有工作者线程都共享同一个工作队列, 因此在对该队列进行并发访问时需要采用某种同步机制来维持队列的完整性。如果通过加锁来保护队列的状态, 那么当一个线程从队列中取出任务时, 其他需要获取下一个任务的线程就必须等待, 这就是任务处理过程中的串行部分

      
    在这里插入图片描述
  单个任务的处理时间不仅包括执行任务Runnable的时间,也包括从共享队列中取出任务的时间。如果使用LinkedBlockingQueue作为工作队列,那么出列操作被阻塞的可能性将小于使用同步LinkedList时发生阻塞的可能性,因为LinkedBlockingQueue使用了一种可伸缩性更高的算法。然而,无论访问何种共享数据结构,基本上都会在程序中引人一个串行部分
  这个示例还忽略了另一种常见的串行操作:对结果进行处理。所有有用的计算都会生成某种结果或者产生某种效应一如果不会,那么可以将它们作为“ 死亡代码” 删除掉。由于Runnable没有提供明确的结果处理过程,因此这些任务一定会产生某种效果,例如将它们的结果写人到日志或者保存到某个数据结构。通常,日志文件和结果容器都会由多个工仵芍旨线程共享,并且这也是一个串行部分。如果所有线程都将各自的计算结果保存到自行维扩喽妇居结构中,并且在所有任务都执行完成后再合并所有的结果,那么这种合并操作也是一个串行部分。
  在所有并发程序中都包含一些串行部分。如果你认为在你程序中不存在串行部分,那么可以再仔细检查一遍。
示例:在各种框架中隐裁的串行部分
  要想知道串行部分是如何隐藏在应用程序的架构中,可以比较当增加线程时吞吐量的变化,并根据观察到的可伸缩性变化来推断串行部分中的差异。图11-2给出了一个简单的应用程序,其中多个线程反复地从一个共享Queue中取出元素进行处理,这与程序清单11-1很相似。处理步骤只需执行线程本地的计算。如果某个线程发现队列为空,那么它将把一组新元素放人队列,因而其他线程在下一次访间时不会没有元素可供处理。在访问共享队列的过程中显然存在着一定程度的串行操作,但处理步骤完全可以并行执行,因为它不会访问共享数据。
    在这里插入图片描述
  图11-2的曲线对两个线程安全的Queue的吞吐率进行了比较:其中一个是采用synchronizedList封装的LinkedList; 另一个是ConcurrentLinkedQueue。这些测试在8路Spare V880系统上运行,操作系统为 Solaris。尽管每次运行都表示相同的 “ 工作量”,但我们可以看到, 只需改变队列的实现方式, 就能对可伸缩性产生明显的影响。
  ConcurrentLinkedQueue 的吞吐量不断提升,直到到达了处理器数量上限, 之后将基本保持不变。 另一方面, 当线程数量小于 3 时,同步 LinkedList 的吞吐量也会有某种程度的提升,但是之后会由于同步开销的增加而下跌。 当线程数最达到 4 个或 5 个时,竞争将非常激烈,至每次访问队列都会在锁上发生竞争,此时的吞吐量主要受到上下文切换的限制。
  吞吐量的差异来源于两个队列中不同比例的串行部分。 同步的 LinkedList 采用单个锁来保护整个队列的状态, 井且在 offer 和 remove 等方法的调用期间都将持有这个锁。 ConcurrentLinkedQueue 使用了一种更复杂的非阻塞队列算法(请参见 15.4.2 节),该算法使用原子引用来更新各个链接指针。 在第一个队列中,整个的插入或删除操作都将串行执行, 而在 第二个队列中, 只有对指针的更新操作需要串行执行。

Amdahl定律的应用

  书中原文,如果能准确估计出执行过程中串行部分所占的比例,那么 Amdahl 定律就能量化当有更多计算资源可用时的加速比。 虽然要直接测量串行部分的比例非常困难,但即使在不进行测试的情况下 Amdahl 定律仍然是有用的。
  因为我们的思维通常会受到周围环境的影响, 因此很多人都会习惯性地认为在多处理器系统中会包含2个或4个处理器,甚至更多(如果得到足够大的预算批准), 因为这种技术在近年来被广泛使用。 但随着多核 CPU逐渐成为主流,系统可能拥有数百个甚至数千个处理器。 一些在 4 路系统中看似具有可伸缩性的算法,却可能含有一些隐藏的可伸缩性瓶颈, 只是还没有遇到而巳。
  在评估一个算法时,要考虑算法在数百个或数千个处理器的情况下的性能表现,从而对可能出现的可伸缩性局限有一定程度的认识。 例如,在 11.4.2 节和 11.4.3 节中介绍了两种降低锁粒度的技术: 锁分解(将一个锁分解为两个锁) 和锁分段(把一个锁分解为多个锁)。 当通过 Amdahl 定律来分析这两项技术时,我们会发现, 如果将一个锁分解为两个锁,似乎并不能充分利用多处理器的能力。 锁分段技术似乎更有前途, 因为分段的数量可随着处理器数量的增加而增加。(当然,性能优化应该考虑实际的性能需求,在某些情况下,将一个锁分解为两个就够了。)

使用多线程带来的开销

单线程程序既不存在线程调度,也不存在同步开销,而且不需要使用锁来保证数据结构的一致性。 在多个线程的调度和协调过程中都需要一定的性能开销: 对于为了提升性能而引入的线程来说,并行带来的性能提升必须超过并发导致的开销。

上下文切换

  上下文切换指:如果可运行的线程数大于CPU 的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能 够使用CPU。这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并 将新调度进来的线程的执行上下文设置为当前上下文。
上下文切换的开销有:
  1、上下文切换需要访问操作系统和JVM共享的数据结构,上下文切换(在JVM和操作系统的代码中消耗越多的CPU时钟周期,应用程序的可用CPU时钟周期就越少)。
  2、它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢

  这就是为什么调度器会为每个可运行的线程分配一个最小执行时间,即使有许多 其他的线程正在等待执行: 它将上下文切换的开销分摊到更多不会中断的执行时间上,从而提嵩整体的吞吐量 (以损失响应性为代价)。
  当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起, 并允许它被交换出去。如果线程频繁地发生阻塞,那么它们将无法使用完整的调度时间片。在程序中发生越多的阻塞 (包括阻塞I/0, 等待获取发生竞争的锁,或者在条件变晕上等待),与CPU密集型的程序就会发生越多的上下文切换,从而增加调度开销,并因此而降低吞吐量。(无阻塞算法同样有助于减小上下文切换。请参见第15章。)
  上下文切换的实际开销会随着平台的不同而变化,然而按照经验来看:在大多数通用的处理器中,上下文切换的开销相当于 5000-10000个时钟周期,也就是几微秒。UNIX系统的vmstat命令和Windows系统的perfmon工具都能报告上下文切换次数以及在内核中执行时间所占比例等信息。如果内核占用率较高(超过10%), 那么通常表示调度活动发生得很频繁,这很可能是由I/0或竞争锁导致的阻塞引起的

内存同步

  在synchronized和volatile提供的可见性保证中会使用一些特殊指令,即内存栅栏(memory barrier),内存栅栏可以刷新缓存,满足可见性,但是它也会抑制一些编译器优化,例如不能指令重排序。注意此处的内存栅栏不同于第五章的同步工具类中的栅栏类。
  内存同步还分锁有竞争的同步和锁无竞争的同步,现代的JVM对于无竞争的synchronized的消耗非常小,基本微乎其微。一些不会发生竞争的锁(例如一个锁对象只能由当前线程访问),那么JVM会通过优化去掉这个锁操作。例如,下面的代码会去掉锁获取。

synchronized (new Object()) {
// do something
} 

  完备的JVM能通过(逸出分析)escape analysis会找出不会发布到堆上的本地对象,锁的获取和释放会被优化为最小的次数甚至去掉。例如,在getStoogeNames中,stooges 是局部变量,并且封闭在栈中,原本在执行过程中对Vector上的锁的获取/释放4次,但是智能的运行时编译器会分析这些调用,去掉4次对所获取操作。

public String getStoogeNames() {
    List<String> stooges = new Vector<String>();
    stooges.add("Moe");
    stooges.add("Larry");
    stooges.add("Curly");
    return stooges.toString();
}

  当然即使不逸出分析,也会有锁粒度粗化(lock coarsening)过程,将临近的同步代码块使用同一个锁合并起来。这都减少了同步的开销。所以不必过度担心非竞争同步带来的开销,这个基本的机制已经非常的快了,而且JVM还有能进行额外的优化以进一步降低或者消除开销的本领。例如getStoogeNames中,JVM进行锁粒度粗化,可能会把3个add调用结合起来,并对toString使用单独的锁请求和释放,在synchronized块的内部,利用启发式方法产生同步开销,而不是指令式方法。这不仅仅减少了同步的开销,同时也给予优化者更大的代码块,很可能成就了进一步的优化。
  不要过分担心非竞争的同步带来的开销。基础的机制已经足够快了,在这个基础上,JVM能够进行额外的优化,大大减少或消除了开销。关注那些真正发生了锁竞争的区域中性能的优化。
  不同线程间要进行一个线程中的同步也可能影响到其他线程的性能。同步造成了共享内存总线上的通信量;这个总线的带宽是有限的,所有的进程都共享这条总线。如果线程必须竞争同步带宽,所有使用到同步的线程都会受阻。synchronized以及volatile提供的可见性都会导致缓存失效。线程栈之间的数据要和主存进行同步,这些同步有一些小小的开销

JVM优化同步的方式总结:
1、JVM优化去掉不会发生竞争的锁
2、JVM会找出不需同步的本地栈元素
3、锁粒度粗化,将近邻的锁合并,减少锁请求和锁释放的次数

阻塞

  非竞争的同步可以由JVM完全掌控(Bacon 等,1998);而竞争的同步可能需要OS的活动,这会增大开销。当锁为竞争性的时候,失败的线程(一个或多个)必然发生阻塞。JVM既能自旋等待(spin-waiting,不断尝试获取锁,直到成功),或者在操作系统中挂起(suspending)这个被阻塞的线程。哪一个效率更高,取决于上下文切换的开销,以及成功地获取锁需要等待的时间这两者之间的关系。自旋等待更适合短期的等待,而挂起适合长时间等待。有一些JVM基于过去等待时间的数据剖析来在这两者之间进行选择,但是大多数等待锁的线程都是被挂起的。
  需要挂起线程可能因为线程无法得到锁,或者因为它正在等待某个条件,亦或被I/O操作阻塞。挂起需要两次额外的上下文切换,以及OS和缓存的相关活动,阻塞的线程在它时间限额还没有到期前就被换出,稍后如果能够获得锁或者其等待的资源,又会再被换入。(阻塞归因于锁的竞争,线程持有这样的锁:当它释放该锁的时候,它必须通知OS,重新开始因该锁而阻塞的线程。)

通过高效使用锁提升性能

  区分竞争锁和非竞争锁对性能的影响非常重要。如果一个锁自始至终只被一个线程使用,那么 JVM 有能力优化它带来的绝大部分损耗。如果一个锁被多个线程使用过,但是在任意时刻,都只有一个线程尝试获取锁,那么它的开销要大一些。我们将以上两种锁称为非竞争锁。而对性能影响最严重的情况出现在多个线程同时尝试获取锁时。这种情况是 JVM 无法优化的,而且通常会发生从用户态到内核态的切换。现代 JVM 已对非竞争锁做了很多优化,使它几乎不会对性能造成影响。常见的优化有以下几种。
  如果一个锁对象只能由当前线程访问,那么其他线程无法获得该锁并发生同步 , 因此 JVM 可以去除对这个锁的请求。
  逸出分析 (escape analysis) 可以识别本地对象的引用是否在堆中被暴露。如果没有,就可以将本地对象的引用变为线程本地的 (thread local) 。
  编译器还可以进行锁的粗化 (lock coarsening) 。把邻近的 synchronized 块用相同的锁合并起来,以减少不必要的锁的获取和释放。
  因此,不要过分担心非竞争锁带来的开销,要关注那些真正发生了锁竞争的临界区中性能的优化。
  很多开发人员因为担心同步带来的性能损失,而尽量减少锁的使用,甚至对某些看似发生错误概率极低的临界区不使用锁保护。这样做往往不会带来性能提高,还会引入难以调试的错误。因为这些错误通常发生的概率极低,而且难以重现。
  因此,**在保证程序正确性的前提下,解决同步带来的性能损失的第一步不是去除锁,而是降低锁的竞争。**通常,有以下三类方法可以降低锁的竞争:减少持有锁的时间,降低请求锁的频率,或者用其他协调机制取代独占锁。

缩小锁的范围(快进快出)

缩小锁的范围可以有效的降低竞争的可能性。
  目标:尽可能缩短持有锁的时间
  方法:只对操作共享变量的代码加锁,将一些与锁无关的代码移出同步代码块
  理论:根据Amdahl定律,减少了必须串行执行的部分
  注意:经管缩小同步代码块能提高可伸缩性,但是同步代码块不能太小。当一个同步代码块分解成多个同步代码块时,如果JVM执行锁粒度粗化操作,那么可能会将分解的同步块又重新合并起来。

@ThreadSafe
   public class AttributeStore{
   	@GuardedBy("this") private final Map<String,String> attributes = new HashMap<String,String>();
   	public synchronized boolean userLocationMatches(String name,String regexp){
   			String key = "users"+name+"loaction";
   			String location = attributes.get(key);
   			if(location == null)
   					return false;
   			else
   					return Pattern.matches(regexp,location);
   	}
   }

  例如AttributeStore例子,将整个方法都同步起来,完全不考虑适当的缩减锁的作用范围,是新人常犯的问题。根据Amdahl定律,串行代码总量减少,可以提升可伸缩性。试想,如果userLocationMatches的操作如果持有锁时间为2毫秒,那么吞吐量不会超过每秒500个操作,缩小同步代码块后,若持有锁时间为1毫秒,那么这个锁对应的吞吐量会提高到每秒1000个操作。

@ThreadSafe
	public class AttributeStore{
		@GuardedBy("this") private final Map<String,String> attributes = new HashMap<String,String>();
		public boolean userLocationMatches(String name,String regexp){
				String key = "users"+name+"loaction";
				String location ;
				synchronized(this)
				{
					location = attributes.get(key);
				}
				if(loacation == null)
					return false;
				else
					return Pattern.matches(regexp,location);
		}
	}

减小锁的粒度

  减少锁粒度包括锁分解和锁分段技术实现。
  锁分解:如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。
  对竞争适中的锁进行分解时,实际上是把这些锁转变为”非竞争“的锁,从而有效地提高性能和可伸缩性。例如,

@ThreadSafe
   public class ServerStatus{
   	@GuardedBy("this") public final Set<String> users;
   	@GuardedBy("this") public final Set<String> queries;
   	public synchronized void addUser(String u){ users.add(u);}
   	public synchronized void addQuery(String q){queries.add(q);}
   }

ServerStatus可以通过ServerStatus锁更改成users、queries独立状态变量锁。

@ThreadSafe
   public class ServerStatus{
   	@GuardedBy("this") public final Set<String> users;
   	@GuardedBy("this") public final Set<String> queries;
   	public void addUser(String u){
   	synchronized(users){
   		 users.add(u);
   		 }
   	}
   	public void addQuery(String q){
   	synchorized(queries)
   	{
   		queries.add(q);}
   	}
   }

锁分段

  即使锁分解能提高一部分可伸缩性,但是在性能和吞吐量最大限度的提升还是有限。锁分段是将所分解技术进一步扩展成一组独立对象上的锁。例如ConcurrentHashMap的实现通过一个包含 16个锁的数组,每个锁保护散列桶的1/16,第N个散列桶由第N个锁来保护。锁分段技术可以使ConcurrentHashMap能支持多达16个并发的写入器。
  锁分段劣势:与采用单个锁来实现独占访问相比,获取多个锁来实现独占访问更加困难并且开销更高。

避免热点域

热点域:某个锁由保护的数据缺被很多常用操作访问(例如Map的size)
解决:ConcurrentHashMap为每个分段都维护一个独立的size计数,并通过每个分段的锁来维护总size

  当每一个操作都请求多个变量的时候,锁的粒度很难被降低。这是性能和可伸缩性相互牵制的另一个方面。通常使用的优化方法,比如缓存常用的计算结果,会引入“热点域(hot fields)”,从而限制可伸缩性。
  如果由你来实现HashMap,你会遇到一个选择:size方法如何计算Map条目的大小?最简单的方法是每次调用的时候数一遍。通常使用的优化方法是在插入和移除的时候更新一个单独的计数器;这会给put和remove方法造成很小的开销,以保证计数器的更新,但是,这会减少size方法的开销,从O(n)减至O(1)。
在单线程或完全同步的实现中,保存一个独立的计数能够很好地提高类似size和isEmpty这样的方法的速度,但是却使改进可伸缩性变得更难了,因为每一个修改map的操作都要更新这个共享的计数器。即使你对每一个哈希链(hash chain)都使用了锁的分离,对计数器独占锁的同步访问还是重新引入了可伸缩性问题。这看起来像是一个性能的优化——缓存size操作的结果——却已经转化为一个可伸缩性问题。这种情况下,计数器被称为热点域(hot field),因为每个变化操作都要访问它。
  为避免这个问题,ConcurrentHashMap中的size将对每个分段进行枚举并将每个分段中的元素数量相加,而不是维护一个全局计数。为了避免列举所有元素,ConcurrentHashMap为每一个条目维护一个独立的计数域,同样由分段的锁来维护这个值。

放弃独占锁

ReadWriteLock:如果多个读取操作都不会修改共享资源,那么这些读取操作可以同时访问该共享资源,但在执行写入操作时必须以独占方式来获取锁
  原子变量:降低更新“热点域”时的开销,例如竞态计数器、序列发生器、或者对链表数据结构中头节点的引用。原子变量类提供了在整数或者对象引用上的细粒度原子操作(因此可伸缩性更高),并使用了现代处理器中提供的底层并发原语(例如比较并交换)。

Map的性能比较

  单线程化的ConcurrentHashMap的性能要比同步的HashMap的性能稍好一些,而且在并发应用中,这种作用就十分明显了。ConcurrentHashMap的实现,假定大多数常用的操作都是获取已存在的某个值,因此它的优化是针对get操作,提供最好的性能和并发性。
  同步的Map实现中,可伸缩性最主要的阻碍在于整个Map存在一个锁,所以一次只有一个线程能够访问map,从另一方面来看,ConcurrentHashMap并没有对大多数的读操作加锁,而对写操作和真正需要锁的读操作使用了分段锁的技术。因此,多线程能够并发访问Map,而不被阻塞。
  同步容器的数量并不是越多越好。单线程的情况与ConcurrentHashMap一致,但是一旦负载由多数为非竞争的情况变成多数为竞争性的情况——这里是两个线程——同步的容器就会很糟糕。这在锁竞争的代码行为中是很常见的。只要竞争小,每个操作所花费的时间取决于真正工作的时间,吞吐量会因为线程数的增加而增加。一旦竞争变得激烈,每个操作花费的时间就由上下文切换和调度延迟决定了,并且加入更多的线程不会对吞吐量有什么帮助。

猜你喜欢

转载自blog.csdn.net/weixin_41262453/article/details/86752781
今日推荐