JavaSE的易遗漏知识点补充

1.HashMap和ConcurrentHashMap的区别

ConcurrentHashMap相比前者,它是能够保证线程安全的。在1.7时,它在hashmap上加了个维度,其实是个二维的哈希表,它把一个个hashmap视为一个个Segment,通过key的哈希函数找到对应的段后对段进行加锁,这样相比对整个集合加锁,其他的段也能够被读写,它这个加锁的级别是段级的。

但在1.8后,就把锁的粒度给减小到hashmap的数组元素级别了,不再采用段加锁的方法,而是直接对数组下的链表或红黑树加锁,当put一个元素时,如果数组为空,就按CAS原则加入,如果产生了哈希冲突,就对这个链表进行Synchronized加锁

2.说下Synchronized

Synchronized中的锁有4种状态:无锁、偏向锁、轻向锁、重量锁

  • 偏向锁:当一个线程访问同步块并获取锁时,最先会拿到偏向锁,会在对象头的Mark Word中存储该线程的id,进行CAS判断操作,而接下来的每次加锁和解锁就不需要CAS操作,而是直接通过判断Mark Word中是否存在该线程id。也就是同一个线程下,后续访问中都能自动获取锁。
  • 轻向锁:当一个线程获得某个同步块的偏向锁时,其他线程也像获取时,偏向锁就会升级为轻向锁。在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,将Mark Word复制到锁记录的空间中,然后将Mark Word改为指向锁记录的指针
  • 重量级锁:当线程A持有轻向锁时,其他线程也想获得会开始自旋等待,但是当自选到一定次数后轻向锁就会升级为重量锁。对于想要获取被重量锁加锁的同步块的其他线程,其他线程都会被阻塞,重量锁的加锁和解锁(也就是线程的阻塞和唤醒)需要不断在用户态和内核态中切换,需要消耗大量的系统资源

3.java中抽象类和接口的区别?

  • 接口是一种特殊的抽象类

  • 构造方法:抽象类可以有构造方法,而接口没有

  • 方法:抽象类可以有抽象方法也可以有普通方法,而接口只能有抽象方法,并且抽象方法不能被实现

  • 修饰符:接口的变量默认public static final修饰,方法默认public abstract修饰

  • 继承:非抽象类只能单继承抽象类,接口能够实现多继承

  • 应用场景:抽象类用于表示这个对象是什么,接口用于表示这个对象能做什么

  • 抽象类:某个问题需要子类除了需要重写父类的abstract的方法,还需要从父类继承变量或者重写重要的非abstract方法,就考虑abstract类

    接口:某个问题不需要继承,只需要很多子类给出重复的abstract方法的具体实现细节,就用接口

4.什么是线程安全?如何实现线程安全?

当多个线程访问一个共享变量或对象时,能够按我们设想的正确获得共享数据,那么他就是线程安全的。

实现:

  • 通过阻塞同步,也就是互斥同步。比如synchronized关键字创建同步锁;

  • 通过非阻塞同步,比如CAS操作:通过三个值:一个我们需要访问的值所在的内存地址V,一个旧的期待值,一个新的期待值;我们访问的内存地址V获得这个值,判断这个值是否等于旧期待值,如果等于用新值替换。不如不符合就失败,返回最新的值

    java.util.concurrent.atomic包下的原子操作类就是基于CAS实现的

  • 线程本地存储:将共享数据限制在可见的范围中,比如ThreadLocal类

5.什么是ThreadLocal?

能够使一个变量是线程隔离的,该变量对其他线程而言是封闭且隔离的。在threadlocal类中有一个ThreadLocalMap用来存储数据,它实际上是Thread类中的一个map,也就是说这个map是每个线程各自独有的一份副本,map中的key是ThreadLocal对象,而value就是我们要存储的线程隔离的值。因此我们每次如果想实现一个线程隔离的值的话,每次就需要创建一个ThreadLocal来set

其中ThreadLocalMap中的每个元素是个Entry,类似HashMap。源码中可以看到,Entry中的key(ThreadLocal)是弱引用

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

    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

ThreadLocal 内存泄漏的原因

为什么key(ThreadLocal)是弱引用?

弱引用在每次gc时都会被回收。这样只要发生gc后ThreadLocal就会被回收,从而导致这个Entry的key变为null。但在remove和set方法中都会进行key是否null的检验,如果key是null,就会清除该Entry。

但需要注意的是,在这两个方法中必须是根据hash值定位到ThreadLocalMap了下指定Entry才会清除,并不是调用就能清除所有的key

如果在使用后不调用remove清除的话,这个value一直被引用着:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value,进而可能导致内存泄漏

所以ThreadLocal正确的使用方法是:每次使用完ThreadLocal都调用它的remove()方法清除数据

6.Serializable接口的作用?

类通过实现Serializable接口能够实现序列化或反序列化,当我们需要将内存中的java对象进行传输或存储的时候,就需要将对象进行序列化。

序列化之后,就可以把序列化后的内容写入磁盘,或者通过网络传输到别的机器上。等要用到的时候将反序列化回Java对象。但这时对象的地址是改变了的

7.volatile关键字的作用?

被volatile修饰的变量在被修改时是直接将修改结果写入主存(内存)中的,读取的时候也是直接从主存读取不从cpu的高速缓存读取。它能够保证在多线程场景下的变量在操作时能够得到正确的结果.

volatile关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。

8.jdbc的连接步骤?

先通过反射加载数据库驱动,然后根据驱动管理类创建一个连接对象,调用连接对象的Statement方法存入sql然后执行

9.静态代理和动态代理的区别?

首先需要什么是代理模式:

代理模式是常用的结构型设计模式之一,某一个对象提供一个代理,并由代理对象控制原对象的引用。代理对象在客户端和目标对象之间起到中介作用。

静态代理:通过创建一个【实现了目标接口的代理类】充当客户对象和目标对象的中介,在代理类中提供调用目标对象的方法,而由客户对象来执行代理类进而实现间接调用目标对象方法

动态代理:动态代理在创建代理对象上更加的灵活,由Java反射机制动态产生。它会根据需要,通过反射机制在程序运行期,动态的为目标对象创建代理对象。动态代理又分为 jdk代理 和 CGLIB代理两种动态代理方法:

JDK代理:

JDK代理是基于目标接口进行代理的。主要有两个重要的接口和类:一个是InvocationHandler(Interface)、另一个则是 Proxy(Class)。

首先创建一个代理工厂类,工厂类中通过Proxy类的newProxyInstance静态方法创建一个代理对象,其中创建参数需要创建一个InvocationHandler接口,然后实现一个invoke方法,在invoke方法中进行目标方法的反射调用。

但在这个代理工厂类并不是代理类,代理类是运行时在内存中创建的,代理对象调用接口的实现方法,实际上时在调用这个代理类的实现方法,而这个实现方法又去调用调用对象的InvocationHandler接口中的invoke方法。所以说代理对象本身只是定义了反射方法,但本身并没有调用权限,是需要代理类去调用的。

// 通过销售站代理类实现火车站销售火车票的JDK代理案例
public SellTickets getProxyObject() {
        //返回代理对象
        /*
            ClassLoader loader : 类加载器,用于加载代理类。可以通过目标对象获取类加载器
            Class<?>[] interfaces : 代理类实现的接口的字节码对象
            InvocationHandler h : 代理对象的调用处理程序
         */

        SellTickets proxyObject = (SellTickets)Proxy.newProxyInstance(
                station.getClass().getClassLoader(),
                station.getClass().getInterfaces(),
                new InvocationHandler() {

                    /*
                        Object proxy : 代理对象。和proxyObject对象是同一个对象,在invoke方法中基本不用
                        Method method : 对接口中的方法进行封装的method对象
                        Object[] args : 调用方法的实际参数

                        返回值: 方法的返回值。
                     */
                    @Override
                    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                        //System.out.println("invoke方法执行了");
                        System.out.println("代售点收取一定的服务费用(jdk动态代理)");
                        //执行目标对象的方法
                        Object obj = method.invoke(station, args);//调用方法的返回值
                        System.out.println(method);
                        return obj;
                    }
                }
        );
        return proxyObject;
    }

CGLIB代理:

CGLIB代理是基于继承代理类进行代理的。对代理对象类的class文件加载进来,通过修改其字节码来生成子类,并在子类中采用方法拦截的技术拦截所有父类方法的调用,顺势织入横切逻辑。

因为CGLIB是通过继承目标类来重写其方法来实现的,故而如果是final和private方法则无法被重写,也就是无法被代理

// 通过销售站代理类实现火车站销售火车票的CGLIB代理案例
public class ProxyFactory implements MethodInterceptor {

    //声明火车站对象
    private TrainStation station = new TrainStation();

    public TrainStation getProxyObject() {
        //创建Enhancer对象,类似于JDK代理中的Proxy类
        Enhancer enhancer = new Enhancer();
        //设置父类的字节码对象。指定父类
        enhancer.setSuperclass(TrainStation.class);
        //设置回调函数
        enhancer.setCallback(this);
        //创建代理对象
        TrainStation proxyObject = (TrainStation) enhancer.create();
        return proxyObject;
    }
    
    @Override
    public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
        //System.out.println("方法执行了");
        System.out.println("代售点收取一定的服务费用(CGLib代理)");
        //要调用目标对象的方法
        Object obj = method.invoke(station, objects);
        return obj;
    }
}
  • 静态代理和动态代理的区别:

    ​ 静态代理中,接口每新增一个抽象方法,除了所有实现类需要实现这个方法外,所有代理类也需要实现此方法(毕竟代理类也是实现了接口),增加了代码维护的复杂度。

    ​ 动态代理中代理工厂不需要实现接口,动态根据接口情况来生成代理类,所以不会出现该问题。接口中声明的所有方法都被转移到调用处理器一个集中的方法中处理

  • JDK代理和CGLIB代理的区别:

​ jdk代理基于目标接口,创建一个实现目标接口的实现类并通过反射机制来实现代理,而cglib代理基于继承目标类,通过修改目标类的字节码来创建一个目标类的子类来代理

​ 在jdk6、jdk7、jdk8逐步对JDK动态代理优化之后,在调用次数较少的情况下,JDK代理效率高于CGLIB代理效率,只有当进行大量调用的时候,jdk6和jdk7比CGLIB代理效率低一点,但是到jdk8的时候,jdk代理效率高于CGLIB代理。

10.为什么String不可变?

public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
    private final char value[];
    //...
}

因为String的底层存储变量value是final修饰的,不可变。

这种说法是错误的:因为final修饰的变量,如果是基础变量的话值不可变,引用变量的话指针的指向不可变。

数组其实是引用类型,因此String中的value数组是不能指向别的变量,里面的元素是可以改变的。

正确的说法应该是:value数组被private修饰,是私有的,但同时String类并不提供public方法供外部修改,比如setter方法,所以String不可变

但其实我们可以通过反射的方法获取String类中的value字段来修改:

@Test
public void stringChange() throws IllegalAccessException, NoSuchFieldException {
    String strObj = new String("aaa");
    System.out.println("反射执行前字符串:" + strObj);
    System.out.println("反射执行前的hash值:" + strObj.hashCode());
    Field field = strObj.getClass().getDeclaredField("value");
    field.setAccessible(true);
    char[] value = (char[]) field.get(strObj);
    value[2] = 'b';
    System.out.println("反射执行后字符串:" + strObj);
    System.out.println("反射执行后的hash值:" + strObj.hashCode());
}

/* 
反射执行前字符串:aaa
反射执行前的hash值:96321
反射执行后字符串:aab
反射执行后的hash值:96321
*/

说明通过反射我们是可以修改String的值的

11.String、StringBuilder和StringBuffer的区别?

  • String:Stirng每次进行拼接时,会拼接后创建一个新的String对象,然后将引用变量指向这个新对象,如果需要进行多次的拼接,性能并不好
  • StringBuilder:每次字符串拼接都是在StringBuilder里操作,通过append方法拼接,适合大量字符串的拼接场景,但StringBuilder是线程不安全的
  • StingBuffer:和StringBuilder作用一样,但它是线程安全的,适合多线程场景下的字符串拼接

12.Exception和Error的区别?

Exception和Error都是Throwable这个祖先类的子类

  • Exception:程序本身可以处理的异常,可以通过catch来捕获。而Exception又分为【运行时异常】和【编译异常】(非运行时异常)。

    常见的运行时异常有:

    • NullPointerException(空指针异常)
    • ClassCastException(类型转化异常)
    • ArithmeticException(算术异常)

    常见的编译异常有:

    • ClassNotFoundException(类无法寻找异常)
    • SQLException(SQL异常)
    • IOException(IO异常)

    异常的出现一般会使得程序停止,但在项目运行中,通常catch和throw使得项目只是会报异常并不会停止

  • Error:程序无法处理的错误,比如虚拟机的错误Virtual MachineError、OutOfMemoryError(堆空间不足错误)、StackOverflowError(栈溢出错误),这些错误出现会使得程序立刻崩溃,程序终止运行。

13.throw和throws的区别是什么?

  • throw:throw用来在方法中进行主动的异常抛出,比如我们在业务处理数据时,需要先判断数据是否合法,如果不合法的直接创建一个异常对象然后抛出(throw new Exception)告诉调用者
  • throws:throws加在方法后面,throws后面可以加多种异常,用来声明异常。表示该方法并不负责处理异常,告诉调用者在调用过程中自行处理出现的异常:调用者可以捕获出现的异常,也可以继续向外抛出异常

14.什么是泛型,泛型的作用是什么?

泛型是JDK5中新增的一种特性,主要作用是提高代码的可读性和稳定性。

比如说创建一个ArrayList:List<Strinf> list = new ArrayList<>();

  • 从使用角度说:<String>表明只能这个list中的元素只能是String的类型,如果add其他类型的元素会报错,并且我们看到<String>就能直观的知道这个list是存储String的。
  • 从定义角度说:我们可以使用<T><E>作为定义类的泛型,表明在调用此类时可以传入任意种类的参数。有泛型类、泛型接口、泛型方法。其中泛型方法中,如果传入的参数是泛型参数,需要在方法返回类型的前面加上泛型,这样泛型参数才会生效
public static < E > void printArray( E[] inputArray ){
    for ( E element : inputArray ){
        System.out.printf( "%s ", element );
    }
    System.out.println();
}

15.为什么HashMap的长度是2的幂次方?

我们知道,hashcode的取值范围为【Integer_MIN,Integer_MAX】(也就是- 2^31 ~ 2^31-1)。

在HashMap中数组的下标是通过key的hashcode确定的。但是这么大的取值范围如果创建一个等长的数组的话堆中是放不下的,所以采用了取余的思想。也就是将哈希值对数组长度length取余,这样就能保证得到的数字在数组范围内。

不过其实存在这么一个等式:hashcode%length == hashcode & (length-1),而且与操作比余数运算要快。但该等式要成立,必须满足一个条件:length必须是2的 n 次方。

而这只是其中之一,还有一个原因就要从二进制的角度来看。我们知道,作为2的幂次方的数(称为L吧),其二进制的表示里是只有一位为1,其他位都是0的。L-1后其二进制就会变成原先是1的那个位后面的低位全为1,前面的高位全为0,这时【L-1】和哈希值【H】相与的结果就完全取决于H的低位的,是分布均匀的。

但如果L不是2的幂次方,【L-1】的二进制里的1和0的分布就是随机的,那么就势必有一些中间位为0,这样就算不同的哈希值相与时,得到的结果总有那么几位始终为0,也就是说这时候得到的结果不仅取决于哈希值,还取决于数组长度了。导致数组中总有那么几个下标不会被定位到,最终索引定位到的不是均匀的分布。

这就是HashMap 的长度是 2 的幂次方的原因。

16.如何将数组转化为集合?

我们第一想法是使用Arrays.asList(arrXXX)方法。这样确实可以做到将一个 包装类数组 转为集合。

List<String> list = Arrays.asList(new String[]{"abc","def"});

但是里面也存在一些坑:得到的集合并不能进行add或remove操作,否则会抛出UnsupportedOperationException异常

原因在于asList方法返回的是一个ArrayList类型对象,但这个对象是Arrays类下的一个内部类,而不是AbstractList类下的:java.util.Arrays$ArrayList。

image-20230530002605744

在Arrays类下的这个内部类并没有实现像AbstractList.ArrayList的add或remove方法。

// 使用举例
List<String> list = Arrays.asList(new String[]{"abc","def"});

// AbstractList类中的方法实现
public void add(int index, E element) {
    throw new UnsupportedOperationException();
}

public E remove(int index) {
    throw new UnsupportedOperationException();
}

像上面例子中将Arrays$ArrayList进行向上转型成List,由于list这个集合引用是List类型,但是其子类也就是这个内部类并没有重写List接口的add和remove方法,所以就会调用到List接口的一个直接实现类(AbstractList类)中的add/remove方法。而在这两个方法中并没有具体的实现逻辑,方法中都是直接抛出UnsupportedOperationException异常。所以是Arrays.ArrayList调用add/remove方法是会抛出异常的。

那么我们应该怎么将数组转化为一个可以add/remove的集合呢?

  1. 最简便的方法
List list = new ArrayList<>(Arrays.asList("a", "b", "c"))
  1. 使用 Java8 的 Stream(推荐)
Integer [] myArray = { 1, 2, 3 };
List myList = Arrays.stream(myArray).collect(Collectors.toList());
//基本类型也可以实现转换(依赖boxed的装箱操作)
int [] myArray2 = { 1, 2, 3 };
List myList = Arrays.stream(myArray2).boxed().collect(Collectors.toList());

17.java线程的生命周期中的blocked和waiting状态的区别?

Blocked状态:一个线程因为等待临界区的锁而被阻塞产生的状态。比如需要进入synchronized关键字的同步代码块,如果此时已经有其他线程在执行着,本线程会进入阻塞状态。

Waitiing状态:无限期等待另一个线程执行特定操作的线程处于此状态。一个线程A在拿到锁但不满足执行条件的时候,需要另一个线程B去满足这个条件,那么线程A就会释放锁并处于waiting的状态,等线程B执行完再执行。

调用Object类下的wait方法,就能让线程进入waiting状态,notify/notifyAll能让线程结束waiting状态。

其中notify是随机唤醒正在此对象的监视器上等待的单个线程

notifyAll是唤醒此对象监视器上等待的所有线程。

一句话概括:在java中,阻塞状态是被动的,是因为线程获取不到锁而进入等待,而等待状态是主动的,是线程得到锁但不满足某些条件主动释放锁进入等待,等待别的线程去满足条件。

18.介绍一下线程池?

线程池:线程池就是管理一系列线程的资源池,当有任务时,直接从线程池中拿出已经创建好的线程来处理,处理完后的线程被放回线程池中,并不会被销毁。

线程池能够降低重复创建和销毁线程所来带的资源消耗,由于能需要时就能获取已经创建的线程,响应速度会更快。

我们可以通过ThreadPoolExecutor类创建一个线程池执行器,通过该执行器调用execute/submit方法执行线程。

image-20230609110810815

参数介绍:

  • corePoolSize(核心线程大小):线程池中维护的一个最小的线程数量,即使这些线程处于空闲状态,也不会被销毁。当任务提交到线程池时,如果发现当前线程数超过了此参数,池子中就会创建新的线程
  • maximumPoolSize(最大线程大小):线程池中的最大线程数量限制,当线程数达到corePoolSize后,如果继续有任务被提交到线程池,会将任务缓存到工作队列。如果队列也满了,就会创建新的线程。
  • workQueue(工作队列):新任务被提交后,会先进入到此工作队列中,任务调度时再从队列中取出任务。
  • handler(拒绝策略):当工作队列中的任务以及达到最大限度,并且池子中的线程数也到达了maximumPoolSize,如果此时有新来的任务,这时候就会触发设置好的拒绝策略。

线程池处理任务的流程是怎么样的?

  1. 如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。
  2. 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。
  3. 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。
  4. 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,就会触发拒绝策略,当前任务会被拒绝,

jdk中提供了4中拒绝策略:

  • AbortPolicy:直接丢弃任务,并抛出RejectedExecutionException异常
  • DiscardPolicy:直接丢弃任务,之后什么都不做
  • DiscardOldestPolicy:丢弃最早进入的任务,尝试把当前的任务加进工作队列
  • CallerRunsPolicy:在调用者线程中直接执行被拒绝任务的run方法

jdk提供的线程池有以下4种:

  • FixedThreadPool(定长线程池):只有核心线程,线程数固定。执行完立即回收,任务队列为链表结构的有界队列。
  • ScheduledThreadPool(定时线程池):核心线程数量固定,非核心线程数量无限,任务队列为延时阻塞队列。提交任务后可以延迟指定延迟时间后执行
  • CachedThreadPool(可缓存线程池):无核心线程,非核心线程数量无限,任务队列为不存储元素的阻塞队列。适合执行大量但耗时小的任务。
  • SingleThreadExecutor(单线程执行器):只有 1 个核心线程,无非核心线程,执行完立即回收,任务队列为链表结构的有界队列

19.System.gc()

System.gc会触发Full GC,但并不是一定会触发。它只是会提醒jvm需要进行gc,但gc的时机还是由jvm来决定

20.什么是CAS机制?

CAS全程是【Compare And Swap】(比较和交换),旨在解决并发编程中的线程安全问题。通过三个值:旧值的地址、预期值、新值,在需要更新值时,通过旧值地址获取到旧值,比较预期值和旧值,如果一致说明该值没别其他线程修改,可以将旧值更新成新值。不一致则拒绝。

但以上的机制并不完善,可能存在【ABA问题】:同个线程在前后两次检验值发现没有变化,认为该值并没有被其他线程修改,但实际上可能被其他线程修改过却又修改回去。也就是ABA之后其他线程无法察觉到中间的变化,认为这个旧值没变,从而成功进行修改。

所以可以加上第四个值:版本号。

在每次能够进行更新操作时让这个版本号自增,检验的时候还要比对版本号是否一致,版本号不一致拒绝掉。

为什么加了个版本号就能够解决ABA问题?

举个例子:假设初始值为A,版本号为0。在执行CAS操作期间,共享变量的值从A变为B,版本号从0变为1。此时,如果另一个线程尝试执行CAS操作,将值从A修改为C,并且版本号仍然是为0的,CAS操作将失败,因为版本号已经发生了变化。

21.公平锁和非公平锁的区别?

公平锁:当多个线程等待获取锁的时候,锁会按照线程的申请顺序给线程分配,先来先得。公平锁是靠一个等待队列来实现的。

非公平锁:非公平锁没有这种顺序保证,当锁被释放时,此时所有等待的锁都会尝试去获取,谁先抢到就是谁的。非公平锁适用于追求高吞吐量、频繁释放锁的场景。但可能会导致先来的线程久久无法抢到锁的饥饿现象

在Java中,公平锁和非公平锁的实现可以通过使用ReentrantLock类来实现。ReentrantLock是一个可重入的互斥锁,可以作为公平锁或非公平锁来使用。锁是否公平可以在创建锁对象的时候存入boolean值决定

ReentrantLock fairLock = new ReentrantLock(true); // 使用公平锁
ReentrantLock fairLock = new ReentrantLock(false); // 使用非公平锁
// 在需要获取锁的代码块中使用lock()方法来获取锁
fairLock.lock();
try {
    // 执行需要保护的临界区代码
} finally {
    // 在不再需要锁时,使用unlock()方法释放锁
    fairLock.unlock();
}

(待更)


 上一篇
了解线程池 了解线程池
线程池:线程池就是管理一系列线程的资源池,当有任务时,直接从线程池中拿出已经创建好的线程来处理,处理完后的线程被放回线程池中,并不会被销毁。 优点:线程池能够降低重复创建和销毁线程所来带的资源消耗,由于能在需要时就能获取已经创建的线程,响应
2023-06-09
下一篇 
Springboot理论知识点 Springboot理论知识点
1.如何理解 IOC 和 DI ?Ioc:inversion of control(控制反转) 它是一种控制思想,将创建对象的控制权转交给Spring容器 何为控制:指的是对象创建的权力 何为反转:创建对象的权力由开发者手中转交给IOC容
2023-05-18
  目录