> 백엔드 개발 > C#.Net 튜토리얼 > .Net 가비지 수집 메커니즘 원리(1)

.Net 가비지 수집 메커니즘 원리(1)

黄舟
풀어 주다: 2017-02-17 11:21:29
원래의
1270명이 탐색했습니다.

영문 원문: Jeffrey Richter

편집자: Zhao Yukai

링크: http://www.php.cn/

Microsoft.Net clr의 가비지 수집 메커니즘을 사용하면 프로그래머는 더 이상 메모리 해제 시점에 주의할 필요가 없습니다. 메모리 해제 문제는 GC에 의해 완전히 수행되며 프로그래머에게 투명합니다. 그럼에도 불구하고 .Net 프로그래머로서 가비지 수집이 어떻게 작동하는지 이해하는 것이 필요합니다. 이 기사에서는 .Net이 관리되는 메모리를 할당하고 관리하는 방법을 살펴본 다음 가비지 수집기의 알고리즘 메커니즘을 단계별로 설명합니다.
프로그램에 적합한 메모리 관리 전략을 설계하는 것은 어렵고 지루하며, 프로그램 자체가 해결하려는 문제를 해결하는 데 집중하지 못하게 합니다. 개발자가 메모리 관리 문제를 해결하는 데 도움이 되는 기본 제공 방법이 있습니까? 물론 .Net의 GC, 가비지 컬렉션입니다.
생각해보면 모든 프로그램은 화면 표시, 네트워크 연결, 데이터베이스 리소스 등과 같은 메모리 리소스를 사용합니다. 실제로 객체 지향 환경에서는 각 유형이 해당 데이터를 저장하기 위해 일부 메모리 리소스를 점유해야 합니다. 객체는 다음 단계에 따라 메모리를 사용해야 합니다.
1. Space
2. 메모리를 초기화하고 메모리를 사용 가능한 상태로 설정합니다
3. 객체의 멤버에 액세스합니다
4. . 객체를 삭제하여 메모리를 정리합니다
5. 메모리 해제
이 단순해 보이는 메모리 사용 패턴으로 인해 때때로 프로그래머는 객체 해제를 잊어버릴 수도 있습니다. 더 이상 사용되지 않는 개체에 액세스하려고 시도하는 경우도 있습니다. 이 두 종류의 버그는 일반적으로 어느 정도 숨겨져 있어 찾기가 쉽지 않습니다. 논리적 오류와 달리 일단 발견되면 수정할 수 있습니다. 프로그램이 한동안 실행된 후 메모리 누수 및 예기치 않은 충돌이 발생할 수 있습니다. 실제로 개발자가 메모리 문제를 감지하는 데 도움이 될 수 있는 도구는 다음과 같습니다. 작업 관리자, 시스템 AcitvieX Control 및 Rational의 Purify를 모니터링합니다.
GC에서는 개발자가 메모리를 해제할 시점에 주의를 기울일 필요가 없습니다. 그러나 가비지 수집기는 메모리의 모든 리소스를 관리할 수 없습니다. 가비지 수집기는 일부 리소스를 재활용하는 방법을 모릅니다. 이러한 리소스의 경우 개발자는 이를 재활용하기 위해 자체 코드를 작성해야 합니다. .Net에서 프레임워크에서 개발자는 일반적으로 이러한 리소스를 Close, Dispose 또는 Finalize 메서드로 정리하는 코드를 작성합니다. 나중에 Finalize 메서드를 살펴보겠습니다. 이 메서드는 가비지 수집기에 의해 자동으로 호출됩니다.
그러나 다음과 같이 리소스를 자체적으로 해제하기 위해 코드를 구현할 필요가 없는 객체가 많이 있습니다. 이를 지우려면 왼쪽, 오른쪽, 너비 및 높이 필드만 지우면 됩니다. 수집가는 이것을 할 수 있습니다. 객체에 메모리가 어떻게 할당되는지 살펴보겠습니다.
객체 할당:

.Net clr은 모든 참조 객체를 관리되는 힙에 할당합니다. 이는 c-런타임 힙과 매우 유사하지만 객체를 언제 해제할지 주의할 필요가 없습니다. 객체는 사용하지 않을 때 자동으로 해제됩니다. 이런 식으로, 가비지 수집기가 객체가 더 이상 사용되지 않고 재활용되어야 한다는 것을 어떻게 알 수 있는가 하는 질문이 생깁니다. 이에 대해서는 나중에 설명하겠습니다.
여러 가지 가비지 수집 알고리즘이 있습니다. 각 알고리즘은 특정 환경에 대한 성능 최적화를 제공합니다. 이 기사에서는 clr의 가비지 수집 알고리즘에 중점을 둡니다. 기본 개념부터 시작해 보겠습니다.
프로세스가 초기화되면 런타임 중에 연속적인 빈 메모리 공간이 예약됩니다. 이 메모리 공간은 관리되는 힙입니다. 관리되는 힙은 다음 개체의 할당 주소를 가리키는 NextObjPtr이라는 포인터를 기록합니다. 처음에 이 포인터는 관리되는 힙의 시작 위치를 가리킵니다.
애플리케이션은 new 연산자를 사용하여 새 객체를 생성합니다. 이 연산자는 먼저 관리되는 힙의 남은 공간이 객체를 수용할 수 있는지 확인해야 합니다. 그런 다음 객체의 생성자를 호출하면 new 연산자가 객체의 주소를 반환합니다.


그림 1 관리형 힙

이때 NextObjPtr은 관리되는 힙에서 다음 객체가 할당되는 위치를 가리킨다. 그림 1은 관리되는 힙에 A, B, C 세 개의 객체가 있음을 보여준다. 다음 개체는 NextObjPtr이 가리키는 위치(C 개체 옆)에 배치됩니다.
이제 c-런타임 힙이 메모리를 할당하는 방법을 살펴보겠습니다. c-런타임 힙에서 메모리를 할당하려면 충분히 큰 메모리 블록을 찾을 때까지 연결 목록의 데이터 구조를 탐색해야 합니다. 이 메모리 블록은 분할된 후 연결 목록의 포인터가 나머지 메모리 공간을 가리켜야 합니다. . 연결 목록이 손상되지 않았는지 확인하세요. 관리되는 힙의 경우 개체를 할당하면 NextObjPtr 포인터의 포인터만 변경되는데 이는 매우 빠릅니다. 실제로 관리되는 힙에 개체를 할당하는 것은 스레드 스택에 메모리를 할당하는 것과 매우 유사합니다.
지금까지는 관리형 힙의 메모리 할당 속도가 c-런타임 힙의 할당 속도보다 빠른 것으로 보이며 구현도 더 간단합니다. 물론 관리되는 힙은 주소 공간이 무제한이라는 가정을 하기 때문에 이러한 이점을 얻습니다. 분명히 이 가정은 틀렸다. 이 가정이 사실인지 확인하는 메커니즘이 있어야 합니다. 이 메커니즘은 가비지 수집기입니다. 그것이 어떻게 작동하는지 봅시다.
애플리케이션이 객체를 생성하기 위해 new 연산자를 호출할 때 객체를 저장할 메모리가 없을 수 있습니다. 관리되는 힙은 NextObjPtr이 가리키는 공간이 힙의 크기를 초과하는지 여부를 감지할 수 있습니다. 힙의 크기를 초과하면 관리되는 힙이 가득 차서 가비지 수집이 필요함을 의미합니다.
실제로는 0세대 힙이 가득 차면 가비지 수집이 트리거됩니다. "세대"는 가비지 수집기의 성능 향상을 위한 구현 메커니즘입니다. "세대"는 새로 생성된 객체가 젊은 세대이고, 재활용 작업이 발생하기 전에 재활용되지 않은 객체는 오래된 객체를 의미합니다. 개체를 여러 세대로 분할하면 가비지 수집기가 모든 개체를 수집하는 대신 특정 세대의 개체만 수집할 수 있습니다.

가비지 수집 알고리즘:

가비지 수집기는 애플리케이션에서 더 이상 사용하지 않는 개체가 있는지 확인합니다. 그러한 개체가 존재하는 경우 해당 개체가 차지하는 공간을 회수할 수 있습니다(힙에 사용 가능한 메모리가 충분하지 않은 경우 new 연산자는 OutofMemoryException을 발생시킵니다). 가비지 수집기가 개체가 아직 사용 중인지 어떻게 확인하는지 물어볼 수 있습니다. 이 질문은 대답하기 쉽지 않습니다.
모든 애플리케이션에는 일련의 루트 개체가 있습니다. 루트는 관리되는 힙의 주소를 가리킬 수도 있고 null일 수도 있습니다. 예를 들어, 모든 전역 및 정적 개체 포인터는 애플리케이션의 루트 개체입니다. 또한 스레드 스택의 로컬 변수/매개 변수도 애플리케이션의 루트 개체이며, 관리되는 힙을 가리키는 CPU 레지스터의 개체도 루트 개체입니다. . 살아남은 루트 개체 목록은 JIT(Just-In-Time) 컴파일러와 clr에 의해 유지 관리되며 가비지 수집기는 이러한 루트 개체에 액세스할 수 있습니다.
가비지 수집기가 실행되기 시작하면 관리되는 힙의 모든 개체가 가비지라고 가정합니다. 즉, 루트 개체가 없고 루트 개체가 참조하는 개체가 없다고 가정합니다. 그런 다음 가비지 수집기는 루트 개체 탐색을 시작하고 루트 개체에 대한 참조가 있는 모든 개체의 그래프를 작성합니다.
그림 2에서는 관리되는 힙에 있는 애플리케이션의 루트 개체가 A, C, D 및 F임을 보여줍니다. 이러한 개체는 그래프의 일부입니다. 그런 다음 개체 D는 개체 H를 참조하므로 개체 H도 그래프에 추가됩니다. 그래프. 가비지 수집기는 도달 가능한 모든 개체를 순환합니다.


사진 2 관리되는 힙의 개체

가비지 수집기는 루트 개체와 참조 개체를 하나씩 순회합니다. 가비지 수집기가 그래프에 객체가 이미 있음을 발견하면 경로를 변경하고 계속해서 탐색합니다. 여기에는 두 가지 목적이 있습니다. 하나는 성능을 향상시키는 것이고, 다른 하나는 무한 루프를 방지하는 것입니다.
모든 루트 개체를 확인한 후 가비지 수집기의 그래프에는 애플리케이션에서 연결할 수 있는 모든 개체가 포함됩니다. 이 그래프에 없는 관리되는 힙의 모든 개체는 재활용할 가비지 개체입니다. 도달 가능한 개체 그래프를 구성한 후 가비지 수집기는 관리되는 힙을 선형적으로 탐색하여 연속적인 가비지 개체 블록(사용 가능한 메모리로 간주될 수 있음)을 찾기 시작합니다. 그런 다음 가비지 수집기는 모든 메모리 조각을 포함하여 가비지가 아닌 개체를 함께 이동합니다(C의 memcpy 함수 사용). 물론, 객체를 이동할 때 모든 객체 포인터를 비활성화하십시오(잘못되었을 수 있으므로). 따라서 가비지 수집기는 응용 프로그램의 루트 개체가 개체의 새 메모리 주소를 가리키도록 수정해야 합니다. 또한 개체에 다른 개체에 대한 포인터가 포함되어 있으면 가비지 수집기가 참조 수정도 담당합니다. 그림 3은 수집 후의 관리되는 힙을 보여줍니다.


사진 3 재활용 후 관리되는 힙
은 그림 3과 같습니다. 재활용 후 모든 쓰레기 개체가 식별되고 쓰레기가 아닌 개체가 모두 함께 이동됩니다. 가비지가 아닌 모든 개체의 포인터도 이동된 메모리 주소로 수정되고 NextObjPtr은 가비지가 아닌 마지막 개체의 뒷면을 가리킵니다. 이때 new 연산자는 계속해서 개체를 성공적으로 생성할 수 있습니다.
보시다시피 가비지 컬렉션에는 상당한 성능 저하가 있는데, 이는 관리되는 힙을 사용할 때의 분명한 단점입니다. 그러나 관리되는 힙이 느려질 때까지는 메모리 회수 작업이 수행되지 않는다는 점을 기억하세요. 관리되는 힙의 성능은 가득 찰 때까지 c-런타임 힙의 성능보다 낫습니다. 런타임 가비지 수집기는 성능 최적화도 수행합니다. 이에 대해서는 다음 문서에서 설명하겠습니다.
다음 코드는 객체가 생성되고 관리되는 방법을 보여줍니다.

class Application {
public static int Main(String[] args) {
 
      // ArrayList object created in heap, myArray is now a root
      ArrayList myArray = new ArrayList();
 
      // Create 10000 objects in the heap
      for (int x = 0; x < 10000; x++) {
         myArray.Add(new Object());    // Object object created in heap
      }
 
      // Right now, myArray is a root (on the thread&#39;s stack). So, 
      // myArray is reachable and the 10000 objects it points to are also 
      // reachable.
      Console.WriteLine(a.Length);
 
      // After the last reference to myArray in the code, myArray is not 
      // a root.
      // Note that the method doesn&#39;t have to return, the JIT compiler 
      // knows
      // to make myArray not a root after the last reference to it in the 
      // code.
 
      // Since myArray is not a root, all 10001 objects are not reachable
      // and are considered garbage.  However, the objects are not 
      // collected until a GC is performed.
   }
}
로그인 후 복사

아마도 GC가 이렇게 좋은데 왜 ANSI C++에 포함되지 않느냐고 물으실 것입니다. 그 이유는 가비지 수집기가 애플리케이션의 루트 개체 목록을 찾을 수 있어야 하고 개체의 포인터를 찾아야 하기 때문입니다. C++에서는 개체 포인터가 서로 변환될 수 있으며 포인터가 가리키는 개체가 무엇인지 알 수 있는 방법이 없습니다. CLR에서 관리되는 힙은 개체의 실제 유형을 알고 있습니다. 메타데이터 정보를 사용하여 개체가 참조하는 구성원 개체를 확인할 수 있습니다.

가비지 수집 및 마무리

가비지 수집기는 개체가 가비지로 표시된 후 개체의 Finalize 메서드를 자동으로 호출할 수 있는 추가 기능을 제공합니다(단, object 개체의 Finalize 메서드를 재정의합니다.
Finalize 메서드는 개체 객체의 가상 메서드입니다. 필요한 경우 이 메서드를 재정의할 수 있지만 이 메서드는 C++ 소멸자와 유사한 방식으로만 재정의할 수 있습니다. 예:

{
~Foo(){
        Console.WriteLine(“Foo Finalize”);
}
}
로그인 후 복사

여기에서 C++를 사용한 프로그래머는 Finalize 메서드가 C++의 소멸자와 정확히 동일하게 작성된다는 사실에 특별한 주의를 기울여야 합니다. 그러나 Finalize 메서드와 .Net의 소멸자는 다음과 같습니다. 예, 관리되는 객체는 파괴될 수 없으며 가비지 수집을 통해서만 재활용될 수 있습니다.
클래스를 디자인할 때 다음과 같은 이유로 Finalize 메서드를 재정의하지 않는 것이 가장 좋습니다.
1. Finalize를 구현하는 개체는 이전 "세대"로 승격되어 메모리 부담이 증가하고 객체 및 이 객체와 연관된 객체는 처음 쓰레기가 될 때 재활용할 수 없습니다.
2. 이러한 개체의 할당 시간은 길어집니다.
3. 가비지 수집기가 Finalize 메서드를 실행하도록 하면 성능이 크게 저하됩니다. Finalize 메서드를 구현하는 모든 개체는 Finalize 메서드를 실행해야 한다는 점을 기억하세요. 길이가 10000인 배열 개체가 있는 경우 각 개체는 Finalize 메서드를 실행해야 합니다.
4 Finalize 메서드를 재정의하는 개체는 참조할 수 있습니다. Finalize 메소드를 구현하지 않은 다른 객체도 재활용을 위해 지연됩니다
5. Finalize 메소드가 실행되는 시점을 제어할 방법이 없습니다. Finalize 메서드에서 데이터베이스 연결 등의 리소스를 해제하려는 경우 시간이 오래 지나서 데이터베이스 리소스가 해제될 수 있습니다
6. 프로그램이 충돌해도 일부 개체는 여전히 참조되고 해당 Finalize 메서드는 참조되지 않습니다. 기회가 실행되었습니다. 이러한 상황은 개체가 백그라운드 스레드에서 사용될 때, 개체가 프로그램을 종료할 때 또는 AppDomain이 언로드될 때 발생합니다. 또한 기본적으로 애플리케이션이 강제 종료되면 Finalize 메서드가 실행되지 않습니다. 물론 모든 운영 체제 리소스는 회수되지만 관리되는 힙의 개체는 회수되지 않습니다. GC의 RequestFinalizeOnShutdown 메서드를 호출하여 이 동작을 변경할 수 있습니다.
7. 런타임은 여러 개체의 Finalize 메서드가 실행되는 순서를 제어할 수 없습니다. 때때로 객체의 소멸은 순차적일 수 있습니다.
정의한 객체가 Finalize 메서드를 구현해야 하는 경우 Finalize 메서드가 최대한 빨리 실행되도록 하고 스레드 동기화 작업을 포함하여 차단을 유발할 수 있는 모든 작업을 피하십시오. 또한 Finalize 메서드로 인해 예외가 발생하지 않는지 확인하세요. 예외가 발생하면 가비지 수집기는 다른 개체의 Finalize 메서드를 계속 실행하고 예외를 직접 무시합니다.
컴파일러는 코드를 생성할 때 생성자에서 기본 클래스의 생성자를 자동으로 호출합니다. 마찬가지로 C++ 컴파일러는 소멸자에 대한 기본 클래스 소멸자에 대한 호출을 자동으로 추가합니다. 그러나 .Net의 Finalize 함수는 이와 다르며 컴파일러는 Finalize 메서드에 대해 특별한 처리를 수행하지 않습니다. Finalize 메서드에서 상위 클래스의 Finalize 메서드를 호출하려면 호출 코드를 명시적으로 직접 추가해야 합니다.
C#의 Finalize 메서드는 C++의 소멸자와 동일하게 작성되었지만 C#은 소멸자를 지원하지 않는다는 점에 유의하세요.

Finalize 메소드를 호출하는 GC의 내부 구현

표면적으로는 가비지 컬렉터가 Finalize 메소드를 사용하는 것은 매우 간단합니다. 객체가 재활용될 때 메서드를 마무리합니다. 그러나 실제로는 조금 더 복잡합니다.
애플리케이션이 새 개체를 생성하면 new 연산자가 힙에 메모리를 할당합니다. 개체가 Finalize 메서드를 구현하는 경우. 개체 포인터는 종료 대기열에 배치됩니다. 마무리 큐는 가비지 수집기에 의해 제어되는 내부 데이터 구조입니다. 대기열의 각 개체는 재활용 시 Finalize 메서드를 호출해야 합니다.
아래 그림에 표시된 힙에는 여러 개체가 포함되어 있으며 그 중 일부는 개체이고 일부는 개체가 아닙니다. 개체 C, E, F, I 및 J가 생성되면 시스템은 이러한 개체가 Finalize 메서드를 구현한다는 것을 감지하고 해당 포인터를 마무리 대기열에 넣습니다.


Finalize 방법이 하는 일은 일반적으로 재활용할 수 없는 것을 재활용하는 것입니다. 파일 핸들, 데이터베이스 연결 등과 같은 가비지 수집기 리소스
가비지 수집 시 객체 B, E, G, H, I, J는 가비지로 표시됩니다. 가비지 수집기는 마무리 대기열을 스캔하여 이러한 개체에 대한 포인터를 찾습니다. 개체 포인터가 발견되면 포인터가 Freachable 큐로 이동됩니다. Freachable 큐는 가비지 수집기에 의해 제어되는 또 다른 내부 데이터 구조입니다. Freachable 큐에 있는 각 개체의 Finalize 메서드가 실행됩니다.
가비지 수집 후 관리되는 힙은 그림 6과 같습니다. B, G, H 객체에는 Finalize 메서드가 없기 때문에 재활용되었음을 알 수 있습니다. 그러나 개체 E, I 및 J는 Finalize 메서드가 아직 실행되지 않았기 때문에 아직 재활용되지 않았습니다.


사진 5 가비지 수집 후 관리되는 힙

프로그램이 실행 중일 때 개체의 Finalize 메서드 호출을 담당하는 전용 스레드가 있습니다. 접근 가능한 대기열. Freachable 큐가 비어 있으면 이 스레드는 대기 상태가 되며 큐에 개체가 있으면 스레드가 활성화되어 큐에 있는 개체를 제거하고 Finalize 메서드를 호출합니다. 따라서 Finalize 메서드를 실행할 때 스레드의 로컬에 액세스하려고 시도하지 마세요. 저장.
완료 대기열과 Freachable 대기열 간의 상호 작용은 매우 영리합니다. 먼저 Freachable이라는 이름이 어떻게 붙었는지 알려드리겠습니다. F는 분명히 종료입니다. 이 대기열의 모든 개체는 Finalize 메서드 실행을 기다리고 있습니다. 이는 이러한 개체가 도착한다는 의미입니다. 즉, Freachable 큐의 개체는 전역 변수나 정적 변수와 마찬가지로 관련 개체로 간주됩니다. 따라서 개체가 연결 가능한 대기열에 있으면 해당 개체는 가비지가 아닙니다.
간단히 말하면 개체에 접근할 수 없으면 가비지 수집기는 해당 개체를 가비지로 간주합니다. 그런 다음 가비지 수집기가 개체를 마무리 큐에서 Freachable 큐로 이동하면 이러한 개체는 더 이상 가비지가 아니며 메모리가 회수되지 않습니다. 이 시점에서 가비지 컬렉터는 가비지 표시를 완료했으며 가비지로 표시된 일부 객체는 가비지 아닌 객체로 재검토되었습니다. 가비지 수집기는 압축된 메모리를 회수하고, 연결 가능한 큐를 지우고, 큐에 있는 각 개체의 Finalize 메서드를 실행합니다.


그림 6 다시 가비지 수집을 수행한 후 관리되는 힙

再次出发垃圾回收之后,实现Finalize方法的对象才被真正的回收。这些对象的Finalize方法已经执行过了,Freachable队列清空了。

垃圾回收让对象复活

在前面部分我们已经说了,当程序不使用某个对象时,这个对象会被回收。然而,如果对象实现了Finalize方法,只有当对象的Finalize方法执行之后才会认为这个对象是可回收对象并真正回收其内存。换句话说,这类对象会先被标识为垃圾,然后放到freachable队列中复活,然后执行Finalize之后才被回收。正是Finalize方法的调用,让这种对象有机会复活,我们可以在Finalize方法中让某个对象强引用这个对象;那么垃圾回收器就认为这个对象不再是垃圾了,对象就复活了。
如下复活演示代码:

public class Foo {
~Foo(){
Application.ObjHolder = this;
  }
}
 
class Application{
  static public Object ObjHolder = null;
}
로그인 후 복사

在这种情况下,当对象的Finalize方法执行之后,对象被Application的静态字段ObjHolder强引用,成为根对象。这个对象就复活了,而这个对象引用的对象也就复活了,但是这些对象的Finalize方法可能已经执行过了,可能会有意想不到的错误发生。
事实上,当你设计自己的类型时,对象的终结和复活有可能完全不可控制。这不是一个好现象;处理这种情况的常用做法是在类中定义一个bool变量来表示对象是否执行过了Finalize方法,如果执行过Finalize方法,再执行其他方法时就抛出异常。
现在,如果有其他的代码片段又将Application.ObjHolder设置为null,这个对象变成不可达对象。最终垃圾回收器会把对象当成垃圾并回收对象内存。请注意这一次对象不会出现在finalization队列中,它的Finalize方法也不会再执行了。
复活只有有限的几种用处,你应该尽可能避免使用复活。尽管如此,当使用复活时,最好重新将对象添加到终结队列中,GC提供了静态方法ReRegisterForFinalize方法做这件事:

如下代码:

public class Foo{
~Foo(){
Application.ObjHolder = this;
GC.ReRegisterForFinalize(this);
}
}
로그인 후 복사

当对象复活时,重新将对象添加到复活队列中。需要注意的时如果一个对象已经在终结队列中,然后又调用了GC.ReRegisterForFinalize(obj)方法会导致此对象的Finalize方法重复执行。
垃圾回收机制的目的是为开发人员简化内存管理。
下一篇我们谈一下弱引用的作用,垃圾回收中的“代”,多线程中的垃圾回收和与垃圾回收相关的性能计数器。

 以上就是.Net 垃圾回收机制原理(一)的内容,更多相关内容请关注PHP中文网(www.php.cn)! 


원천:php.cn
본 웹사이트의 성명
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.
인기 튜토리얼
더>
최신 다운로드
더>
웹 효과
웹사이트 소스 코드
웹사이트 자료
프론트엔드 템플릿