synchronized使用起来非常简单,但是需要注意的是synchronized加锁的是什么维度。
对象级别:
public synchronized void test() {
// TODO
}
public void test() {
synchronized (this) {
// TODO
}
}
类级别:
public synchronized static void test1() {
// TODO
}
public static void test2() {
synchronized (SynchronizedOpt.class) {
// TODO
}
}
案例:看一个加锁粒度过粗的案例
public class BadSync implements Runnable {
volatile long totalTime = 0;
long start = System.currentTimeMillis();
int i = 0;
public void inc() {
i++;
}
@Override
public synchronized void run() {
try {
//读取数据库,一堆耗时操作
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
//更新计数器
inc();
totalTime += (System.currentTimeMillis() - start);
}
public static void main(String[] args) throws InterruptedException {
BadSync badSync = new BadSync();
for (int i = 0; i < 5; i++) {
new Thread(badSync).start();
}
Thread.sleep(3000);
System.out.println("计数器:" + badSync.i);
System.out.println("最后耗时:" + badSync.totalTime);
}
}
改进方案:
synchronized关键字加在inc()方法上
看一个小需求:电商系统中记录首页被用户浏览的次数,以及最后一次操作的时间(含读或写)。
优化前:
public class TotalLock {
// 类启动时间
private final Long start = System.currentTimeMillis();
//总耗时
private AtomicLong totalTime = new AtomicLong(0L);
// 缓存变量
private Map<String,Long> map = new HashMap(){{put("count",0L);}};
private ReentrantLock lock = new ReentrantLock();
// 查看写入map次数
public Map read() {
lock.lock();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
// 最后操作完成时间
map.put("timed", 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.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
map.put("count",map.get("count")+1);
long end = System.currentTimeMillis();
// 最后操作完成时间
map.put("timed", 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 totalLock = new TotalLock();
for (int i = 0; i < 4; i++) {
new Thread(()->{
totalLock.read();
}).start();
}
new Thread(()->totalLock.write()).start();
Thread.sleep(3000);
System.out.println(totalLock.map);
System.out.println("读写总共耗时:"+totalLock.totalTime.get());
}
}
输出:
Thread-1,read=215
Thread-0,read=315
Thread-4,write=415
Thread-2,read=515
Thread-3,read=615
{timed=1596676528655, count=1}
读写总共耗时:2075
查看次数这里其实是可以并行读取的,我们关注的业务是写入次数,也就是count,至于读取发生的时间time的写入操作,只是一个put,不需要原子性保障,对这个加互斥锁没有必要。
优化:改成读写锁
public class TotalLock {
// 类启动时间
private final Long start = System.currentTimeMillis();
//总耗时
private AtomicLong totalTime = new AtomicLong(0L);
//缓存变量,注意!因为read并发,这里换成ConcurrentHashMap
private Map<String,Long> map = new ConcurrentHashMap(){{put("count",0L);}};
// 读写锁
private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
// 查看写入map次数
public Map read() {
// 读锁,共享锁
lock.readLock().lock();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
// 最后操作完成时间
// 使用共享锁,需要使用ConcurrentHashMap保证put操作的安全性
map.put("timed", 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.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
map.put("count",map.get("count")+1);
long end = System.currentTimeMillis();
// 最后操作完成时间
map.put("timed", 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 {
TotalLock totalLock = new TotalLock();
new Thread(()->totalLock.write()).start();
for (int i = 0; i < 4; i++) {
new Thread(()->{
totalLock.read();
}).start();
}
Thread.sleep(3000);
System.out.println(totalLock.map);
System.out.println("读写总共耗时:"+totalLock.totalTime.get());
}
}
输出:
Thread-0,write=209
Thread-4,read=309
Thread-3,read=309
Thread-2,read=309
Thread-1,read=309
{timed=1596677561973, count=1}
读写总共耗时:1445
读操作并行,当read远大于write时,优化效果更明显。
Executors只是一个工具类,协助你创建线程池。Executors对特定场景下做了参数调优。
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());
2)newFixedThreadPool
//core=max=指定数量
//timeout=0
//queue=无界链表
//也就是说,线程数一直保持制定数量,不增不减,用不超时
//如果不够用,就沿着队列一直追加上去,排队等候
//注意:并发太高时,容易造成长时间等待无响应,如果任务临时变量数据过多,容易OOM
return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue(),threadFactory);
3)newSingleThreadExecutor
//core=max=1
//timeout=0
//queue=无界链表
//只有一个线程在慢吞吞的干活,可以认为是fix的特例
//适用于任务零散提交,不紧急的情况
new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue()));
4)newScheduledThreadPool
//core=制定数
//max=Integer
//timeout=0
//queue=DelayedWorkQueue(重点!)
//用于任务调度,DelayedWorkQueue限制住了任务可被获取的时机(getTask方法),也就实现了时间控制
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue(),threadFactory);
1)corePoolSize
基本线程数,一旦有任务进来,在core范围内会立刻创建线程进入工作。所以这个值应该参考业务并发量在绝大多数时间内的并发情况。同时分析任务的特性。
高并发,执行时间短的,要尽可能小的线程数,如配置CPU个数+1,减少线程上下文的切换。因为它不怎么占时间,让少量线程快跑干活。
并发不高、任务执行时间长的要分开看:如果时间都花在了IO上,那就调大CPU,如配置两倍CPU个数+1。不能让CPU闲下来,线程多了并行处理更快。如果时间都花在了运算上,运算的任务还很重,本身
就很占cpu,那尽量减少cpu,减少切换时间。
2) workQueue
任务队列,用于传输和保存等待执行任务的阻塞队列。这个需要根据你的业务可接受的等待时间。是一个需要权衡时间还是空间的地方,如果你的机器cpu资源紧张,jvm内存够大,同时任务又不是那么紧迫,减少coresize,加大这里。如果你的cpu不是问题,对内存比较敏感比较害怕内存溢出,同时任务又要求快点响应,那么减少这里。
3) maximumPoolSize
线程池最大数量,这个值和队列要搭配使用,如果你采用了无界队列,那很大程度上,这个参数没有意义。同时要注意,队列盛满,同时达到max的时候,再来的任务可能会丢失(下面的handler会讲)。如果你的任务波动较大,同时对任务波峰来的时候,实时性要求比较高。也就是来的很突然并且都是着急的。那么调小队列,加大这里。如果你的任务不那么着急,可以慢慢做,那就扔队列吧。
队列与max是一个权衡。队列空间换时间,多花内存少占cpu,轻视任务紧迫度。max舍得cpu线程开销,少占内存,给任务最快的响应。
4) keepaliveTime
线程存活保持时间,超出该时间后,线程会从max下降到core,很明显,这个决定了你养闲人所花的代价。如果你不缺cpu,同时任务来的时间没法琢磨,波峰波谷的间隔比较短。经常性的来一波。那么适当的延长销毁时间,避免频繁创建和销毁线程带来的开销。如果你的任务波峰出现后,很长一段时间不再出现,间隔比较久,那么要适当调小该值,让闲着不干活的线程尽快销毁,不要占据资源。
5) threadFactory(自定义展示实例)
线程工厂,用于创建新线程。threadFactory创建的线程也是采用new Thread()方式,threadFactory创建的线程名都具有统一的风格:pool-m-thread-n(m为线程池的编号,n为线程池内的线程编号)。如果需要自己定义线程的某些属性,如个性化的线程名,可以在这里动手。一般不需要折腾它。
6)handler
线程饱和策略,当线程池和队列都满了,再加入线程会执行此策略。默认不处理的话会扔出异常,打进日志。这个与任务处理的数据重要程度有关。如果数据是可丢弃的,那不需要额外处理。如果数据极其重要,那需要在这里采取措施防止数据丢失,如扔消息队列或者至少详细打入日志文件可追踪。
CPU通过时间片分配算法来循环执行任务,时间片一般是几十毫秒(ms)。切换就要保存旧状态,完成恢复时就要读取存储的内容。这个操作过程就是上下文的切换。
1)锁的持有时间越长,就意味着有越多的线程在等待该竞争资源释放。上下文的切换代价就越多。
2)将锁贴近需要加锁的地方,越近越好!
1)过时通知
public class WaitInvalid {
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先执行时,提前释放了notify通知,这时候,print还没进入wait,收不到这个信号。等print去wait的时候,再等通知等不到了,典型的通知过时现象。
2)额外唤醒
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();
}
}
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());
}
}
分析:
出异常了!因为等候的两个线程都被唤醒了,第一个线程删除集合后,第二个线程再去删除时,等待前的判空失效,直接删除会有异常。
解决方案:
线程唤醒后,要警惕睡眠前后状态不一致,要二次判断。
1)线程池的线程数量设置不宜过大,因为一旦线程池的工作线程总数超过系统所拥有的处理器数量,就会导致过多的上下文切换。
2)慎用Executors,尤其如newCachedThreadPool。这个方法前面分析过。如果任务过多会无休止创建过多线程,增加了上下文的切换。最好根据业务情况,自己创建线程池参数。
1)很多 JVM 垃圾回收器(serial 收集器、ParNew 收集器)在回收旧对象时,会产生内存碎片
2)碎片内存整理中就需要移动存活的对象。而移动内存对象就意味着这些对象所在的内存地址会发生变化
3)内存地址变化就要去移动对象前暂停线程,在移动完成后需要再次唤醒。无形中增加了上下文的切换
4)结论:合理搭配JVM内存调优,减少 JVM 垃圾回收的频率可以有效地减少上下文切换