Java并发之 volatile & synchronized & ThreadLocal 讲解

Java 之 volatile & synchronized & ThreadLocal 讲解

在并发编程中,基本上离不开这三个东西,如何实现多线程之间的数据共享,可以用 volatile; 每个线程维护自己的变量,则采用 ThreadLocal; 为了保证方法or代码块的线程安全,就该 synchronized 上场。这里将主要说明下这三个可以怎么用,以及内部的实现细节

1. volatile

java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。

实现原理

处理器为了提高处理速度,不直接和内存进行通讯,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完之后不知道何时会写到内存,如果对声明了Volatile变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里 用一个图简单的说明上面的过程

图解

图画的一般般,简单说一下

  1. cpu与内部缓存进行交互
  2. volatile生命的变量,操作完之后写入内存(data -> data' 同时写入内存)
  3. 其他cpu缓存嗅探总线变动,并设置自己的data无效,使用时,从内存中获取

测试case

我们有两个线程, 线程B修改一个共享变量tag, 线程A一直循环干模式, 当发现 tag 设置为了 true 时, 则结束

private volatile boolean tag = false;

    @Test
    public void testVolatile() throws InterruptedException {
        Thread threadA = new Thread(new Runnable() {
            @Override
            public void run() {
                int i = 0;
                System.out.println("in A-------");
                while (!tag) {
                    System.out.print((i++) + ",");
                }
                System.out.println("\nout A-------");
            }
        });


        Thread threadB = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("in B---------");
                tag = true;
                System.out.println("out B--------");
            }
        });

        threadA.start();
        Thread.sleep(1);
        threadB.start();;
    }

输出为:

in A-------
0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,in B---------
96,
out A-------
out B--------

从上面的输出可以看出,当进入线程B之后,将 tag设置为true, 对线程A而言,它很迅速的感知到了这个参数的变化, 并终止了循环; 如果将tag前面的volatile 关键字干掉,下面是输出,从最终的结果来看好像并没有什么区别,那这个东西到底有什么用,该怎么用?

输出结果

in A-------
0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,in B---------
135,
out A-------
out B--------

一篇参考链接: http://blog.csdn.net/feier7501/article/details/20001083 (说明这篇博文中的case,本机jdk8并没有复现....., 所以这是一个失败的case)

再看一个case,

public class TestVolatile {
    int a = 1;
    int b = 2;

    public void change(){
        a = 3;
        Thread.sleep(10);   // 人肉加长这个赋值的时间
        b = a;
    }

    public void print(){
        System.out.println("b="+b+";a="+a);
    }

    public static void main(String[] args) {
        while (true){
            final TestVolatile test = new TestVolatile();
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(10);
                        test.change();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }).start();

            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test.print();
                }
            }).start();

        }
    }
}

从上面的代码来看,正常来讲,输出1,2; 或者 3, 3, 而实际输出却并不是这样

...... 
b=2;a=1
b=2;a=1
b=3;a=3
b=3;a=3
b=3;a=1           <--------------------- 看这里
b=3;a=3
b=2;a=1
b=3;a=3
b=3;a=3
......

使用建议:在两个或者更多的线程访问的成员变量上使用volatile。当要访问的变量已在synchronized代码块中,或者为常量时,不必使用。

2. synchronized

synchronized 同步代码块 or 同步方法, 加锁, 简单来讲,当一块被这个关键词修饰时,那么这块在统一时刻,只能有一个线程进行访问

通常来讲,有三种使用方法,用来修饰成员方法, 静态方法, 和代码快,下面分别来写个测试case

修饰静态方法

  public synchronized static void staticFunc() {
        System.out.println(Thread.currentThread().getName() + " in 1--->");
        try {
            System.out.println(Thread.currentThread().getName() + "-->synch staticFunc print");
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + " out 1--->");
    }

    public synchronized static void staticFunc2() {
        System.out.println(Thread.currentThread().getName() + " in 2--->");
        try {
            System.out.println(Thread.currentThread().getName() + "-->synch staticFunc2 print");
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + " out 2--->");
    }


    public static void main(String[] args) {
        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                SynchronizedTest.staticFunc();
            }
        });

        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                SynchronizedTest.staticFunc2();
            }
        });

        thread1.start();
        thread2.start();
    }

输出如下, 两个同步修饰的静态方法, 第一个线程使用其中的方法时,第二个线程即便调用第二个静态方法,依然会被阻塞

Thread-0 in 1--->
Thread-0-->synch staticFunc print
Thread-0 out 1--->
Thread-1 in 2--->
Thread-1-->synch staticFunc2 print
Thread-1 out 2--->

将上面的 synchronized 修饰去掉, 看下输出如下,也就是说,两者的调用是可以并行的

Thread-1 in 2--->
Thread-0 in 1--->
Thread-1-->synch staticFunc2 print
Thread-0-->synch staticFunc print
Thread-1 out 2--->
Thread-0 out 1--->

修饰成员方法

在上面的例子中,稍稍改动即可

public class SynchronizedTest {

    public synchronized void staticFunc() {
        System.out.println(Thread.currentThread().getName() + " in 1--->");
        try {
            System.out.println(Thread.currentThread().getName() + "-->synch staticFunc print");
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + " out 1--->");
    }

    public synchronized void staticFunc2() {
        System.out.println(Thread.currentThread().getName() + " in 2--->");
        try {
            System.out.println(Thread.currentThread().getName() + "-->synch staticFunc2 print");
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + " out 2--->");
    }


    public static void main(String[] args) {
        SynchronizedTest synchronizedTest = new SynchronizedTest();

        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronizedTest.staticFunc();
            }
        });

        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronizedTest.staticFunc2();
            }
        });

        thread1.start();
        thread2.start();
    }

}

输出如下:

Thread-0 in 1--->
Thread-0-->synch staticFunc print
Thread-0 out 1--->
Thread-1 in 2--->
Thread-1-->synch staticFunc2 print
Thread-1 out 2--->

成员方法和静态方法的修饰区别是什么 ?对上面的代码,做一个简单的修改, Thread1调用对象1的方法1, Thread3 调用对象2的方法1

public static void main(String[] args) {
        SynchronizedTest synchronizedTest = new SynchronizedTest();

        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronizedTest.staticFunc();
            }
        });

        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronizedTest.staticFunc2();
            }
        });


        SynchronizedTest synchronizedTest2 = new SynchronizedTest();
        Thread thread3 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronizedTest2.staticFunc2();
            }
        });

        thread1.start();
        thread2.start();
        thread3.start();
    }

输出如下, 其中线程0 和线程1 保证有序, 但是与线程2就没有什么关系了;即这个锁是针对对象的,这个也很容易理解,毕竟对象都不同了,对象的成员方法当然是相对独立的

Thread-0 in 1--->
Thread-0-->synch staticFunc print
Thread-2 in 2--->
Thread-2-->synch staticFunc2 print
Thread-0 out 1--->
Thread-2 out 2--->
Thread-1 in 2--->
Thread-1-->synch staticFunc2 print
Thread-1 out 2--->

同步代码块

同步代码块的使用,就是将一块代码用大括号圈起来, 外面用 synchronized() 进行修饰,括号里面就表示要加锁的东西

public class SynchronizedTest {

    public void staticFunc() {
        System.out.println(Thread.currentThread().getName() + " in 1--->");
        try {
            synchronized (this) {
                System.out.println(Thread.currentThread().getName() + "-->synch staticFunc print");
                Thread.sleep(100);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + " out 1--->");
    }

    public void staticFunc2() {
        System.out.println(Thread.currentThread().getName() + " in 2--->");
        try {
            synchronized (this) {
                System.out.println(Thread.currentThread().getName() + "-->synch staticFunc2 print");
                Thread.sleep(100);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + " out 2--->");
    }


    public static void main(String[] args) {
        SynchronizedTest synchronizedTest = new SynchronizedTest();

        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronizedTest.staticFunc();
            }
        });

        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronizedTest.staticFunc2();
            }
        });

        thread1.start();
        thread2.start();
    }
}

输出如下, 对这个说明一点, 如果在静态方法中, 使用了同步代码块, 那么括号里面的可以写什么 ? xx.class 即可

Thread-0 in 1--->
Thread-1 in 2--->
Thread-0-->synch staticFunc print
Thread-0 out 1--->
Thread-1-->synch staticFunc2 print
Thread-1 out 2--->

实现原理

源码如下

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("Method 1 start");
        }
    }
}

在加锁的代码块, 多了一个 monitorenter , monitorexit

每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

  1. 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
  2. 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
  3. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权

执行monitorexit的线程必须是objectref所对应的monitor的所有者。

  1. 指令执行时,monitor的进入数减1
  2. 如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者
  3. 其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权

谈到 synchronized 就不可避免的要说到锁这个东西,基本上在网上可以搜索到一大批的关于偏向锁,轻量锁,重量锁的讲解文档,对这个东西基本上我也不太理解,多看几篇博文之后,简单的记录一下

先抛一个结论: 轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能

1. 偏向锁

获取过程

  • 判断是否为可偏向状态
  • 是,则判断线程ID是否指向当前线程
    • 是,即表示这个偏向锁就是这个线程持有, 直接执行代码块
    • 否,通过CAS操作竞争锁
      • 竞争成功, 则设置线程ID为当前线程, 并执行代码块;
      • 竞争失败,说明多线程竞争啦,问题严重了,当偏向锁到达安全点时,将偏向锁升级为轻量锁

释放过程

  • 当偏向锁遇到其他线程尝试竞争时,持有偏向锁的线程会释放,并升级为轻量锁
  • 到达安全点, 暂停拥有偏向锁的线程,判断锁对象是否处于被锁的状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

2. 轻量锁

“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。但是,首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁

3. 转换

简单来讲,单线程时,使用偏向锁,如果这个时候,又来了一个线程访问这个代码块,那么就要升级为轻量锁,如果这个线程在访问代码块同时,又来了一个线程来访问这个代码块,那么就要升级为重量锁了。下面更多的显示了这些变动时,标记位的随之改变

4. 经典案例

单例模式,懒加载的方式,就是一个典型的利用了 synchronized 的案例

public class SingleClz {
    private static final SingleClz instance;
    
    private SingleClz() {}
    
    public static SingleClz getINstance() {
        if(instance == null) {
            synchronized(SingleClz.class) {
                if(instance == null) {
                    instance = new SingleClz();
                }
            }
        }
        return instance;
    }
}

ThreadLocal

线程本地变量,每个线程保存变量的副本,对副本的改动,对其他的线程而言是透明的(即隔离的)

1. 使用姿势一览

先来瞅一下,这个东西一般的使用姿势。通常要获取线程变量, 直接调用 ParamsHolder.get()

public class ParamsHolder {
    private static final ThreadLocal<Params> PARAMS_INFO = new ThreadLocal<>();

    @ToString
    @Getter
    @Setter
    public static class Params {
        private String mk;
    }

    public static void setParams(Params params) {
        PARAMS_INFO.set(params);
    }

    public static void clear() {
        PARAMS_INFO.remove();
    }
    
    public static Params get() {
        return PARAMS_INFO.get();
    }
    
    
    public static void main(String[] args) {

        Thread child = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("child thread initial: " + ParamsHolder.get());
                ParamsHolder.setParams(new ParamsHolder.Params("thread"));
                System.out.println("child thread final: " + ParamsHolder.get());
            }
        });


        child.start();

        System.out.println("main thread initial: " + ParamsHolder.get());
        ParamsHolder.setParams(new ParamsHolder.Params("main"));
        System.out.println("main thread final: " + ParamsHolder.get());
    }
}

输出结果

child thread initial: null
main thread initial: null
child thread final: ParamsHolder.Params(mk=thread)
main thread final: ParamsHolder.Params(mk=main)

2. 实现原理探究

直接看源码中的两个方法, get/set, 看下到底是如何实现线程变量的

public void set(T value) {
   Thread t = Thread.currentThread();
   ThreadLocalMap map = getMap(t);
   if (map != null)
       map.set(this, value);
   else
       createMap(t, value);
}

public T get() {
   Thread t = Thread.currentThread();
   ThreadLocalMap map = getMap(t);
   if (map != null) {
       ThreadLocalMap.Entry e = map.getEntry(this);
       if (e != null) {
           @SuppressWarnings("unchecked")
           T result = (T)e.value;
           return result;
       }
   }
   return setInitialValue();
}

先看set方法, 逻辑是获取当前线程对象, 获取到线程对象中的 threadLocals 属性, 这个属性的解释如下,简单来讲, 这个里面的变量都是线程独享的,完全由线程自己hold住

 ThreadLocal values pertaining to this thread. This map is maintained by the ThreadLocal class.

获取的话主要是从 ThreadLocalMap 中,将存进去的参数捞出来,现在需要了解的就是这个对象的内部构造了, 里面的有个table对象,维护了一个Entry的数组tableEntry的key为ThreadLocal对象, value为具体的值。

聚焦在 int i = key.threadLocalHashCode & (table.length - 1); 这一行,这个就是获取Entry对象在table中索引值的主要逻辑,主要利用当前线程的hashCode值,假设出现两个不同的线程,这个code值一样,会如何?下面的getEntry()逻辑中对key值进行了判断是否为当前线程

//ThreadLocalMap.java
static class Entry extends WeakReference<ThreadLocal<?>> {
       /** The value associated with this ThreadLocal. */
       Object value;

       Entry(ThreadLocal<?> k, Object v) {
           super(k);
           value = v;
       }
   }
   
   /**
    * The table, resized as necessary.
    * table.length MUST always be a power of two.
    */
private Entry[] table;
   
private Entry getEntry(ThreadLocal<?> key) {
       int i = key.threadLocalHashCode & (table.length - 1);
       Entry e = table[i];
       if (e != null && e.get() == key)
           return e;
       else
           return getEntryAfterMiss(key, i, e);
   }

针对上面的逻辑,有两个点有必要继续研究下, hashCode 的计算方式, 为什么要和数组的长度进行与计算

作为ThreadLocal实例的变量只有 threadLocalHashCode 这一个,nextHashCodeHASH_INCREMENT 是ThreadLocal类的静态变量,实际上HASH_INCREMENT是一个常量,表示了连续分配的两个ThreadLocal实例的threadLocalHashCode值的增量,而nextHashCode 的表示了即将分配的下一个ThreadLocal实例的threadLocalHashCode 的值

所有ThreadLocal对象共享一个AtomicInteger对象nextHashCode用于计算hashcode,一个新对象产生时它的hashcode就确定了,算法是从0开始,以HASH_INCREMENT = 0x61c88647为间隔递增,这是ThreadLocal唯一需要同步的地方。根据hashcode定位桶的算法是将其与数组长度-1进行与操作

ThreadLocalMap的初始长度为16,每次扩容都增长为原来的2倍,即它的长度始终是2的n次方,上述算法中使用0x61c88647可以让hash的结果在2的n次方内尽可能均匀分布,减少冲突的概率

3. 线程池中使用ThreadLocal的注意事项

这里主要的一个问题是线程复用时, 如果不清楚掉ThreadLocal 中的值,就会有可怕的事情发生, 先简单的演示一下

private static final ThreadLocal<AtomicInteger> threadLocal =new ThreadLocal<AtomicInteger>() {

        @Override
        protected AtomicInteger initialValue() {
            return new AtomicInteger(0);
        }
    };


    static class Task implements Runnable {

        @Override
        public void run() {
            AtomicInteger s = threadLocal.get();
            int initial = s.getAndIncrement();
            // 期望初始为0
            System.out.println(initial);
        }
    }


    public static void main(String[] args) {

        ExecutorService executor = Executors.newFixedThreadPool(2);
        executor.execute(new Task());
        executor.execute(new Task());
        executor.execute(new Task());
        executor.shutdown();
    }

输出结果

0
0
1

说好的线程变量,这里居然没有按照我们预期的来玩,主要原因就是线程复用了,而线程中的局部变量没有清零,导致下一个使用这个线程的时候,这些局部变量也带过来,导致没有按照我们的预期使用

这个最可能导致的一个超级严重的问题,就是web应用中的用户串掉的问题,如果我们将每个用户的信息保存在 ThreadLocal 中, 如果出现线程复用了,那么问题就会导致明明是张三用户,结果登录显示的是李四的帐号,这下就真的呵呵了

因此,强烈推荐,对于线程变量,一但不用了,就显示的调用 remove()方法进行清楚

4. 经典case

SimpleDataFormate 是一个非线程安全的类,可以使用 ThreadLocal 完成的线程安全的使用

public class ThreadLocalDateFormat {
    static ThreadLocal<DateFormat> sdf = new ThreadLocal<DateFormat>() {

        @Override
        protected DateFormat initialValue() {
            return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        }
    };

    public static String date2String(Date date) {
        return sdf.get().format(date);
    }

    public static Date string2Date(String str) throws ParseException {
        return sdf.get().parse(str);
    }
}

参考文档:

  1. 聊聊并发(一)深入分析Volatile的实现原理
  2. Java 并发编程:volatile的使用及其原理
  3. Synchronized及其实现原理
  4. Java并发编程:Synchronized底层优化(偏向锁、轻量级锁)
  5. 聊聊并发(二)Java SE1.6中的Synchronized
  6. 理解ThreadLocal / 计算机程序的思维逻辑
  7. 【ThreadLocal】深入JDK源码之ThreadLocal类

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏程序员的知识天地

Python程序员必备的30个编程技巧

直接交换2个数字的位置 Python 提供了一种直观的方式在一行代码中赋值和交换(变量值)。如下所示:

1442
来自专栏程序你好

在c#中,如何序列化/反序列化一个字典对象?

.Net提供的各种序列化的类,通过使用这些类,. Net对象的序列化和反序列化变得很容易。但是字典对象的序列化并不是那么容易。为此,您必须创建一个能够序列化自身...

901
来自专栏知道一点点

sass入门学习篇(二)

一,sass有两种后缀名文件:一种后缀名为sass,不使用大括号和分号;另一种就是我们这里使用的scss文件,建议scss.

1052
来自专栏魂祭心

原 What Every Dev need

2838
来自专栏大内老A

yield在WCF中的错误使用——99%的开发人员都有可能犯的错误[上篇]

在定义API的时候,对于一些返回集合对象的方法,很多人喜欢将返回类型定义成IEnumerable<T>,这本没有什么问题。这里要说的是另一个问题:对于返回类型为...

1818
来自专栏全华班

java学习手册-JAVA程序员笔试题(一)

JAVA程序员笔试题(一) 一、选择题: 1、类的成员变量要求仅仅能够被同一package下的类访问,应该使用哪个修辞词 A. Protected、B. Pub...

4025
来自专栏java一日一条

50个常见的 Java 错误及避免方法(第二部分)

System.out.println("Whatdo you want to do?");

1163
来自专栏猿份到

Thread ThreadLocal,傻傻分不清

Thread相信大家都不陌生,作为一个多线程的使用存在,不管是在Java处理并发数据还是Android中处理异步数据或是更新UI操作等,几乎随处可见它的身影。 ...

29010
来自专栏不会写文章的程序员不是好厨师

[翻译]Java 6,7,8中的String.intern

最近一直在关注“故障排查”的相关知识,首先着手的是OOM的异常。OOM异常通常会有Perm区的OOM(java7及以前)和HeapSpace的OOM,这两种各有...

1442
来自专栏程序员互动联盟

【编程基础】C语言常见宏定义

我们在使用C语言编写程序的时候,常常会使用到宏定义以及宏编译指令,有的可能比较常用,有的可能并不是很常用,是不是所有的C语言宏定义以及宏指令你都清楚呢? 指令 ...

3818

扫码关注云+社区

领取腾讯云代金券