性能调优
# 1 锁优化
# 1.1 Synchronized优化
synchronized使用起来非常简单,但是需要注意的是synchronized加锁的是什么维度
对象级别:
public synchronized void test(){
// TODO
}
public void test(){
synchronized (this) {
// TODO
}
}
类级别:
public static synchronized void test(){
// TODO
}
public void test(){
synchronized (TestSynchronized.class) {
// TODO
}
}
两个的区别:
对象级别:
BadSync sync = new BadSync();
锁的是sync
类级别:
锁的是TestSynchronized.class,不管你new多少个BadSync,都是一把锁
如下案例理解
案例:互斥锁用不好可能会失效,看一个典型的锁不住现
package com.itheima;
public class ObjectLock {
public static Integer i=0;
public void inc(){
synchronized (this){
int j=i;
try {
Thread.sleep(100);
j++;
} catch (InterruptedException e) {
e.printStackTrace();
}
i=j;
}
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
public void run() {
//重点!
new ObjectLock().inc();
}
}).start();
}
Thread.sleep(3000);
//理论上10才对。可是....
System.out.println(ObjectLock.i);
}
}
结果分析:每个线程内都是new对象,所以this不是同一把锁,结果锁不住,输出1
this,换成static的 i 变量试试?
换成ObjectLock.class 试试? 可以
换成String.class 可以
去掉synchronized块,外部方法上加 static synchronized
案例:看一个加锁粒度的案例
package com.itheima.thread.opt;
import java.util.concurrent.atomic.AtomicLong;
public class BadSync implements Runnable{
long start = System.currentTimeMillis();
AtomicLong atomicLong = new AtomicLong(0);
volatile int i=0;
public void inc(){
i++;
}
@Override
public synchronized void run() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
inc();
atomicLong.getAndAdd(System.currentTimeMillis() ‐ start);
}
public static void main(String[] args) throws InterruptedException {
BadSync sync = new BadSync();
for (int i = 0; i < 5; i++) {
new Thread(sync).start();
}
Thread.sleep(3000);
System.out.println("最终计数:i="+ sync.i);
System.out.println("最终耗时:time="+sync.atomicLong.get());
}
}
- 看一下最后的结果和耗时
最终计数:i=5
最终耗时:time=1519
- 将synchronized换到inc方法上,再试试最后的结果和耗时
最终计数:i=5
最终耗时:time=555
为什么上面的这个计数没有混乱,原因是因为对象实例是在多线程之前实例的,所以都是用的同一个对象,所以锁可以锁住。
结论是什么?
# 1.2 Lock 锁优化
看一个小需求:电商系统中记录首页被用户浏览的次数,以及最后一次操作的时间(含读或写)。
package com.itheima;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantLock;
public class TotalLock {
//类创建的时间
final long start = System.currentTimeMillis();
//总耗时
AtomicLong totalTime = new AtomicLong(0);
//缓存变量
private Map<String,Long> map = new HashMap(){{put("count",0L);}};
ReentrantLock lock = new ReentrantLock();
//查看map被写入了多少次
public Map read(){
lock.lock();
try {
Thread.currentThread().sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
//最后操作完成的时间
map.put("time",end);
lock.unlock();
System.out.println(Thread.currentThread().getName()+",read="+(end‐start));
totalTime.addAndGet(end ‐ start);
return map;
}
//写入
public Map write(){
lock.lock();
try {
Thread.currentThread().sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
//写入计数
map.put("count",map.get("count")+1);
long end = System.currentTimeMillis();
map.put("time",end);
lock.unlock();
System.out.println(Thread.currentThread().getName()+",write="+(end‐start));
totalTime.addAndGet(end ‐ start);
return map;
}
public static void main(String[] args) throws InterruptedException {
TotalLock count = new TotalLock();
//读
for (int i = 0; i < 4; i++) {
new Thread(()‐>{
count.read();
}).start();
}
//写
for (int i = 0; i < 1; i++) {
new Thread(()‐>{
count.write();
}).start();
}
Thread.sleep(3000);
System.out.println(count.map);
System.out.println("读写总共耗时:"+count.totalTime.get());
}
}
结果
Thread-0,read=332
Thread-1,read=436
Thread-2,read=559
Thread-3,read=660
Thread-4,read=760
{count=1,time=1596025770578}
读写总共耗时:2747
仔细看读的时间变化和执行的总时间,思考一下,从业务和技术角度有没有可优化空间?
仔细分析业务:查看次数这里其实是可以并行读取的,我们关注的业务是写入次数,也就是count,至于读取发生的时间time的写入操作,只是一个单步put,每次覆盖,不需要原子性保障,对这个加互斥锁没有必要。
改成读写锁试试......
读写锁,读读是并行的,写是互斥的,当写的时候会被保护起来,让读进行阻塞。
package com.itheima;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadAndWrite {
//类创建的时间
final long start = System.currentTimeMillis();
//总耗时
AtomicLong totalTime = new AtomicLong(0);
//缓存变量,注意!因为read并发,这里换成ConcurrentHashMap
private Map<String,Long> map = new ConcurrentHashMap(){{put("count",0L);}};
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
//查看map被写入了多少次
public Map read(){
lock.readLock().lock();
try {
Thread.currentThread().sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
//最后操作完成的时间
map.put("time",end);
lock.readLock().unlock();
System.out.println(Thread.currentThread().getName()+",read="+(end‐start));
totalTime.addAndGet(end ‐ start);
return map;
}
//写入
public Map write(){
lock.writeLock().lock();
try {
Thread.currentThread().sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
//写入计数
map.put("count",map.get("count")+1);
long end = System.currentTimeMillis();
map.put("time",end);
lock.writeLock().unlock();
System.out.println(Thread.currentThread().getName()+",write="+(end‐start));
totalTime.addAndGet(end ‐ start);
return map;
}
public static void main(String[] args) throws InterruptedException {
ReadAndWrite rw = new ReadAndWrite();
//读
for (int i = 0; i < 4; i++) {
new Thread(()‐>{
rw.read();
}).start();
}
//写
for (int i = 0; i < 1; i++) {
new Thread(()‐>{
rw.write();
}).start();
}
Thread.sleep(3000);
System.out.println(rw.map);
System.out.println("读写总共耗时:"+rw.totalTime.get());
}
}
再来看读的时间变化和总执行时间。
结果
Thread-0,read=269
Thread-1,read=370
Thread-2,read=470
Thread-3,read=571
Thread-4,read=671
{count=1,time=1596026177303}
读写总共耗时:2351
当read远大于write时,这个差距会更明显(改成9:1试试……),测试差了一倍
# 1.3 CAS乐观锁优化
回顾上面的计数器,我们用synchronized实现了准确计数,本节我们看执行时间,追究性能问题。
# 1.3.1 案例一:直接加synchronized锁
package com.itheima;
public class NormalSync implements Runnable{
Long start = System.currentTimeMillis();
int i=0;
public synchronized void run() {
int j = i;
//实际业务中可能会有一堆的耗时操作,这里等待100ms模拟
try {
//做一系列操作
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
//业务结束后,增加计数
i = j+1;
System.out.println(Thread.currentThread().getId()+
" ok,time="+(System.currentTimeMillis() ‐ start));
}
public static void main(String[] args) throws InterruptedException {
NormalSync test = new NormalSync();
new Thread(test).start();
new Thread(test).start();
Thread.currentThread().sleep(1000);
System.out.println("last value="+test.i);
}
}
线程二最终耗时会在200ms+,总耗时300ms,原因是悲观锁卡在了read后的耗时操作上,但是保证了最终结果是 2
11 ok,time=102
12 ok,time=203
last value=2
# 1.3.2 案例二:基于CAS思想,compare再set
package com.itheima;
import sun.misc.Unsafe;
import java.lang.reflect.Field;
public class CasSync implements Runnable{
long start = System.currentTimeMillis();
int i=0;
public void run() {
int j = i;
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
//CAS处理,在这里理解思想,实际中不推荐大家使用!
try {
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
Unsafe unsafe = (Unsafe) f.get(null);
long offset = unsafe.objectFieldOffset(CasSync.class.getDeclaredField("i"));
while (!unsafe.compareAndSwapInt(this,offset,j,j+1)){
j = i;
}
} catch (Exception e) {
e.printStackTrace();
}
//实际开发中,要用atomic包,或者while+synchronized自旋
// synchronized (this){
// //注意这里!
// while (j != i){
// j = i;
// }
// i = j+1;
// }
System.out.println(Thread.currentThread().getName()+
" ok,time="+(System.currentTimeMillis() ‐ start));
}
public static void main(String[] args) throws InterruptedException {
CasSync test = new CasSync();
new Thread(test).start();
new Thread(test).start();
Thread.currentThread().sleep(1000);
System.out.println("last value="+test.i);
}
}
线程一、二均在100ms+,总耗时200ms,最终结果还是2
# 1.4 一些经验
# 减少锁的时间
不需要同步执行的代码,能不放在同步快里面执行就不要放在同步快内,可以让锁尽快释放
# 减少锁的粒度
将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争,典型如分段锁
# 锁的粒度
拆锁的粒度不能无限拆,最多可以将一个锁拆为当前cup数量相等
# 减少加减锁的次数
假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都要加锁
# 使用读写锁
业务细分,读操作加读锁,可以并发读,写操作使用写锁,只能单线程写,参考计数器案例
# 善用volatile
volatile的控制比synchronized更轻量化,在某些变量上可以加以运用,如单例模式中
# 2 线程池参数调优
# 2.1 代码调试
- 创建线程池,无限循环添加task,debug看works和queue数量增长规律
- 等待一段时间后,查看works数量是否回落到core
public class ExecutorTest {
public static void main(String[] args) {
BlockingQueue queue = new ArrayBlockingQueue(2);
ThreadPoolExecutor executor = new ThreadPoolExecutor(
3,
5,
20,
TimeUnit.SECONDS,
queue,
new RejectedExecutionHandler() {
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
System.out.println("reject");
}
}
);
for (;;){
executor.execute(new Runnable() {
public void run() {
try {
Thread.currentThread().sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
}
}
}
# 2.2 Executors剖析
Executors只是一个工具类,协助你创建线程池。Executors对特定场景下做了参数调优。
# 2.2.1 newCachedThreadPool
//core=0
//max=Integer
//timeout=60s
//queue=1
//也就是只要线程不够用,就一直开,不用就全部释放。线程数0‐max之间弹性伸缩
//注意:任务并发太高且耗时较长时,造成cpu高消耗,同时要警惕OOM
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
# 2.2.2 newFixedThreadPool
//core=max=指定数量
//timeout=0
//queue=无界链表
//也就是说,线程数一直保持制定数量,不增不减,永不超时
//如果不够用,就沿着队列一直追加上去,排队等候
//注意:并发太高时,容易造成长时间等待无响应,如果任务临时变量数据过多,容易OOM
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(),
threadFactory);
# 2.2.3 newSingleThreadExecutor
//core=max=1
//timeout=0
//queue=无界链表
//只有一个线程在慢吞吞的干活,可以认为是fix的特例
//适用于任务零散提交,不紧急的情况
new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
# 2.2.4 newScheduledThreadPool
//core=制定数
//max=Integer
//timeout=0
//queue=DelayedWorkQueue(重点!)
//用于任务调度,DelayedWorkQueue限制住了任务可被获取的时机(getTask方法),也就实现了时间控制
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue(), threadFactory);
# 2.3 一些经验
# 2.3.1 corePoolSize
基本线程数,一旦有任务进来,在core范围内会立刻创建线程进入工作。所以这个值应该参考业务并发量在绝大多数时间内的并发情况。同时分析任务的特性。
高并发,执行时间短的,要尽可能小的线程数,如配置CPU个数+1,减少线程上下文的切换。因为它不怎么占时间,让少量线程快跑干活。
并发不高、任务执行时间长的要分开看:如果时间都花在了IO上(写磁盘),那就调大CPU,如配置两倍CPU个数+1。不能让CPU闲下来,线程多了并行处理更快。如果时间都花在了运算上,运算的任务还很重,本身就很占cpu,那尽量减少cpu,减少切换时间。参考第一条
如果高并发,执行时间还很长……
# 2.3.2 workQueue
任务队列,用于传输和保存等待执行任务的阻塞队列。这个需要根据你的业务可接受的等待时间。是一个需要权衡时间还是空间的地方,如果你的机器cpu资源紧张,jvm内存够大,同时任务又不是那么紧迫,减少coresize,加大这里。如果你的cpu不是问题,对内存比较敏感比较害怕内存溢出,同时任务又要求快点响应。那么减少这里。
# 2.3.3 maximumPoolSize
线程池最大数量,这个值和队列要搭配使用,如果你采用了无界队列,那很大程度上,这个参数没有意义。同时要注意,队列盛满,同时达到max的时候,再来的任务可能会丢失(下面的handler会讲)。
如果你的任务波动较大,同时对任务波峰来的时候,实时性要求比较高。也就是来的很突然并且都是着急的。那么调小队列,加大这里。如果你的任务不那么着急,可以慢慢做,那就扔队列吧。
队列与max是一个权衡。队列空间换时间,多花内存少占cpu,轻视任务紧迫度。max舍得cpu线程开销,少占内存,给任务最快的响应。
#
# 2.3.4 keepaliveTime
线程存活保持时间,超出该时间后,线程会从max下降到core,很明显,这个决定了你养闲人所花的代价。如果你不缺cpu,同时任务来的时间没法琢磨,波峰波谷的间隔比较短。经常性的来一波。那么实当的延长销毁时间,避免频繁创建和销毁线程带来的开销。如果你的任务波峰出现后,很长一段时间不再出现,间隔比较久,那么要适当调小该值,让闲着不干活的线程尽快销毁,不要占据资源。
# 2.3.5 threadFactory(自定义展示实例)
线程工厂,用于创建新线程。threadFactory创建的线程也是采用new Thread()方式,threadFactory创建的线程名都具有统一的风格:pool-m-thread-n(m为线程池的编号,n为线程池内的线程编号)。如果需要自己定义线程的某些属性,如个性化的线程名,可以在这里动手。一般不需要折腾它。
# 2.3.6 handler
线程饱和策略,当线程池和队列都满了,再加入线程会执行此策略。默认不处理的话会扔出异常,打进日志。这个与任务处理的数据重要程度有关。如果数据是可丢弃的,那不需要额外处理。如果数据极其重要,那需要在这里采取措施防止数据丢失,如扔消息队列或者至少详细打入日志文件可追踪。
# 3 协程
# 3.1 概念
面试官:你知道协程吗?
你:知道,出差或旅游经常用。
面试官:……
很大一部分的程序员不知道协程是啥,项目中也没用到协程。
先看概念:计算机有进程,线程和协程。前两者大家都很熟。而协程,则是基于线程之上自主开辟的异步任务。
- 线程的切换由操作系统负责调度,协程由用户自己进行调度
- 线程的默认Stack大小是1M,而协程更轻量,接近1K。因此可以在相同的内存中开启更多的协程。
- 多个协程在同一个线程上,因此不必使用锁,也减少了上下文切换。
再说结论:
- 一般需要使用第三方框架来实现
- java里相对非主流,go和python相对用的多
- 实际web开发中用的较少,还是要把主要精力放到线程上
# 3.2 使用方式
使用Kilim框架看协程与线程的编码
解压kilim.zip包,导入工程,按说明执行
package com.itheima.thread;
import kilim.Mailbox;
import kilim.Pausable;
import kilim.Task;
public class SimpleTask extends Task {
static Mailbox<String> mb = new Mailbox<String>();
/**
* 1.mvn compile
* 2.java 命令运行,注意分隔符,linux为:,windows为 ;
*
* linux下执行:
* java -cp ./lib/kilim.jar:./target/classes:$CLASSPATH com.itheima.thread.SimpleTask
*
* winows下执行:
* java -cp ./lib/kilim.jar;./target/classes;$CLASSPATH com.itheima.thread.SimpleTask
*/
public static void main(String[] args) throws Exception {
new SimpleTask().start();
Thread.sleep(10);
mb.putnb("Hello ");
mb.putnb("World \n");
mb.putnb("done");
}
public void execute() throws Pausable {
while (true) {
String s = mb.get();
if (s.equals("done")) break;
System.out.print(s);
}
System.exit(0);
}
}
# 4 并发容器选择
# 4.1 案例一:电商网站中记录一次活动下各个商品售卖的数量。
- **场景分析:**需要频繁按商品id做get和set,但是商品id(key)的数量相对稳定不会频繁增删
- **初级方案:**选用HashMap,key为商品id,value为商品购买的次数。每次下单取出次数,增加后再写入
- **问题:**HashMap线程不安全!在多次商品id写入后,如果发生扩容,在 JDK1.7 之前,在并发场景下HashMap 会出现死循环,从而导致 CPU 使用率居高不下。JDK1.8 中修复了 HashMap 扩容导致的死循环问题,但在高并发场景下,依然会有数据丢失以及不准确的情况出现。
- **选型:**Hashtable 不推荐,锁太重,选 ConcurrentHashMap 确保高并发下多线程的安全性
# 4.2 案例二:在一次活动下,为每个用户记录浏览商品的历史和次数。
- **场景分析:**每个用户各自浏览的商品量级非常大,并且每次访问都要更新次数,频繁读写
- **初级方案:**为确保线程安全,采用上面的思路,ConcurrentHashMap
- **问题:**ConcurrentHashMap 内部机制在数据量大时,会把链表转换为红黑树。而红黑树在高并发情况下,删除和插入过程中有个平衡的过程,会牵涉到大量节点,因此竞争锁资源的代价相对比较高
- **选型:**用跳表,ConcurrentSkipListMap将key值分层,逐个切段,增删效率高于ConcurrentHashMap
**结论:**如果对数据有强一致要求,则需使用 Hashtable;在大部分场景通常都是弱一致性的情况下,使用ConcurrentHashMap 即可;如果数据量级很高,且存在大量增删改操作,则可以考虑使用ConcurrentSkipListMap。
# 4.3 案例三:在活动中,创建一个用户列表,记录冻结的用户。一旦冻结,不允许再下单抢购,但是可以浏览。
- **场景分析:**违规被冻结的用户不会太多,但是绝大多数非冻结用户每次抢单都要去查一下这个列表。低频写,高频读。
- **初级方案:**ArrayList记录要冻结的用户id
- **问题:**ArrayList对冻结用户id的插入和读取操作在高并发时,线程不安全。Vector可以做到线程安全,但并发性能差,锁太重。
- 选型:综合业务场景,选CopyOnWriteArrayList,会占空间,但是也仅仅发生在添加新冻结用户的时候。绝大多数的访问在非冻结用户的读取和比对上,不会阻塞。
# 5 上下文切换优化
# 5.1 基本操作
CPU通过时间片分配算法来循环执行任务,时间片一般是几十毫秒(ms)。切换就要保存旧状态,完成恢复时就要读取存储的内容。这个操作过程就是上下文的切换。
(现实例子:参考日常需求开发与应急bug处理开发任务被挂起的场景 -_-! )
# 5.2 竞争锁
1)锁的持有时间越长,就意味着有越多的线程在等待该竞争资源释放。上下文的切换代价就越多。
2)将锁贴近需要加锁的地方,越近越好!在synchronized性能优化中有案例
public void f(){
synchronized (this){
f1();
f2();
f3();
}
}
优化后
public void f(){
f1();
synchronized (this){
f2();
}
f3();
}
3)结论:类锁 < 静态锁 < 方法锁 < 代码块锁 , 能共享锁的地方尽量不要用独享锁
# 5.3 wait/notify
# 5.3.1 过时通知
看一个典型错误,猜一猜结果……
package com.itheima;
public class WaitInvalid {
volatile int total = 0;
byte[] lock = new byte[0];
//计算1‐100的和,算完后通知print
public void count(){
synchronized (lock){
for (int i = 1; i < 101; i++) {
total += i;
}
lock.notify();
}
System.out.println("count finish");
}
//打印,等候count的通知
public void print(){
synchronized (lock){
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(total);
}
public static void main(String[] args) {
WaitInvalid waitInvalid = new WaitInvalid();
new Thread(()‐>{
waitInvalid.count();
}).start();
new Thread(()‐>{
waitInvalid.print();
}).start();
}
}
将count和wait的顺序交换,再看一下结果,思考一下为什么??
分析:
count先执行时,提前释放了notify通知,这时候,print还没进入wait,收不到这个信号。
等print去wait的时候(阻塞),再等通知等不到了,典型的通知过时现象。
仅仅因为一行代码的顺序问题,如果不注意,造成整个程序卡死
# 5.3.2 额外唤醒
跑一下看看结果……
package com.itheima;
import java.util.ArrayList;
import java.util.List;
public class NotifyInvalid {
List list = new ArrayList();
byte[] lock = new byte[0];
public void del() {
synchronized (lock){
//没值就等,有值就删
if (list.isEmpty()){
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 二次判断
// if (list.isEmpty()){
list.remove(0);
// }
}
}
public void add(){
synchronized (lock){
//加个值后唤醒
list.add(0,0);
lock.notifyAll();
}
}
public static void main(String[] args) throws InterruptedException {
NotifyInvalid notifyInvalid = new NotifyInvalid();
//启动两个线程等候删除
for (int i = 0; i < 2; i++) {
new Thread(()‐>{
notifyInvalid.del();
}).start();
}
//新线程添加一个
new Thread(()‐>{
notifyInvalid.add();
}).start();
Thread.sleep(1000);
System.out.println(notifyInvalid.list.size());
}
}
分析:
出异常了!因为等候的两个线程第一个删除后,第二个唤醒时,等待前的状态已失效。
方案:
线程唤醒后,要警惕睡眠前后状态不一致,要二次判断
# 5.4 线程池
1)线程池的线程数量设置不宜过大,因为一旦线程池的工作线程总数超过系统所拥有的处理器数量,就会导致过多的上下文切换。
2)慎用Executors,尤其如newCachedThreadPool。这个方法前面分析过。如果任务过多会无休止创建过多线程,增加了上下文的切换。最好根据业务情况,自己创建线程池参数。
# 5.5 虚拟机
1)很多 JVM 垃圾回收器(serial 收集器、ParNew 收集器)在回收旧对象时,会产生内存碎片
2)碎片内存整理中就需要移动存活的对象。而移动内存对象就意味着这些对象所在的内存地址会发生变化
3)内存地址变化就要去移动对象前暂停线程,在移动完成后需要再次唤醒。无形中增加了上下文的切换
4)结论:合理搭配JVM内存调优,减少 JVM 垃圾回收的频率可以有效地减少上下文切换
# 5.6 协程
1)协程不需要切换上下文,更轻量化。
2)平时用的相对较少。用不好会出问题。